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:
Ben Senescu 2026-03-10 14:36:16 -04:00 committed by GitHub
parent 1c26449c93
commit 3e489d0771
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 422 additions and 155 deletions

View File

@ -1,31 +1,22 @@
# Every App / Gateway settings # -----------------------------------------------------------------------------
VITE_APP_ID=open-seo # Core settings
# -----------------------------------------------------------------------------
# ------------------------------------------------------------- # Required for all modes. See README.md for setup instructions.
# # --- 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.
# DATAFORSEO_API_KEY= # DATAFORSEO_API_KEY=
# # Choose local app port # Optional 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):
# PORT=3001 # 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

View File

@ -4,6 +4,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
![OpenSEO demo (placeholder)](https://github.com/user-attachments/assets/6a928771-66ff-486b-b131-a54a3943985f) ![OpenSEO demo (placeholder)](https://github.com/user-attachments/assets/6a928771-66ff-486b-b131-a54a3943985f)
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/every-app/open-seo)
## Table of Contents ## Table of Contents
- [Why Use This](#why-use-this) - [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) - [Community](#community)
- [Pricing / Costs (Free + API costs)](#pricing--costs) - [Pricing / Costs (Free + API costs)](#pricing--costs)
- [DataForSEO API Key Setup](#dataforseo-api-key-setup) - [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) - [Local Development](#local-development)
- [Contributing](#contributing) - [Contributing](#contributing)
- [SEO API Cost Reference](#seo-api-cost-reference) - [SEO API Cost Reference](#seo-api-cost-reference)
@ -80,6 +84,73 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
- Docker self-hosting: `.env` - Docker self-hosting: `.env`
- Local development: `.env.local` - 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.
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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 ## Docker Self Hosting
Quickstart: Quickstart:
@ -113,11 +184,11 @@ cp .env.example .env.local
pnpm install pnpm install
# Initialize local DB schema (required on a fresh machine) # Initialize local DB schema (required on a fresh machine)
pnpm run db:migrate:local 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: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. `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). - `pnpm dev:agents` mirrors output to `.logs/dev-server.log` (gitignored).
- The log file is overwritten on each run. - 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 ### Database Commands
Generate migration: Generate migration:

View File

@ -1,8 +1,8 @@
# Docker Self-Hosting # 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 ## 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. This stack is local-first and uses dev runtimes to emulate Cloudflare Worker bindings.
- Do not expose these ports directly to the public internet. - Do not expose these ports directly to the public internet.
- There is no built-in Gateway auth in this mode. - 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#self-hosting-deploy-on-cloudflare-5-10-minutes)). - 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 ## 1) Configure env values
@ -33,8 +33,7 @@ Required:
Optional: Optional:
- `PORT` (defaults to `3001`) - `PORT` (defaults to `3001`)
- `VITE_APP_ID` (defaults to `open-seo`) - `AUTH_MODE=local_noauth` (Docker compose already sets this)
- `BYPASS_GATEWAY_LOCAL_ONLY=true` (Docker compose already sets this)
## 2) Start OpenSEO ## 2) Start OpenSEO

View File

@ -6,10 +6,7 @@ services:
working_dir: /app working_dir: /app
environment: environment:
- PORT=${PORT:-3001} - PORT=${PORT:-3001}
- BYPASS_GATEWAY_LOCAL_ONLY=true - AUTH_MODE=local_noauth
- 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}}
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
- VITE_SHOW_DEVTOOLS=false - VITE_SHOW_DEVTOOLS=false
command: command:

View File

@ -1,5 +0,0 @@
{
"appId": "open-seo",
"displayName": "OpenSEO",
"description": "Own your SEO. Research keywords and competitors on your terms.",
}

View File

@ -1,5 +1,5 @@
{ {
"ignoreBinaries": ["everyapp"], "ignoreBinaries": [],
"entry": [ "entry": [
// Detect Tanstack Start Routes // Detect Tanstack Start Routes
"src/router.tsx", "src/router.tsx",

View File

@ -5,9 +5,5 @@
"type": "local", "type": "local",
"command": ["npx", "-y", "@upstash/context7-mcp"], "command": ["npx", "-y", "@upstash/context7-mcp"],
}, },
"every-app": {
"type": "local",
"command": ["npx", "-y", "@every-app/mcp"],
},
}, },
} }

View File

@ -4,8 +4,8 @@
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "AUTH_MODE=local_noauth vite dev",
"dev:agents": "mkdir -p .logs && BYPASS_GATEWAY_LOCAL_ONLY=true portless run vite dev 2>&1 | tee .logs/dev-server.log", "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", "build": "vite build && tsc --noEmit",
"lint": "oxlint .", "lint": "oxlint .",
"lint:fix": "oxlint . --fix", "lint:fix": "oxlint . --fix",
@ -16,13 +16,27 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate:local": "drizzle-kit migrate", "db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:prod": "npx everyapp app remote-d1-shell -- drizzle-kit migrate --config=drizzle-prod.config.ts", "db:migrate:prod": "wrangler d1 migrations apply DB --remote",
"db:studio:local": "drizzle-kit studio",
"db:studio:prod": "npx everyapp app remote-d1-shell -- drizzle-kit studio --config=drizzle-prod.config.ts",
"knip": "knip", "knip": "knip",
"ci": "prettier --check . && knip && tsc --noEmit && oxlint ." "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": { "dependencies": {
"@every-app/sdk": "^0.1.13", "@every-app/sdk": "^0.1.13",
"@tanstack/query-core": "^5.90.9", "@tanstack/query-core": "^5.90.9",

View 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>
);
}

View File

@ -1,6 +1,10 @@
import { Link, rootRouteId, useMatch, useRouter } from "@tanstack/react-router"; import { Link, rootRouteId, useMatch, useRouter } from "@tanstack/react-router";
import type { ErrorComponentProps } 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) { export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter(); const router = useRouter();
@ -13,6 +17,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
error, error,
"Something went wrong. Please try again.", "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 ( return (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6"> <div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">

View File

@ -21,13 +21,7 @@ export function Sidebar({
return ( return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col"> <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"> <div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
<a <span className="font-semibold text-base-content">OpenSEO</span>
href={import.meta.env.VITE_GATEWAY_URL}
target="_top"
className="font-semibold text-base-content hover:text-primary transition-colors"
>
Every App
</a>
{onClose && ( {onClose && (
<button <button
onClick={onClose} 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"> <div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
{/* Header */} {/* Header */}
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between"> <div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
<a <span className="font-semibold text-base-content">OpenSEO</span>
href={import.meta.env.VITE_GATEWAY_URL}
target="_top"
className="font-semibold text-base-content hover:text-primary transition-colors"
>
Every App
</a>
{onClose && ( {onClose && (
<button <button
onClick={onClose} onClick={onClose}

View File

@ -2,6 +2,8 @@ import { isErrorCode, type ErrorCode } from "@/shared/error-codes";
const STANDARD_MESSAGES: Record<ErrorCode, string> = { const STANDARD_MESSAGES: Record<ErrorCode, string> = {
UNAUTHENTICATED: "Please sign in and try again.", 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.", FORBIDDEN: "You do not have access to this resource.",
NOT_FOUND: "The requested resource was not found.", NOT_FOUND: "The requested resource was not found.",
VALIDATION_ERROR: "Please check your input and try again.", 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]; if (isErrorCode(error.message)) return STANDARD_MESSAGES[error.message];
return fallback; 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
View File

@ -5,8 +5,9 @@ declare namespace Cloudflare {
interface Env { interface Env {
R2: R2Bucket; R2: R2Bucket;
// Gateway URL AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
GATEWAY_URL: string; TEAM_DOMAIN?: string;
POLICY_AUD?: string;
// DataForSEO API Basic auth value (base64 of login:password) // DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string; DATAFORSEO_API_KEY: string;

View File

@ -2,43 +2,166 @@ import { createMiddleware } from "@tanstack/react-start";
import { db } from "@/db"; import { db } from "@/db";
import { users } from "@/db/schema"; import { users } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import {
authenticateRequest,
getAuthConfig,
} from "@every-app/sdk/tanstack/server";
import { AppError } from "@/server/lib/errors"; 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 AuthMode = "cloudflare_access" | "local_noauth";
type: "function",
}).server(async (c) => {
const { next } = c;
const authConfig = getAuthConfig();
const session = await authenticateRequest(authConfig); const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost";
if (!session || !session.email) { const jwksByTeamDomain = new Map<
throw new AppError("UNAUTHENTICATED"); 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 return "cloudflare_access";
const user = await db.query.users.findFirst({ }
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), where: eq(users.id, userId),
}); });
if (!user) { if (!existingUser) {
await db.insert(users).values({ await db.insert(users).values({
id: userId, 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({ return next({
context: { context: {
userId, userId,
userEmail: user?.email || session.email, userEmail: ensuredEmail,
session,
}, },
}); });
}); });

View File

@ -19,7 +19,6 @@ import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url"; import appCss from "@/client/styles/app.css?url";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { Sidebar } from "@/client/components/Sidebar"; import { Sidebar } from "@/client/components/Sidebar";
import { EmbeddedAppProvider } from "@every-app/sdk/tanstack";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { projectNavItems } from "@/client/navigation/items"; import { projectNavItems } from "@/client/navigation/items";
@ -61,7 +60,6 @@ export const Route = createRootRoute({
sizes: "16x16", sizes: "16x16",
href: "/favicon-16x16.png", href: "/favicon-16x16.png",
}, },
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
], ],
scripts: [], scripts: [],
@ -95,20 +93,14 @@ function AppLayout() {
> >
<Menu className="h-6 w-6" /> <Menu className="h-6 w-6" />
</button> </button>
<span className="font-semibold text-base-content ml-1"> <span className="font-semibold text-base-content ml-1">OpenSEO</span>
Every App
</span>
</div> </div>
{/* Desktop: EveryApp brand + nav links (left) */} {/* Desktop: app brand + nav links (left) */}
<div className="hidden md:flex items-center gap-1"> <div className="hidden md:flex items-center gap-1">
<a <span className="text-lg font-semibold text-base-content px-2">
href={import.meta.env.VITE_GATEWAY_URL} OpenSEO
target="_top" </span>
className="text-lg font-semibold text-base-content hover:text-primary transition-colors px-2"
>
Every App
</a>
{projectId && {projectId &&
projectNavItems.map((item) => { projectNavItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
@ -195,28 +187,23 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body> <body>
<ClientOnly> <ClientOnly>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<EmbeddedAppProvider appId={import.meta.env.VITE_APP_ID}> <>
<> {children}
{children} <Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} />
<Toaster {showDevtools ? (
position="bottom-right" <TanStackDevtools
mobileOffset={{ bottom: 100 }} config={{ position: "bottom-right" }}
eventBusConfig={{ connectToServerBus: true }}
plugins={[
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
defaultOpen: true,
},
]}
/> />
{showDevtools ? ( ) : null}
<TanStackDevtools </>
config={{ position: "bottom-right" }}
eventBusConfig={{ connectToServerBus: true }}
plugins={[
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
defaultOpen: true,
},
]}
/>
) : null}
</>
</EmbeddedAppProvider>
</QueryClientProvider> </QueryClientProvider>
</ClientOnly> </ClientOnly>
<Scripts /> <Scripts />

View File

@ -2,7 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { getOrCreateDefaultProject } from "@/serverFunctions/keywords"; 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("/")({ export const Route = createFileRoute("/")({
component: IndexRedirect, component: IndexRedirect,
@ -26,14 +30,34 @@ function IndexRedirect() {
}, [mutate]); }, [mutate]);
if (isError) { 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 ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full p-4">
<p className="text-error"> <div className="flex flex-col items-center gap-3 max-w-xl">
{getStandardErrorMessage( <p className="text-error text-center">
error, {getStandardErrorMessage(
"An unexpected error occurred. Please check server logs.", error,
)} "An unexpected error occurred. Please check server logs.",
</p> )}
</p>
</div>
</div> </div>
); );
} }

View File

@ -1,7 +1,6 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCurrentUser } from "@every-app/sdk/tanstack";
import { getProject } from "@/serverFunctions/keywords"; import { getProject } from "@/serverFunctions/keywords";
export const Route = createFileRoute("/p/$projectId")({ export const Route = createFileRoute("/p/$projectId")({
@ -12,7 +11,6 @@ function ProjectLayout() {
const params = Route.useParams(); const params = Route.useParams();
const { projectId } = params; const { projectId } = params;
const navigate = useNavigate(); const navigate = useNavigate();
const user = useCurrentUser();
const { const {
data: project, data: project,
@ -21,14 +19,13 @@ function ProjectLayout() {
} = useQuery({ } = useQuery({
queryKey: ["project", projectId], queryKey: ["project", projectId],
queryFn: () => getProject({ data: { projectId } }), queryFn: () => getProject({ data: { projectId } }),
enabled: user !== null,
}); });
useEffect(() => { useEffect(() => {
if (user !== null && !isLoading && (isError || !project)) { if (!isLoading && (isError || !project)) {
void navigate({ to: "/" }); void navigate({ to: "/" });
} }
}, [isLoading, isError, project, navigate, user]); }, [isLoading, isError, project, navigate]);
if (isLoading) { if (isLoading) {
return ( return (

View File

@ -1,9 +1,7 @@
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { errorHandlingMiddleware } from "@/middleware/errorHandling"; import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { ensureUserMiddleware } from "@/middleware/ensureUser";
export const authenticatedServerFunctionMiddleware = [ export const authenticatedServerFunctionMiddleware = [
errorHandlingMiddleware, errorHandlingMiddleware,
useSessionTokenClientMiddleware,
ensureUserMiddleware, ensureUserMiddleware,
] as const; ] as const;

View File

@ -1,5 +1,6 @@
export const ERROR_CODES = [ export const ERROR_CODES = [
"UNAUTHENTICATED", "UNAUTHENTICATED",
"AUTH_CONFIG_MISSING",
"FORBIDDEN", "FORBIDDEN",
"NOT_FOUND", "NOT_FOUND",
"VALIDATION_ERROR", "VALIDATION_ERROR",

View File

@ -6,8 +6,6 @@ interface ViteTypeOptions {
} }
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_GATEWAY_URL: string;
readonly VITE_APP_ID: string;
readonly VITE_SHOW_DEVTOOLS?: string; readonly VITE_SHOW_DEVTOOLS?: string;
// more env variables... // more env variables...
} }

View File

@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false"; const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
return { return {
envPrefix: ["VITE_", "BYPASS_GATEWAY_LOCAL_ONLY"], envPrefix: ["VITE_"],
server: { server: {
port, port,
}, },

View File

@ -1,5 +1,5 @@
/* eslint-disable */ /* 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 // Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
declare namespace Cloudflare { declare namespace Cloudflare {
interface GlobalProps { interface GlobalProps {
@ -13,7 +13,11 @@ declare namespace Cloudflare {
VITE_GATEWAY_URL: string; VITE_GATEWAY_URL: string;
GATEWAY_URL: string; GATEWAY_URL: string;
GATEWAY_APP_API_TOKEN: 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']>; 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; [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
}; };
declare namespace NodeJS { 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 // Begin runtime types

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "every-open-seo", "name": "open-seo",
"main": "src/server.ts", "main": "src/server.ts",
"compatibility_date": "2025-09-02", "compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
@ -14,12 +14,6 @@
"class_name": "SiteAuditWorkflow", "class_name": "SiteAuditWorkflow",
}, },
], ],
"services": [
{
"binding": "EVERY_APP_GATEWAY",
"service": "every-app-gateway",
},
],
"kv_namespaces": [ "kv_namespaces": [
{ {
"binding": "KV", "binding": "KV",
@ -29,8 +23,8 @@
"d1_databases": [ "d1_databases": [
{ {
"binding": "DB", "binding": "DB",
"database_name": "every-open-seo", "database_name": "open-seo",
"database_id": "dc7eb9f1-9a43-4d37-9bc9-1293a9200f13", "database_id": "37bee90a-e1aa-404f-b01e-b0d1d479bda1",
"migrations_dir": "drizzle", "migrations_dir": "drizzle",
}, },
], ],