Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
"port": 4321,
"autoPort": true
},
{
"name": "stack-raw",
"runtimeExecutable": "zsh",
"runtimeArgs": [
"-c",
"export NVM_DIR=\"${NVM_DIR:-$HOME/.nvm}\"; [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" && nvm use --silent; PORTLESS=0 node scripts/dev-stack.mjs"
],
"port": 4321
},
{
"name": "web-preview",
"runtimeExecutable": "pnpm",
Expand Down
17 changes: 16 additions & 1 deletion apps/auth/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,25 @@ describe("deriveCookieDomain", () => {
expect(deriveCookieDomain("http://localhost:8788")).toBeUndefined();
});

it("returns undefined for a *.localhost host", () => {
it("returns undefined for a bare *.localhost host (no shareable parent)", () => {
expect(deriveCookieDomain("http://auth.localhost:8788")).toBeUndefined();
});

it("anchors the real-TLD portless zone parent across worktree prefixes", () => {
expect(deriveCookieDomain("https://auth.uploads.local.buildinternet.dev")).toBe(
".uploads.local.buildinternet.dev",
);
expect(deriveCookieDomain("https://fix-ui.auth.uploads.local.buildinternet.dev")).toBe(
".uploads.local.buildinternet.dev",
);
});

it("shares the last-two-label parent for portless *.localhost hosts", () => {
expect(deriveCookieDomain("https://auth.uploads.localhost")).toBe(".uploads.localhost");
expect(deriveCookieDomain("http://auth.uploads.localhost:1355")).toBe(".uploads.localhost");
expect(deriveCookieDomain("https://fix-ui.auth.uploads.localhost")).toBe(".uploads.localhost");
});

it("returns undefined for an IP host", () => {
expect(deriveCookieDomain("http://127.0.0.1:8788")).toBeUndefined();
});
Expand Down
18 changes: 17 additions & 1 deletion apps/auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,26 @@ export function deriveCookieDomain(betterAuthUrl: string | undefined): string |
} catch {
return undefined;
}
if (host === "localhost" || /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.endsWith(".localhost")) {
if (host === "localhost" || /^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
return undefined;
}
if (host.endsWith(".localhost")) {
// Portless dev (see the `portless` skill): auth.uploads.localhost shares a
// session cookie with uploads.localhost via the `.<name>.localhost` parent.
// Always anchor on the last two labels so worktree-prefixed hosts
// (fix-ui.auth.uploads.localhost) land on the same parent as the web app.
// A bare `<name>.localhost` has no shareable parent — host-only cookie.
const parts = host.split(".");
return parts.length >= 3 ? "." + parts.slice(-2).join(".") : undefined;
}
const parts = host.split(".");
// Real-TLD portless zone (see trusted-origins.ts): anchor on
// `.uploads.local.buildinternet.dev` so worktree-prefixed hosts
// (fix-ui.auth.uploads.local.buildinternet.dev) share the same parent as
// the web app, mirroring the `.localhost` rule above.
if (host.endsWith(".uploads.local.buildinternet.dev")) {
return "." + parts.slice(-4).join(".");
}
if (parts.length < 2) return undefined;
if (parts.length === 2) return "." + host;
return "." + parts.slice(1).join(".");
Expand Down
37 changes: 37 additions & 0 deletions apps/auth/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,43 @@ describe("local demo session", () => {
expect(wrongOrigin.status).toBe(404);
});

it("is absent for non-loopback or mismatched portless-style origins", async () => {
for (const env of [
// Real TLD, not `.localhost` — never enables the bypass.
localEnv({
BETTER_AUTH_URL: "https://auth.local.uploads.sh",
WEB_ORIGIN: "https://local.uploads.sh",
}),
// Bare `.localhost` auth host has no shareable parent.
localEnv({ BETTER_AUTH_URL: "https://auth.localhost", WEB_ORIGIN: "https://web.localhost" }),
// Web host outside the auth host's `.uploads.localhost` parent.
localEnv({
BETTER_AUTH_URL: "https://auth.uploads.localhost",
WEB_ORIGIN: "https://other.localhost",
}),
]) {
const res = await app.request(
"/api/auth/dev-session",
{ method: "POST", headers: { Origin: env.WEB_ORIGIN ?? "" } },
env,
);
expect(res.status).toBe(404);
}
});

it("is available for a matched portless *.localhost pair", async () => {
const env = localEnv({
BETTER_AUTH_URL: "https://auth.uploads.localhost",
WEB_ORIGIN: "https://uploads.localhost",
});
const res = await app.request(
"/api/auth/dev-session",
{ method: "POST", headers: { Origin: "https://uploads.localhost" } },
env,
);
expect(res.status).toBe(200);
});

it("seeds an ordinary member and issues a standard Better Auth session", async () => {
const env = localEnv();
const res = await app.request(
Expand Down
4 changes: 2 additions & 2 deletions apps/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cors } from "hono/cors";
import { createAuth, type AuthEnv } from "./auth";
import { internal } from "./internal-routes";
import { isInternalRequest } from "./internal";
import { LOCAL_STACK_WEB_ORIGIN, localDemoEnabled } from "./local-demo";
import { localDemoEnabled } from "./local-demo";
import { isTrustedOrigin } from "./trusted-origins";
import { runAuthRetentionSweep } from "./retention-sweep";
import { sweepOauthClients } from "./oauth-client-reaper";
Expand Down Expand Up @@ -74,7 +74,7 @@ export const app = new Hono<{ Bindings: AuthEnv }>()
// Better Auth handling so its normal CSRF/origin machinery cannot leak a
// different status for an endpoint that should not exist.
.use("/api/auth/dev-session", async (c, next) => {
if (!localDemoEnabled(c.env) || c.req.header("origin") !== LOCAL_STACK_WEB_ORIGIN) {
if (!localDemoEnabled(c.env) || c.req.header("origin") !== c.env.WEB_ORIGIN) {
return c.json({ error: { code: "not_found", message: "Not found" } }, 404);
}
await next();
Expand Down
47 changes: 39 additions & 8 deletions apps/auth/src/local-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@ import * as schema from "./schema";
export const LOCAL_STACK_AUTH_ORIGIN = "http://127.0.0.1:8788";
export const LOCAL_STACK_WEB_ORIGIN = "http://127.0.0.1:4321";

/**
* Portless dev origins (see the `portless` skill): the stack runs at
* https://auth.<name>.localhost / https://<name>.localhost (optionally
* worktree-prefixed, optionally on the sudo-less proxy port, e.g.
* http://uploads.localhost:1355). Accept the pair only when the auth host has
* a shareable `.<name>.localhost` parent and the web host lives under that
* same parent — the exact property the shared session cookie depends on.
* `.localhost` names resolve to loopback by spec, so this stays as local-only
* as the IP-literal pair above.
*/
function portlessLocalStackOrigins(authUrl: string | undefined, webOrigin: string | undefined) {
if (!authUrl || !webOrigin) return false;
let auth: URL;
let web: URL;
try {
auth = new URL(authUrl);
web = new URL(webOrigin);
} catch {
return false;
}
if (!/^https?:$/.test(auth.protocol) || !/^https?:$/.test(web.protocol)) return false;
const parts = auth.hostname.split(".");
if (parts.length < 3 || parts.at(-1) !== "localhost") return false;
const base = parts.slice(-2).join(".");
return web.hostname === base || web.hostname.endsWith("." + base);
}

const DEMO_USER = {
id: "local-dev-demo-user",
email: "dev-demo@uploads.local",
Expand All @@ -24,16 +51,19 @@ const DEMO_ORGANIZATION = { id: "local-dev-demo-org", slug: "dev-demo", name: "D

/**
* The route is deliberately unavailable unless the lifecycle runner explicitly
* opts in. Exact origins avoid accidentally enabling an identity bypass on a
* public preview, a localhost alias, or a partially configured environment.
* opts in. Loopback-only origin shapes (exact IP-literal pair, or a matched
* portless `*.localhost` pair) avoid accidentally enabling an identity bypass
* on a public preview, a real-TLD alias, or a partially configured environment.
*/
export function localDemoEnabled(env: AuthEnv): boolean {
return (
env.LOCAL_STACK === "true" &&
env.ENVIRONMENT === "development" &&
if (env.LOCAL_STACK !== "true" || env.ENVIRONMENT !== "development") return false;
if (
env.BETTER_AUTH_URL === LOCAL_STACK_AUTH_ORIGIN &&
env.WEB_ORIGIN === LOCAL_STACK_WEB_ORIGIN
);
) {
return true;
}
return portlessLocalStackOrigins(env.BETTER_AUTH_URL, env.WEB_ORIGIN);
}

async function ensureDemoIdentity(env: AuthEnv) {
Expand Down Expand Up @@ -104,8 +134,9 @@ export function localDemoPlugin(env: AuthEnv) {
{ method: "POST", requireHeaders: true },
async (ctx) => {
// Keep a wrong/missing browser origin indistinguishable from an
// absent route. The plugin is itself omitted outside localDemoEnabled.
if (ctx.headers?.get("origin") !== LOCAL_STACK_WEB_ORIGIN) {
// absent route. The plugin is itself omitted outside localDemoEnabled,
// which has already vetted WEB_ORIGIN as a loopback-only shape.
if (ctx.headers?.get("origin") !== env.WEB_ORIGIN) {
return new Response("Not Found", { status: 404 });
}
const user = await ensureDemoIdentity(env);
Expand Down
17 changes: 17 additions & 0 deletions apps/auth/src/trusted-origins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ describe("isTrustedOrigin", () => {
it("allows portless *.localhost origins outside production", () => {
const env = { WEB_ORIGIN: "https://uploads.sh", ENVIRONMENT: "development" };
expect(isTrustedOrigin("https://uploads.localhost", env)).toBe(true);
expect(isTrustedOrigin("https://auth.uploads.localhost", env)).toBe(true);
expect(isTrustedOrigin("https://fix-ui.auth.uploads.localhost", env)).toBe(true);
expect(isTrustedOrigin("http://uploads.localhost:1355", env)).toBe(true);
});

it("allows the real-TLD portless OAuth zone outside production only", () => {
const env = { WEB_ORIGIN: "https://uploads.sh", ENVIRONMENT: "development" };
const zone = "uploads.local.buildinternet.dev";
expect(isTrustedOrigin(`https://${zone}`, env)).toBe(true);
expect(isTrustedOrigin(`https://auth.${zone}`, env)).toBe(true);
expect(isTrustedOrigin(`https://fix-ui.auth.${zone}`, env)).toBe(true);
expect(isTrustedOrigin(`http://auth.${zone}`, env)).toBe(false);
expect(isTrustedOrigin("https://evil-uploads.local.buildinternet.dev", env)).toBe(false);
// Never under uploads.sh — the local zone must not share prod's
// registrable domain (cookie scope).
expect(isTrustedOrigin("https://auth.local.uploads.sh", env)).toBe(false);
expect(isTrustedOrigin(`https://auth.${zone}`, prodEnv)).toBe(false);
});

it("rejects unrelated hosts outside production", () => {
Expand Down
23 changes: 19 additions & 4 deletions apps/auth/src/trusted-origins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@
*/

const LOCALHOST_ORIGIN_RE = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
// Portless dev (see the `portless` skill): named `*.localhost` origins with no
// port, e.g. https://uploads.localhost.
const PORTLESS_ORIGIN_RE = /^https?:\/\/[a-z0-9-]+\.localhost$/;
// Portless dev (see the `portless` skill): named `*.localhost` origins, e.g.
// https://uploads.localhost, https://auth.uploads.localhost, a worktree-
// prefixed https://fix-ui.uploads.localhost, or the sudo-less proxy fallback
// http://uploads.localhost:1355. `.localhost` resolves to loopback by spec.
const PORTLESS_ORIGIN_RE = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.localhost(:\d+)?$/;
// Real-TLD portless mode for OAuth providers that reject `*.localhost`
// redirect URIs (see the `oauth` skill): PORTLESS_TLD=dev
// PORTLESS_NAME=uploads.local.buildinternet serves
// https://uploads.local.buildinternet.dev + subdomains, resolved to loopback
// via public DNS. Deliberately on the shared infra domain (matching the
// sibling repos) rather than under uploads.sh, so prod's `Domain=.uploads.sh`
// session cookies never overlap the local zone. Non-production only.
const LOCAL_BUILDINTERNET_ORIGIN_RE =
/^https:\/\/([a-z0-9-]+\.)*uploads\.local\.buildinternet\.dev$/;

export type TrustedOriginsEnv = {
WEB_ORIGIN?: string;
Expand Down Expand Up @@ -42,5 +53,9 @@ export function authTrustedOrigins(env: TrustedOriginsEnv): string[] {
export function isTrustedOrigin(origin: string, env: TrustedOriginsEnv): boolean {
if (authTrustedOrigins(env).includes(origin)) return true;
if (env.ENVIRONMENT === "production") return false;
return LOCALHOST_ORIGIN_RE.test(origin) || PORTLESS_ORIGIN_RE.test(origin);
return (
LOCALHOST_ORIGIN_RE.test(origin) ||
PORTLESS_ORIGIN_RE.test(origin) ||
LOCAL_BUILDINTERNET_ORIGIN_RE.test(origin)
);
}
8 changes: 7 additions & 1 deletion apps/web/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
# here points the signed-in pages (and their CSP connect-src, which is derived
# from the same origins — see resolveSignedInOrigins in
# src/lib/signed-in-page.ts) at the local auth/API workers instead. Matches
# the ports used by `pnpm dev:stack` (see scripts/dev-stack-common.mjs).
# the ports used by standalone `pnpm dev:web` against loopback workers.
#
# NOTE: `pnpm dev:stack` runs through portless (see docs/local-dev.md) and
# injects the named origins (https://auth.uploads.localhost etc.) via process
# env at startup — it does not need this file. If you run the portless stack
# and signed-in pages still resolve loopback/prod origins, a stale .dev.vars
# here is the usual culprit.
UPLOADS_AUTH_ORIGIN=http://127.0.0.1:8788
UPLOADS_API_ORIGIN=http://127.0.0.1:8787
10 changes: 10 additions & 0 deletions apps/web/src/lib/signed-in-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,22 @@ export function resolveSignedInOrigins(env: OriginEnv): {
authOrigin: string;
apiOrigin: string;
} {
// In dev, the stack supervisor (scripts/dev-stack.mjs) injects the live
// portless origins as PUBLIC_* process env — but the Cloudflare adapter's
// runtime env lets a stale apps/web/.dev.vars shadow them. Prefer the
// supervisor's values in dev so the CSP and clients point at the workers
// that are actually running; production is untouched (PUBLIC_* is unset
// there, so the runtime-env chain below still decides).
const devAuth = import.meta.env.DEV ? import.meta.env.PUBLIC_UPLOADS_AUTH_ORIGIN : undefined;
const devApi = import.meta.env.DEV ? import.meta.env.PUBLIC_UPLOADS_API_ORIGIN : undefined;
return {
authOrigin:
devAuth ??
env.UPLOADS_AUTH_ORIGIN ??
import.meta.env.PUBLIC_UPLOADS_AUTH_ORIGIN ??
"https://auth.uploads.sh",
apiOrigin:
devApi ??
env.UPLOADS_API_ORIGIN ??
import.meta.env.PUBLIC_UPLOADS_API_ORIGIN ??
"https://api.uploads.sh",
Expand Down
79 changes: 78 additions & 1 deletion docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,89 @@ pnpm doctor # diagnose the setup — reports what's missing and how to

pnpm dev # API on :8787 (local R2 + KV + D1)
pnpm dev:web # Astro site
pnpm dev:stack # authenticated Auth + API + Web stack, ready at 127.0.0.1:4321
pnpm dev:stack # authenticated Auth + API + Web stack (portless, see below)
pnpm dev:stack:check --json # machine-readable readiness + session/API smoke proof
pnpm check # lint + format (CI gate)
pnpm typecheck # wrangler types + tsc across workspaces
```

## Named local URLs (portless)

`pnpm dev:stack` runs through [portless](https://npmjs.com/portless), so the
stack gets stable named `.localhost` origins instead of bare ports:

| Service | URL |
| ------- | -------------------------------- |
| web | `https://uploads.localhost` |
| auth | `https://auth.uploads.localhost` |
| api | `https://api.uploads.localhost` |

The shared `.uploads.localhost` parent is what makes local auth work like
prod: the Better Auth session cookie set by the auth worker is sent to web
and api the same way `.uploads.sh` cookies are, so signed-in pages
(`/account/*`, `/admin/*`) just work in a local browser — including agent
browser panels. In a linked git worktree, portless prefixes the branch name
(`fix-ui.uploads.localhost` / `fix-ui.auth.uploads.localhost`); the cookie
parent still anchors on the last two labels, so nothing else changes.
`dev:stack` prints the resolved `previewUrl` when ready, and
`pnpm dev:stack:check --json` reports it too.

Notes:

- First run may prompt for sudo so the proxy can bind :443 (HTTPS). If sudo
is unavailable, portless falls back to plain HTTP on `:1355` — the stack
handles both. `pnpm exec portless doctor` diagnoses routing/CA issues, and
`pnpm exec portless service install` keeps the proxy across reboots.
- `pnpm dev:stack:raw` (or `PORTLESS=0 pnpm dev:stack`) restores the legacy
pinned loopback ports (`127.0.0.1:4321/8787/8788`). This is also the path
to use when testing the dev GitHub OAuth app, whose callback is pinned to
`http://127.0.0.1:8788/api/auth/callback/github`. The `stack-raw` launch
config (.claude/launch.json) boots the same thing with a port-based
preview; in portless mode the web port is dynamic, so open the printed
`previewUrl` directly instead.
- `pnpm dev:stack:oauth` is the named alias for the real-TLD mode below.
- The zero-input `/api/auth/dev-session` bypass stays fail-closed: it only
enables for the exact loopback pair or a matched `*.localhost` pair — never
for real-TLD origins.

### Real-TLD mode for OAuth (`*.uploads.local.buildinternet.dev`)

Some OAuth providers (Google, Apple) reject `*.localhost` redirect URIs, so
the stack can run under a real TLD instead — on the shared
`local.buildinternet.dev` infra zone, same as the sibling repos:

```bash
PORTLESS_TLD=dev PORTLESS_NAME=uploads.local.buildinternet pnpm dev:stack
# -> https://uploads.local.buildinternet.dev
# https://auth.uploads.local.buildinternet.dev
# https://api.uploads.local.buildinternet.dev
```

The zone is deliberately NOT under uploads.sh: prod sets its session cookie
with `Domain=.uploads.sh`, so a `local.uploads.sh` zone would leak prod
cookies into local dev stacks (and let local software set cookies scoped to
prod). The infra domain has no production cookies to overlap.

DNS: `local.buildinternet.dev` + `*.local.buildinternet.dev` are public
DNS-only A records → `127.0.0.1` (never proxy them), so the names resolve to
loopback on any machine, worktree prefixes included.
`pnpm exec portless hosts sync` is only a fallback for offline work.

The proxy only serves TLDs it was started with, so if yours runs with the
default `.localhost` only, this mode auto-starts a second proxy on `:1355`
and the URLs carry that port. For clean port-free URLs, run one proxy with
both TLDs: `sudo portless proxy stop && sudo portless proxy start --https
--tld localhost --tld dev` (or bake it in with
`portless service install --tld localhost --tld dev`).

These origins are trusted by the auth worker outside production (https only).
Register the provider's redirect URI as
`https://auth.uploads.local.buildinternet.dev/api/auth/callback/<provider>`.
Note the `dev-session` bypass is intentionally unavailable in this mode —
sign in through the real provider flow you're testing. GitHub accepts
loopback callbacks, so day-to-day GitHub testing can stay on `PORTLESS=0`
instead.

`bootstrap` is idempotent (safe to re-run; never overwrites your env files or
re-mints an existing local workspace) and `doctor` is read-only. `dev:stack`
uses the real Workers, Better Auth cookie, service binding, membership checks,
Expand Down
Loading
Loading