feat: remove Every App dependency (#8)
* remove Every App SDK and add auth modes for Cloudflare Access and local_noauth * align local dev auth defaults and normalize Access team domain * Apply suggestions from code review * restore local drizzle D1 URL helper * save * fix auth error mapping and document self-hosting setup * Tweak readme * improve auth config error UI and remove manifest link * fix team domain config validation and docs anchor
This commit is contained in:
parent
1c26449c93
commit
3e489d0771
43
.env.example
43
.env.example
@ -1,31 +1,22 @@
|
||||
# Every App / Gateway settings
|
||||
VITE_APP_ID=open-seo
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# # --- Local development --
|
||||
# # Run everyapp app setup-local to automatically configure you .env.local if you've set up your gateway in cloudflare
|
||||
# # This should be: https://every-app-gateway.
|
||||
# VITE_GATEWAY_URL=
|
||||
# GATEWAY_URL=
|
||||
|
||||
# # See README.md for instructions for how to get this.
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core settings
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required for all modes. See README.md for setup instructions.
|
||||
# DATAFORSEO_API_KEY=
|
||||
|
||||
# # Choose local app port
|
||||
# # PORT=3001
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# --- Docker self-hosting only (local mode) ---
|
||||
# See SELF_HOSTING_DOCKER.md
|
||||
# Docker compose runs with BYPASS_GATEWAY_LOCAL_ONLY=true.
|
||||
# Optional host/container port (defaults to 3001):
|
||||
# Optional app port
|
||||
# PORT=3001
|
||||
# Optional in .env because compose already sets it.
|
||||
# BYPASS_GATEWAY_LOCAL_ONLY=true
|
||||
# Optional override (defaults to open-seo):
|
||||
# VITE_APP_ID=open-seo
|
||||
|
||||
# # See README.md for instructions for how to get this.
|
||||
# DATAFORSEO_API_KEY=
|
||||
# -----------------------------------------------------------------------------
|
||||
# Auth mode
|
||||
# -----------------------------------------------------------------------------
|
||||
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
|
||||
# - local_noauth: local trusted mode with injected admin user (admin@localhost)
|
||||
# - hosted: reserved for future hosted auth flow (not implemented yet)
|
||||
#
|
||||
# Defaults to cloudflare_access when unset.
|
||||
# AUTH_MODE=cloudflare_access
|
||||
|
||||
# Legacy gateway auth keys are not needed in Docker self-host mode.
|
||||
# Required when AUTH_MODE=cloudflare_access
|
||||
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
|
||||
# POLICY_AUD=your-cloudflare-access-aud-tag
|
||||
|
||||
88
README.md
88
README.md
@ -4,6 +4,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
||||
|
||||

|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/every-app/open-seo)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Use This](#why-use-this)
|
||||
@ -12,7 +14,9 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
||||
- [Community](#community)
|
||||
- [Pricing / Costs (Free + API costs)](#pricing--costs)
|
||||
- [DataForSEO API Key Setup](#dataforseo-api-key-setup)
|
||||
- [Docker Self Hosting](#docker-self-hosting)
|
||||
- [Self-hosting](#self-hosting)
|
||||
- [Cloudflare Deployment + Access Setup](#cloudflare-deployment--access-setup)
|
||||
- [Docker Self Hosting](#docker-self-hosting)
|
||||
- [Local Development](#local-development)
|
||||
- [Contributing](#contributing)
|
||||
- [SEO API Cost Reference](#seo-api-cost-reference)
|
||||
@ -80,6 +84,73 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
|
||||
- Docker self-hosting: `.env`
|
||||
- Local development: `.env.local`
|
||||
|
||||
## Self-hosting
|
||||
|
||||
OpenSEO supports two self-hosting paths:
|
||||
|
||||
- Cloudflare for hosting on the internet (Recommended).
|
||||
- Docker for your homelab or local use.
|
||||
|
||||
Use this quick guide:
|
||||
|
||||
- Choose Cloudflare when:
|
||||
- You've never used Docker before.
|
||||
- You want a more SaaS like experience.
|
||||
- You want to use it from multiple devices or with teammates.
|
||||
- You want support for more powerful features in the future like sharing public links to reports or site audits rendering your websites javascript.
|
||||
- Choose Docker when:
|
||||
- You already have Docker installed and want to get setup most quickly.
|
||||
- You have a homelab setup.
|
||||
- You only want to use OpenSEO locally on one device.
|
||||
|
||||
## Cloudflare Deployment + Access Setup
|
||||
|
||||
You can use the Deploy button at the top of this README or run with Wrangler directly.
|
||||
|
||||
### 1) Deploy the Worker
|
||||
|
||||
Clicking this button will open a page to deploy OpenSEO in your Cloudflare account.
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/every-app/open-seo)
|
||||
|
||||
### 2) Set required environment variables
|
||||
|
||||
OpenSEO needs these vars in production:
|
||||
|
||||
- `DATAFORSEO_API_KEY`: base64-encoded `login:password` from DataForSEO.
|
||||
- `AUTH_MODE=cloudflare_access`
|
||||
- `TEAM_DOMAIN`: your Access team domain (for example `https://your-team.cloudflareaccess.com`).
|
||||
- `POLICY_AUD`: Access Application Audience tag for your OpenSEO app.
|
||||
|
||||
You can set plain vars and secrets with Wrangler:
|
||||
|
||||
```sh
|
||||
pnpm exec wrangler secret put DATAFORSEO_API_KEY
|
||||
pnpm exec wrangler secret put POLICY_AUD
|
||||
pnpm exec wrangler secret put TEAM_DOMAIN
|
||||
pnpm exec wrangler secret put AUTH_MODE
|
||||
```
|
||||
|
||||
You can also set these in Cloudflare Dashboard under Worker Settings.
|
||||
|
||||
### 3) Configure Cloudflare Access
|
||||
|
||||
In Cloudflare Zero Trust:
|
||||
|
||||
1. Create an Access application for your OpenSEO route/domain.
|
||||
2. Add policy rules for the identities/groups allowed to access OpenSEO.
|
||||
3. Ensure requests to your Worker include `cf-access-jwt-assertion`.
|
||||
4. Copy values into OpenSEO config:
|
||||
- Access team domain -> `TEAM_DOMAIN`
|
||||
- Access app AUD tag -> `POLICY_AUD`
|
||||
|
||||
### 4) Validate setup
|
||||
|
||||
- Visit your OpenSEO URL.
|
||||
- You should be prompted by Cloudflare Access if not signed in.
|
||||
- After sign-in, the app should load normally.
|
||||
- If config is missing, OpenSEO shows an in-app setup error that links back to this section.
|
||||
|
||||
## Docker Self Hosting
|
||||
|
||||
Quickstart:
|
||||
@ -113,11 +184,11 @@ cp .env.example .env.local
|
||||
pnpm install
|
||||
# Initialize local DB schema (required on a fresh machine)
|
||||
pnpm run db:migrate:local
|
||||
# This runs in BYPASS_GATEWAY mode for local use and quick testing.
|
||||
# This runs in local_noauth mode for local use and quick testing.
|
||||
pnpm dev:agents
|
||||
```
|
||||
|
||||
`pnpm dev` runs on `http://localhost:3001` by default (or `PORT` from `.env.local`).
|
||||
`pnpm dev` runs on `http://localhost:3001` by default (or `PORT` from `.env.local`) in `AUTH_MODE=local_noauth`.
|
||||
|
||||
`pnpm dev:agents` runs through [portless](https://github.com/vercel-labs/portless) at `http://open-seo.localhost:1355` by default.
|
||||
|
||||
@ -140,6 +211,17 @@ pnpm dev:agents
|
||||
- `pnpm dev:agents` mirrors output to `.logs/dev-server.log` (gitignored).
|
||||
- The log file is overwritten on each run.
|
||||
|
||||
### Auth modes
|
||||
|
||||
- `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`.
|
||||
- `AUTH_MODE=local_noauth`: local trusted mode, no auth check, injects `admin@localhost`.
|
||||
- `AUTH_MODE=hosted`: reserved for upcoming multi-tenant auth flow (not yet implemented).
|
||||
|
||||
Local scripts (`pnpm dev` and `pnpm dev:agents`) set `AUTH_MODE=local_noauth` automatically.
|
||||
Use `AUTH_MODE=cloudflare_access pnpm dev` when you specifically want to test Access validation locally.
|
||||
|
||||
For Cloudflare deployments, ensure Cloudflare Access is enabled on your Worker route/domain and provide `TEAM_DOMAIN` + `POLICY_AUD` in environment variables.
|
||||
|
||||
### Database Commands
|
||||
|
||||
Generate migration:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Docker Self-Hosting
|
||||
|
||||
This guide runs OpenSEO as a local service without Every App Gateway.
|
||||
This guide runs OpenSEO as a local service.
|
||||
|
||||
In this mode, OpenSEO runs with `BYPASS_GATEWAY_LOCAL_ONLY=true`, so authentication and Gateway-managed user accounts are disabled.
|
||||
In this mode, OpenSEO runs with `AUTH_MODE=local_noauth`, so request authentication is disabled and a local admin user (`admin@localhost`) is injected automatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -13,8 +13,8 @@ In this mode, OpenSEO runs with `BYPASS_GATEWAY_LOCAL_ONLY=true`, so authenticat
|
||||
This stack is local-first and uses dev runtimes to emulate Cloudflare Worker bindings.
|
||||
|
||||
- Do not expose these ports directly to the public internet.
|
||||
- There is no built-in Gateway auth in this mode.
|
||||
- If you expose it beyond localhost, put it behind the same authentication layer you use for your other self-hosted services (or use the [Cloudflare deployment path](./README.md#self-hosting-deploy-on-cloudflare-5-10-minutes)).
|
||||
- There is no built-in auth check in this mode.
|
||||
- If you expose it beyond localhost, put it behind the same authentication layer you use for your other self-hosted services (or use the [Cloudflare deployment path](./README.md#cloudflare-deployment--access-setup)).
|
||||
|
||||
## 1) Configure env values
|
||||
|
||||
@ -33,8 +33,7 @@ Required:
|
||||
Optional:
|
||||
|
||||
- `PORT` (defaults to `3001`)
|
||||
- `VITE_APP_ID` (defaults to `open-seo`)
|
||||
- `BYPASS_GATEWAY_LOCAL_ONLY=true` (Docker compose already sets this)
|
||||
- `AUTH_MODE=local_noauth` (Docker compose already sets this)
|
||||
|
||||
## 2) Start OpenSEO
|
||||
|
||||
|
||||
@ -6,10 +6,7 @@ services:
|
||||
working_dir: /app
|
||||
environment:
|
||||
- PORT=${PORT:-3001}
|
||||
- BYPASS_GATEWAY_LOCAL_ONLY=true
|
||||
- VITE_APP_ID=${VITE_APP_ID:-open-seo}
|
||||
- VITE_GATEWAY_URL=${VITE_GATEWAY_URL:-http://localhost:${PORT:-3001}}
|
||||
- GATEWAY_URL=${GATEWAY_URL:-http://localhost:${PORT:-3001}}
|
||||
- AUTH_MODE=local_noauth
|
||||
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
||||
- VITE_SHOW_DEVTOOLS=false
|
||||
command:
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"appId": "open-seo",
|
||||
"displayName": "OpenSEO",
|
||||
"description": "Own your SEO. Research keywords and competitors on your terms.",
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoreBinaries": ["everyapp"],
|
||||
"ignoreBinaries": [],
|
||||
"entry": [
|
||||
// Detect Tanstack Start Routes
|
||||
"src/router.tsx",
|
||||
|
||||
@ -5,9 +5,5 @@
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@upstash/context7-mcp"],
|
||||
},
|
||||
"every-app": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@every-app/mcp"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
26
package.json
26
package.json
@ -4,8 +4,8 @@
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:agents": "mkdir -p .logs && BYPASS_GATEWAY_LOCAL_ONLY=true portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
||||
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint . --fix",
|
||||
@ -16,13 +16,27 @@
|
||||
"format:check": "prettier --check .",
|
||||
"format:write": "prettier . --write",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate:local": "drizzle-kit migrate",
|
||||
"db:migrate:prod": "npx everyapp app remote-d1-shell -- drizzle-kit migrate --config=drizzle-prod.config.ts",
|
||||
"db:studio:local": "drizzle-kit studio",
|
||||
"db:studio:prod": "npx everyapp app remote-d1-shell -- drizzle-kit studio --config=drizzle-prod.config.ts",
|
||||
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
||||
"knip": "knip",
|
||||
"ci": "prettier --check . && knip && tsc --noEmit && oxlint ."
|
||||
},
|
||||
"cloudflare": {
|
||||
"bindings": {
|
||||
"AUTH_MODE": {
|
||||
"description": "Set to `cloudflare_access` for secured deployments behind Cloudflare Access, or `local_noauth` for trusted local self-host mode only."
|
||||
},
|
||||
"TEAM_DOMAIN": {
|
||||
"description": "Cloudflare Access team domain (for example `https://your-team.cloudflareaccess.com`) used to validate `cf-access-jwt-assertion` JWTs."
|
||||
},
|
||||
"POLICY_AUD": {
|
||||
"description": "Cloudflare Access Application Audience (AUD) tag for this Worker route/domain."
|
||||
},
|
||||
"DATAFORSEO_API_KEY": {
|
||||
"description": "Base64-encoded `login:password` for DataForSEO API access."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@every-app/sdk": "^0.1.13",
|
||||
"@tanstack/query-core": "^5.90.9",
|
||||
|
||||
52
src/client/components/AuthConfigErrorCard.tsx
Normal file
52
src/client/components/AuthConfigErrorCard.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
const README_CLOUDFLARE_ACCESS_URL =
|
||||
"https://github.com/every-app/open-seo#cloudflare-deployment--access-setup";
|
||||
|
||||
type AuthConfigErrorCardProps = {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export function AuthConfigErrorCard({
|
||||
message,
|
||||
onRetry,
|
||||
}: AuthConfigErrorCardProps) {
|
||||
return (
|
||||
<div className="card w-full max-w-2xl bg-base-100 border border-base-300 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title gap-2">
|
||||
<ShieldAlert className="size-5 text-error" />
|
||||
Cloudflare Access setup required
|
||||
</h2>
|
||||
|
||||
<div className="alert alert-error">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-base-content/70">
|
||||
This deployment is missing required Access settings for
|
||||
<code className="mx-1">AUTH_MODE=cloudflare_access</code>. Configure{" "}
|
||||
<code className="mx-1">TEAM_DOMAIN</code> and
|
||||
<code className="ml-1">POLICY_AUD</code>, then retry.
|
||||
</p>
|
||||
|
||||
<div className="card-actions justify-end">
|
||||
{onRetry ? (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onRetry}>
|
||||
Try Again
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
className="btn btn-primary btn-sm"
|
||||
href={README_CLOUDFLARE_ACCESS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open Setup Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
import { Link, rootRouteId, useMatch, useRouter } from "@tanstack/react-router";
|
||||
import type { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
} from "@/client/lib/error-messages";
|
||||
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
@ -13,6 +17,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
error,
|
||||
"Something went wrong. Please try again.",
|
||||
);
|
||||
const errorCode = getErrorCode(error);
|
||||
const showAuthConfigHelp = errorCode === "AUTH_CONFIG_MISSING";
|
||||
|
||||
if (showAuthConfigHelp) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex items-center justify-center">
|
||||
<AuthConfigErrorCard
|
||||
message={message}
|
||||
onRetry={() => {
|
||||
router.invalidate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
|
||||
@ -21,13 +21,7 @@ export function Sidebar({
|
||||
return (
|
||||
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
|
||||
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
|
||||
<a
|
||||
href={import.meta.env.VITE_GATEWAY_URL}
|
||||
target="_top"
|
||||
className="font-semibold text-base-content hover:text-primary transition-colors"
|
||||
>
|
||||
Every App
|
||||
</a>
|
||||
<span className="font-semibold text-base-content">OpenSEO</span>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
@ -49,13 +43,7 @@ export function Sidebar({
|
||||
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
|
||||
<a
|
||||
href={import.meta.env.VITE_GATEWAY_URL}
|
||||
target="_top"
|
||||
className="font-semibold text-base-content hover:text-primary transition-colors"
|
||||
>
|
||||
Every App
|
||||
</a>
|
||||
<span className="font-semibold text-base-content">OpenSEO</span>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@ -2,6 +2,8 @@ import { isErrorCode, type ErrorCode } from "@/shared/error-codes";
|
||||
|
||||
const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
UNAUTHENTICATED: "Please sign in and try again.",
|
||||
AUTH_CONFIG_MISSING:
|
||||
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
|
||||
FORBIDDEN: "You do not have access to this resource.",
|
||||
NOT_FOUND: "The requested resource was not found.",
|
||||
VALIDATION_ERROR: "Please check your input and try again.",
|
||||
@ -20,3 +22,8 @@ export function getStandardErrorMessage(
|
||||
if (isErrorCode(error.message)) return STANDARD_MESSAGES[error.message];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getErrorCode(error: unknown): ErrorCode | null {
|
||||
if (!(error instanceof Error)) return null;
|
||||
return isErrorCode(error.message) ? error.message : null;
|
||||
}
|
||||
|
||||
5
src/env.d.ts
vendored
5
src/env.d.ts
vendored
@ -5,8 +5,9 @@ declare namespace Cloudflare {
|
||||
interface Env {
|
||||
R2: R2Bucket;
|
||||
|
||||
// Gateway URL
|
||||
GATEWAY_URL: string;
|
||||
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
TEAM_DOMAIN?: string;
|
||||
POLICY_AUD?: string;
|
||||
|
||||
// DataForSEO API Basic auth value (base64 of login:password)
|
||||
DATAFORSEO_API_KEY: string;
|
||||
|
||||
@ -2,43 +2,166 @@ import { createMiddleware } from "@tanstack/react-start";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
authenticateRequest,
|
||||
getAuthConfig,
|
||||
} from "@every-app/sdk/tanstack/server";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async (c) => {
|
||||
const { next } = c;
|
||||
const authConfig = getAuthConfig();
|
||||
type AuthMode = "cloudflare_access" | "local_noauth";
|
||||
|
||||
const session = await authenticateRequest(authConfig);
|
||||
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||
|
||||
if (!session || !session.email) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
const jwksByTeamDomain = new Map<
|
||||
string,
|
||||
ReturnType<typeof createRemoteJWKSet>
|
||||
>();
|
||||
|
||||
function getAuthMode(): AuthMode {
|
||||
const value = env.AUTH_MODE;
|
||||
|
||||
if (value === "local_noauth" || value === "cloudflare_access") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const userId = session.sub;
|
||||
if (value === "hosted") {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
"AUTH_MODE=hosted is not implemented yet",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.query.users.findFirst({
|
||||
return "cloudflare_access";
|
||||
}
|
||||
|
||||
function getJwks(teamDomain: string) {
|
||||
const existing = jwksByTeamDomain.get(teamDomain);
|
||||
if (existing) return existing;
|
||||
|
||||
const jwks = createRemoteJWKSet(
|
||||
new URL(`${teamDomain}/cdn-cgi/access/certs`),
|
||||
);
|
||||
jwksByTeamDomain.set(teamDomain, jwks);
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function normalizeTeamDomain(teamDomain: string) {
|
||||
return teamDomain.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getValidatedTeamDomain(teamDomain: string) {
|
||||
const normalized = normalizeTeamDomain(teamDomain);
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("TEAM_DOMAIN must use https");
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"TEAM_DOMAIN must be a full https URL like https://your-team.cloudflareaccess.com",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUserRecord(userId: string, userEmail: string) {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!existingUser) {
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
email: session.email,
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
if (existingUser.email !== userEmail) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ email: userEmail })
|
||||
.where(eq(users.id, userId));
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
return existingUser.email;
|
||||
}
|
||||
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next }) => {
|
||||
const authMode = getAuthMode();
|
||||
|
||||
if (authMode === "local_noauth") {
|
||||
const userEmail = await ensureUserRecord(
|
||||
LOCAL_ADMIN_USER_ID,
|
||||
LOCAL_ADMIN_EMAIL,
|
||||
);
|
||||
|
||||
return next({
|
||||
context: {
|
||||
userId: LOCAL_ADMIN_USER_ID,
|
||||
userEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const request = getRequest();
|
||||
|
||||
const teamDomain = env.TEAM_DOMAIN
|
||||
? getValidatedTeamDomain(env.TEAM_DOMAIN)
|
||||
: null;
|
||||
const policyAud = env.POLICY_AUD?.trim() || null;
|
||||
|
||||
if (!teamDomain || !policyAud) {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"Missing Cloudflare Access configuration",
|
||||
);
|
||||
}
|
||||
|
||||
const token = request.headers.get("cf-access-jwt-assertion");
|
||||
|
||||
if (!token) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
let userEmail: string;
|
||||
|
||||
try {
|
||||
const JWKS = getJwks(teamDomain);
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: teamDomain,
|
||||
audience: policyAud,
|
||||
});
|
||||
|
||||
userId = typeof payload.sub === "string" ? payload.sub : "";
|
||||
userEmail = typeof payload.email === "string" ? payload.email : "";
|
||||
|
||||
if (!userId || !userEmail) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
const ensuredEmail = await ensureUserRecord(userId, userEmail);
|
||||
|
||||
return next({
|
||||
context: {
|
||||
userId,
|
||||
userEmail: user?.email || session.email,
|
||||
session,
|
||||
userEmail: ensuredEmail,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,7 +19,6 @@ import { NotFound } from "@/client/components/NotFound";
|
||||
import appCss from "@/client/styles/app.css?url";
|
||||
import { Toaster } from "sonner";
|
||||
import { Sidebar } from "@/client/components/Sidebar";
|
||||
import { EmbeddedAppProvider } from "@every-app/sdk/tanstack";
|
||||
import { queryClient } from "@/client/tanstack-db";
|
||||
import { projectNavItems } from "@/client/navigation/items";
|
||||
|
||||
@ -61,7 +60,6 @@ export const Route = createRootRoute({
|
||||
sizes: "16x16",
|
||||
href: "/favicon-16x16.png",
|
||||
},
|
||||
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
],
|
||||
scripts: [],
|
||||
@ -95,20 +93,14 @@ function AppLayout() {
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<span className="font-semibold text-base-content ml-1">
|
||||
Every App
|
||||
</span>
|
||||
<span className="font-semibold text-base-content ml-1">OpenSEO</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: EveryApp brand + nav links (left) */}
|
||||
{/* Desktop: app brand + nav links (left) */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href={import.meta.env.VITE_GATEWAY_URL}
|
||||
target="_top"
|
||||
className="text-lg font-semibold text-base-content hover:text-primary transition-colors px-2"
|
||||
>
|
||||
Every App
|
||||
</a>
|
||||
<span className="text-lg font-semibold text-base-content px-2">
|
||||
OpenSEO
|
||||
</span>
|
||||
{projectId &&
|
||||
projectNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@ -195,28 +187,23 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<body>
|
||||
<ClientOnly>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EmbeddedAppProvider appId={import.meta.env.VITE_APP_ID}>
|
||||
<>
|
||||
{children}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
mobileOffset={{ bottom: 100 }}
|
||||
<>
|
||||
{children}
|
||||
<Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} />
|
||||
{showDevtools ? (
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-right" }}
|
||||
eventBusConfig={{ connectToServerBus: true }}
|
||||
plugins={[
|
||||
{
|
||||
name: "TanStack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
defaultOpen: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{showDevtools ? (
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-right" }}
|
||||
eventBusConfig={{ connectToServerBus: true }}
|
||||
plugins={[
|
||||
{
|
||||
name: "TanStack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
defaultOpen: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</EmbeddedAppProvider>
|
||||
) : null}
|
||||
</>
|
||||
</QueryClientProvider>
|
||||
</ClientOnly>
|
||||
<Scripts />
|
||||
|
||||
@ -2,7 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getOrCreateDefaultProject } from "@/serverFunctions/keywords";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
} from "@/client/lib/error-messages";
|
||||
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: IndexRedirect,
|
||||
@ -26,14 +30,34 @@ function IndexRedirect() {
|
||||
}, [mutate]);
|
||||
|
||||
if (isError) {
|
||||
const errorCode = getErrorCode(error);
|
||||
|
||||
if (errorCode === "AUTH_CONFIG_MISSING") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<AuthConfigErrorCard
|
||||
message={getStandardErrorMessage(
|
||||
error,
|
||||
"An unexpected error occurred. Please check server logs.",
|
||||
)}
|
||||
onRetry={() => {
|
||||
mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-error">
|
||||
{getStandardErrorMessage(
|
||||
error,
|
||||
"An unexpected error occurred. Please check server logs.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="flex flex-col items-center gap-3 max-w-xl">
|
||||
<p className="text-error text-center">
|
||||
{getStandardErrorMessage(
|
||||
error,
|
||||
"An unexpected error occurred. Please check server logs.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCurrentUser } from "@every-app/sdk/tanstack";
|
||||
import { getProject } from "@/serverFunctions/keywords";
|
||||
|
||||
export const Route = createFileRoute("/p/$projectId")({
|
||||
@ -12,7 +11,6 @@ function ProjectLayout() {
|
||||
const params = Route.useParams();
|
||||
const { projectId } = params;
|
||||
const navigate = useNavigate();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const {
|
||||
data: project,
|
||||
@ -21,14 +19,13 @@ function ProjectLayout() {
|
||||
} = useQuery({
|
||||
queryKey: ["project", projectId],
|
||||
queryFn: () => getProject({ data: { projectId } }),
|
||||
enabled: user !== null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null && !isLoading && (isError || !project)) {
|
||||
if (!isLoading && (isError || !project)) {
|
||||
void navigate({ to: "/" });
|
||||
}
|
||||
}, [isLoading, isError, project, navigate, user]);
|
||||
}, [isLoading, isError, project, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
|
||||
export const authenticatedServerFunctionMiddleware = [
|
||||
errorHandlingMiddleware,
|
||||
useSessionTokenClientMiddleware,
|
||||
ensureUserMiddleware,
|
||||
] as const;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const ERROR_CODES = [
|
||||
"UNAUTHENTICATED",
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"VALIDATION_ERROR",
|
||||
|
||||
2
src/types/vite-env.d.ts
vendored
2
src/types/vite-env.d.ts
vendored
@ -6,8 +6,6 @@ interface ViteTypeOptions {
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GATEWAY_URL: string;
|
||||
readonly VITE_APP_ID: string;
|
||||
readonly VITE_SHOW_DEVTOOLS?: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
|
||||
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
|
||||
|
||||
return {
|
||||
envPrefix: ["VITE_", "BYPASS_GATEWAY_LOCAL_ONLY"],
|
||||
envPrefix: ["VITE_"],
|
||||
server: {
|
||||
port,
|
||||
},
|
||||
|
||||
10
worker-configuration.d.ts
vendored
10
worker-configuration.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
/* eslint-disable */
|
||||
// Generated by Wrangler by running `wrangler types` (hash: aac0d6d9ff3ca42b08edafb09451cfdf)
|
||||
// Generated by Wrangler by running `wrangler types` (hash: 0e03c138742d121a834f2931f5295961)
|
||||
// Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
|
||||
declare namespace Cloudflare {
|
||||
interface GlobalProps {
|
||||
@ -13,7 +13,11 @@ declare namespace Cloudflare {
|
||||
VITE_GATEWAY_URL: string;
|
||||
GATEWAY_URL: string;
|
||||
GATEWAY_APP_API_TOKEN: string;
|
||||
EVERY_APP_GATEWAY: Fetcher /* every-app-gateway */;
|
||||
DATAFORSEO_API_KEY: string;
|
||||
PORT: string;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
JWT_PRIVATE_KEY: string;
|
||||
JWT_PUBLIC_KEY: string;
|
||||
SITE_AUDIT_WORKFLOW: Workflow<Parameters<import("./src/server").SiteAuditWorkflow['run']>[0]['payload']>;
|
||||
}
|
||||
}
|
||||
@ -22,7 +26,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||
};
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "VITE_APP_ID" | "VITE_GATEWAY_URL" | "GATEWAY_URL" | "GATEWAY_APP_API_TOKEN">> {}
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "VITE_APP_ID" | "VITE_GATEWAY_URL" | "GATEWAY_URL" | "GATEWAY_APP_API_TOKEN" | "DATAFORSEO_API_KEY" | "PORT" | "BETTER_AUTH_SECRET" | "JWT_PRIVATE_KEY" | "JWT_PUBLIC_KEY">> {}
|
||||
}
|
||||
|
||||
// Begin runtime types
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "every-open-seo",
|
||||
"name": "open-seo",
|
||||
"main": "src/server.ts",
|
||||
"compatibility_date": "2025-09-02",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
@ -14,12 +14,6 @@
|
||||
"class_name": "SiteAuditWorkflow",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "EVERY_APP_GATEWAY",
|
||||
"service": "every-app-gateway",
|
||||
},
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "KV",
|
||||
@ -29,8 +23,8 @@
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "every-open-seo",
|
||||
"database_id": "dc7eb9f1-9a43-4d37-9bc9-1293a9200f13",
|
||||
"database_name": "open-seo",
|
||||
"database_id": "37bee90a-e1aa-404f-b01e-b0d1d479bda1",
|
||||
"migrations_dir": "drizzle",
|
||||
},
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user