From f21e9bbdc3e963cc1c57c0ec68592ccc385c56c4 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:30:30 -0700 Subject: [PATCH 1/4] FEATURE: Serve tenant content from per-org subdomains CONTENT_ORIGIN becomes CONTENT_DOMAIN: every org's files, sites, and thumbnails are served from `.` over the dashboard's scheme, on one wildcard route. The host gate reads the slug from the request host, the handle hook resolves it to an org through a small KV cache (`org-slug:`, 300s, misses 60s) in front of `orgs`, and puts `{ orgId, slug }` on `locals.content`; an unknown or suspended slug is a 404 on every path. Content routes run with that org as CurrentOrg, so a file id from another org on this host is a 404, and grants are minted and verified against the org's own origin. CurrentOrg carries the slug and `AppConfig.contentOriginFor(slug)` builds the origin that link generation, thumbnails, the MCP server, and the CLI (via /api/files) receive. The dashboard CSP allows `//*.`. Route tests exercise content requests on `.localhost:5174` and cover wrong-slug, unknown-slug, and suspended-org hosts. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/deploy-fresh-instance/SKILL.md | 6 +- README.md | 23 +++-- apps/web/.dev.vars.example | 10 +- apps/web/scripts/content-proxy.mjs | 5 + apps/web/src/app.d.ts | 6 ++ apps/web/src/hooks.server.ts | 65 +++++++++---- apps/web/src/lib/server/config.ts | 12 ++- apps/web/src/lib/server/content-host.ts | 96 +++++++++++++++++++ apps/web/src/lib/server/edge.ts | 39 +++++--- apps/web/src/lib/server/file-content-link.ts | 6 +- apps/web/src/lib/server/host-gate.ts | 94 +++++++++++++++--- apps/web/src/lib/server/identity.ts | 13 ++- apps/web/src/lib/server/layer.ts | 47 +++++++-- apps/web/src/lib/server/mcp/server.ts | 14 ++- apps/web/src/lib/server/security-headers.ts | 28 ++++-- .../src/lib/server/services/current-org.ts | 32 +++++-- .../src/lib/server/services/grant-secrets.ts | 24 ++++- .../web/src/lib/server/services/sites/read.ts | 7 +- apps/web/src/routes/+page.server.ts | 3 - apps/web/src/routes/api/files/+server.ts | 6 +- apps/web/src/routes/api/files/[id]/+server.ts | 3 +- apps/web/src/routes/api/search/+server.ts | 3 +- .../api/sites/sessions/[id]/commit/+server.ts | 5 +- apps/web/src/routes/f/[id]/+server.ts | 1 - .../src/routes/s/[id]/[...path]/+server.ts | 2 - .../t/[id]/[version]/grid.webp/+server.ts | 15 ++- apps/web/worker-configuration.d.ts | 8 +- apps/web/wrangler.jsonc | 25 +++-- docs/release.md | 2 +- scripts/check-wrangler-drift.mjs | 4 +- 30 files changed, 467 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/lib/server/content-host.ts diff --git a/.agents/skills/deploy-fresh-instance/SKILL.md b/.agents/skills/deploy-fresh-instance/SKILL.md index 1fe3dd0..a1e0743 100644 --- a/.agents/skills/deploy-fresh-instance/SKILL.md +++ b/.agents/skills/deploy-fresh-instance/SKILL.md @@ -54,7 +54,7 @@ into `env.production` by hand. degrading to keyword search. That is intended. 5. In the Cloudflare dashboard, open **Images → Transformations**, select - the zone that owns `CONTENT_ORIGIN` (`davis7.space` for + the zone that owns `CONTENT_DOMAIN` (`davis7.space` for `files.davis7.space`), and enable transformations. Dashboard thumbnails require this zone-level setting. @@ -119,8 +119,10 @@ Expected live checks once both origins are up: - dashboard root → **200** - unauth `GET /api/files` → **401** -- `GET /api/files` → **421** (dashboard API rejected on content origin) +- `GET https://./api/files` → **421** (dashboard API rejected on a content host) - `GET /f/` → **421** (content rejected on dashboard origin) +- `GET https:///f/` → **421** (content needs an org host) +- `GET https://no-such-org./f/` → **404** - missing file → **404** - a known non-empty public file without a `Range` header → **200** with no `Content-Range` diff --git a/README.md b/README.md index 3884bb6..e657e53 100644 --- a/README.md +++ b/README.md @@ -80,25 +80,31 @@ start both local origins: bun --filter @adrive/web dev ``` -The dashboard/API is at `http://localhost:5173/`. Public file bytes are -served from `http://localhost:5174/`. The second port is a small streaming -proxy into the same SvelteKit process so both origins share one local D1/R2 -state while the Worker still sees and enforces the content host. +The dashboard/API is at `http://localhost:5173/`. Each org's file bytes are +served from its own host under `CONTENT_DOMAIN`: `http://.localhost:5174/` locally (browsers resolve `*.localhost` to loopback, +so nothing needs configuring). The second port is a small streaming proxy +into the same SvelteKit process so both origins share one local state +while the Worker still sees and enforces the tenant host. The settings +page shows your org's content origin. ### Developing over Tailscale (or another network hostname) The dev server binds `0.0.0.0`, so other devices can use it — phones, -tablets, or a laptop pointed at a beefier dev box. Set both origins in +tablets, or a laptop pointed at a beefier dev box. Set both values in `apps/web/.dev.vars` to the hostname the _browser_ will use. With Tailscale MagicDNS that's your machine name plus tailnet domain (see `tailscale status`): ```bash DASHBOARD_ORIGIN="http://..ts.net:5173" -CONTENT_ORIGIN="http://..ts.net:5174" +CONTENT_DOMAIN="..ts.net:5174" ``` -Any LAN hostname or IP works the same way; the origins just have to match +`CONTENT_DOMAIN` has no scheme (it follows the dashboard) and content is +served from `.`, so the browser also has to +resolve that wildcard to the machine: `*.localhost` does out of the box, +any other domain needs a wildcard DNS entry. The values just have to match how the browser addresses the machine, since the Worker enforces its host-routing rules even in dev. @@ -192,7 +198,8 @@ modify those source tables. The checked-in Wrangler D1 and R2 resource names are placeholders. The `AUTH_GUARD` KV namespace is already provisioned and bound. Before deployment, create one D1 database and one private R2 bucket, replace the D1 database ID, -apply the migration remotely, set production dashboard/content origins, and set +apply the migration remotely, set the production dashboard origin and +content domain, and set the secrets (`MAINTENANCE_SECRET`, `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`, `WORKOS_COOKIE_PASSWORD`, `WORKOS_WEBHOOK_SECRET`): diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index 6831827..62f9e51 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -21,8 +21,12 @@ CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://adrive:adri # Optional: develop from other devices (e.g. over Tailscale). The dev # server binds 0.0.0.0, so any hostname that reaches this machine works — -# set both origins to how the BROWSER will address it. With Tailscale -# MagicDNS that's your machine name plus your tailnet domain, e.g.: +# set both values to how the BROWSER will address it. Content is served +# from `.` (no scheme; it follows the dashboard), +# so the browser must resolve `.` to this machine: +# `*.localhost` does so out of the box, other domains need a wildcard DNS +# entry. With Tailscale MagicDNS that's your machine name plus your +# tailnet domain, e.g.: # DASHBOARD_ORIGIN="http://..ts.net:5173" -# CONTENT_ORIGIN="http://..ts.net:5174" +# CONTENT_DOMAIN="..ts.net:5174" # (find yours with `tailscale status`; omit these lines for localhost-only) diff --git a/apps/web/scripts/content-proxy.mjs b/apps/web/scripts/content-proxy.mjs index e2b9092..deb8281 100644 --- a/apps/web/scripts/content-proxy.mjs +++ b/apps/web/scripts/content-proxy.mjs @@ -1,5 +1,10 @@ import { createServer, request } from 'node:http'; +// Streams `.localhost:5174` (or whatever CONTENT_DOMAIN names) into +// the dashboard dev server on 5173. The Host header is forwarded untouched +// so the Worker sees the tenant host and enforces its host routing exactly +// as in production; browsers resolve `*.localhost` to loopback, so no DNS +// setup is needed locally. const server = createServer((incoming, outgoing) => { const upstream = request( { diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 403a84c..dd9081d 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,4 +1,5 @@ import type { Env } from '../worker-configuration'; +import type { ContentHost } from '$lib/server/content-host'; import type { AuthContext } from '$lib/server/identity'; declare global { @@ -14,6 +15,11 @@ declare global { // Resolved once per request by hooks.server.ts from the adr_ // bearer key or the session cookie; null when neither is valid. auth: AuthContext | null; + // The org whose content host (`.`) the + // request arrived on; null on the dashboard origin. The hook + // answers 404 before any content route runs for a host that + // names no live org. + content: ContentHost | null; } interface Platform { env: Env; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 44fb9dc..4768b32 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,21 +1,33 @@ import type { Handle } from '@sveltejs/kit'; import { SESSION_COOKIE, sessionCookieOptions } from '$lib/server/auth-policy'; +import { resolveContentHost } from '$lib/server/content-host'; import { MisdirectedRequest } from '$lib/server/errors'; import { assertHostRoute, normalizeOrigins } from '$lib/server/host-gate'; import { resolveEventAuth } from '$lib/server/request-auth'; import { applySecurityHeaders } from '$lib/server/security-headers'; +const unavailable = (cause: unknown) => { + console.error( + JSON.stringify({ + message: 'request identity could not be resolved', + cause: String(cause) + }) + ); + return new Response('Storage unavailable', { status: 502 }); +}; + export const handle: Handle = async ({ event, resolve }) => { const env = event.platform?.env; if (!env) throw new Error('Cloudflare bindings unavailable'); const originConfig = { dashboardOrigin: env.DASHBOARD_ORIGIN, - contentOrigin: env.CONTENT_ORIGIN + contentDomain: env.CONTENT_DOMAIN }; + let hostRoute; try { - assertHostRoute(event.url, originConfig); + hostRoute = assertHostRoute(event.url, originConfig); } catch (cause) { if (cause instanceof MisdirectedRequest) { return new Response(cause.message, { status: 421 }); @@ -23,29 +35,42 @@ export const handle: Handle = async ({ event, resolve }) => { throw cause; } - try { - const resolved = await resolveEventAuth(env, event); - event.locals.auth = resolved.auth; - if (resolved.refreshedSession !== null) { - event.cookies.set( - SESSION_COOKIE, - resolved.refreshedSession, - sessionCookieOptions - ); + event.locals.auth = null; + event.locals.content = null; + if (hostRoute.route === 'content') { + // The host names the org. A slug nobody owns, or one whose org is + // suspended, is a 404 for every path so the host leaks nothing. + let resolved; + try { + resolved = await resolveContentHost(env, hostRoute.slug); + } catch (cause) { + return unavailable(cause); + } + if (resolved._tag === 'Missing') { + return new Response('Not found', { status: 404 }); + } + event.locals.content = resolved.host; + } else { + try { + const resolved = await resolveEventAuth(env, event); + event.locals.auth = resolved.auth; + if (resolved.refreshedSession !== null) { + event.cookies.set( + SESSION_COOKIE, + resolved.refreshedSession, + sessionCookieOptions + ); + } + } catch (cause) { + return unavailable(cause); } - } catch (cause) { - console.error( - JSON.stringify({ - message: 'request identity could not be resolved', - cause: String(cause) - }) - ); - return new Response('Storage unavailable', { status: 502 }); } + const origins = normalizeOrigins(originConfig); return applySecurityHeaders(await resolve(event), { pathname: event.url.pathname, requestOrigin: event.url.origin, - contentOrigin: normalizeOrigins(originConfig).contentOrigin + contentDomain: origins.contentDomain, + contentScheme: origins.contentScheme }); }; diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index 513e98e..9b17749 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -1,6 +1,6 @@ import { dev } from '$app/environment'; import { Context, Layer } from 'effect'; -import { normalizeOrigins } from './host-gate'; +import { contentOriginFor, normalizeOrigins } from './host-gate'; // A null API key is reserved for the explicitly enabled development fake. // Production always requires real WorkOS credentials. @@ -13,7 +13,11 @@ export interface WorkOSConfig { export interface AppConfigShape { readonly dashboardOrigin: string; - readonly contentOrigin: string; + // Tenant content is served from `.` over the + // dashboard's scheme; contentOriginFor builds one org's origin. + readonly contentDomain: string; + readonly contentScheme: string; + readonly contentOriginFor: (slug: string) => string; readonly maxUploadBytes: number; // Signs the Worker facade's cron and queue self-requests. readonly maintenanceSecret: string; @@ -77,7 +81,7 @@ const workosFromEnv = (env: Env): WorkOSConfig => { export const configFromEnv = (env: Env) => { const origins = normalizeOrigins({ dashboardOrigin: env.DASHBOARD_ORIGIN, - contentOrigin: env.CONTENT_ORIGIN + contentDomain: env.CONTENT_DOMAIN }); const maxUploadBytes = Number(env.MAX_UPLOAD_BYTES); if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes <= 0) { @@ -102,6 +106,8 @@ export const configFromEnv = (env: Env) => { } return { ...origins, + contentOriginFor: (slug: string) => + contentOriginFor(origins.contentScheme, origins.contentDomain, slug), maxUploadBytes, maintenanceSecret: env.MAINTENANCE_SECRET, workos: workosFromEnv(env), diff --git a/apps/web/src/lib/server/content-host.ts b/apps/web/src/lib/server/content-host.ts new file mode 100644 index 0000000..3d9e677 --- /dev/null +++ b/apps/web/src/lib/server/content-host.ts @@ -0,0 +1,96 @@ +import { Effect, Schema } from 'effect'; +import { runWorkerProgram } from './edge'; +import { StorageError } from './errors'; +import { PgSql } from './pg'; +import { AuthGuardStore } from './services/bindings'; + +// The org a content request is served for, as resolved from its host. +export interface ContentHost { + readonly orgId: string; + readonly slug: string; +} + +export type ContentHostResolution = + | { readonly _tag: 'Found'; readonly host: ContentHost } + // Unknown slug, or an org that is suspended: every path is a 404. + | { readonly _tag: 'Missing' }; + +// Slug lookups sit in front of every content request, so they are cached +// in the AUTH_GUARD KV namespace: `org-slug:` holds the org id and +// trust for five minutes; a miss is remembered for one minute so a burst +// of requests for a random host does not fan out to Postgres. Suspending +// an org or changing its slug must delete the key (forgetContentSlug) so +// the change lands before the entry expires. +export const contentSlugCacheKey = (slug: string) => `org-slug:${slug}`; +const CACHE_TTL_SECONDS = 300; +const NEGATIVE_CACHE_TTL_SECONDS = 60; + +const CachedSlug = Schema.Union([ + Schema.Struct({ orgId: Schema.String, trust: Schema.String }), + Schema.Struct({ missing: Schema.Literal(true) }) +]); + +const decodeCached = (value: string | null) => { + if (value === null) return null; + try { + const decoded = Schema.decodeUnknownOption(CachedSlug)(JSON.parse(value)); + return decoded._tag === 'Some' ? decoded.value : null; + } catch { + return null; + } +}; + +const resolution = ( + slug: string, + entry: typeof CachedSlug.Type +): ContentHostResolution => + 'missing' in entry || entry.trust === 'suspended' + ? { _tag: 'Missing' } + : { _tag: 'Found', host: { orgId: entry.orgId, slug } }; + +export const resolveContentSlug = Effect.fn('resolveContentSlug')(function* ( + slug: string +) { + const store = yield* AuthGuardStore; + const sql = yield* PgSql; + const key = contentSlugCacheKey(slug); + const cached = decodeCached( + yield* Effect.tryPromise({ + try: () => store.get(key), + catch: (cause) => + new StorageError({ operation: 'read slug cache', cause }) + }) + ); + if (cached) return resolution(slug, cached); + const rows = yield* sql<{ id: string; trust: string }>` + SELECT id, trust FROM orgs WHERE slug = ${slug} LIMIT 1 + `.pipe( + Effect.mapError( + (cause) => new StorageError({ operation: 'resolve org slug', cause }) + ) + ); + const row = rows[0]; + const entry: typeof CachedSlug.Type = row + ? { orgId: row.id, trust: row.trust } + : { missing: true }; + yield* Effect.tryPromise({ + try: () => + store.put(key, JSON.stringify(entry), { + expirationTtl: row ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS + }), + catch: (cause) => new StorageError({ operation: 'write slug cache', cause }) + }).pipe(Effect.catchTag('StorageError', () => Effect.void)); + return resolution(slug, entry); +}); + +export const forgetContentSlug = (slug: string) => + Effect.flatMap(AuthGuardStore, (store) => + Effect.tryPromise({ + try: () => store.delete(contentSlugCacheKey(slug)), + catch: (cause) => + new StorageError({ operation: 'purge slug cache', cause }) + }) + ); + +export const resolveContentHost = (env: Env, slug: string) => + runWorkerProgram(env, resolveContentSlug(slug)); diff --git a/apps/web/src/lib/server/edge.ts b/apps/web/src/lib/server/edge.ts index 3a6380c..1a6ec85 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -15,12 +15,13 @@ import { Unauthorized, type AppError } from './errors'; -import type { ProgramIdentity } from './identity'; +import type { ProgramIdentity, ProgramTenant } from './identity'; import { requestLayer } from './layer'; import { PgSql } from './pg'; import type { AppConfig } from './config'; import type { AuthGuard } from './services/auth-guard'; import type { Auth } from './services/auth'; +import type { AuthGuardStore } from './services/bindings'; import type { Blobs } from './services/blobs'; import type { CurrentOrg, CurrentUser } from './services/current-org'; import type { Files } from './services/files'; @@ -38,6 +39,7 @@ export type AppServices = | PgSql | AppConfig | AuthGuard + | AuthGuardStore | Auth | Blobs | CurrentOrg @@ -146,10 +148,22 @@ const throwCauseAsHttp = (cause: Cause.Cause): never => { error(500, 'Internal error'); }; -// The tenant comes from the request's resolved credential. A request -// without one still gets a layer (sign-in, device polling, content routes -// all run before or without a tenant); reading the org there is a bug and -// surfaces as a defect, never as another org's rows. +// The tenant comes from the request's resolved credential on the +// dashboard origin, or from the host on a content origin (the hook +// resolved `.` to its org). A request with neither +// still gets a layer (sign-in, device polling run before a tenant exists); +// reading the org there is a bug and surfaces as a defect, never as +// another org's rows. +const eventTenant = (locals: App.Locals): ProgramTenant | null => + locals.auth ?? + (locals.content + ? { + orgId: locals.content.orgId, + orgSlug: locals.content.slug, + userId: null + } + : null); + const runWithEvent = async ( event: RequestEvent, program: Effect.Effect @@ -158,7 +172,7 @@ const runWithEvent = async ( if (!env) error(500, 'Cloudflare bindings unavailable'); const exit = await Effect.runPromiseExit( - program.pipe(Effect.provide(requestLayer(env, event.locals.auth))) + program.pipe(Effect.provide(requestLayer(env, eventTenant(event.locals)))) ); if (Exit.isSuccess(exit)) return exit.value; return throwCauseAsHttp(exit.cause); @@ -197,23 +211,24 @@ export const runAcrossOrgs = async ( options: { readonly limit?: number } = {} ) => { const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); - const orgIds = await runWorkerProgram( + const orgs = await runWorkerProgram( env, Effect.flatMap( PgSql, - (sql) => sql<{ id: string }>` - SELECT id FROM orgs + (sql) => sql<{ id: string; slug: string }>` + SELECT id, slug FROM orgs WHERE trust <> 'suspended' ORDER BY random() LIMIT ${limit}` - ).pipe(Effect.map((rows) => rows.map((row) => row.id))) + ) ); const results: Array<{ readonly orgId: string; readonly value: A }> = []; - for (const orgId of orgIds) { + for (const { id: orgId, slug } of orgs) { try { const value = await runWorkerProgram(env, program, { orgId, - userId: 'system' + userId: 'system', + orgSlug: slug }); results.push({ orgId, value }); } catch (cause) { diff --git a/apps/web/src/lib/server/file-content-link.ts b/apps/web/src/lib/server/file-content-link.ts index f9cde7e..cc66fd4 100644 --- a/apps/web/src/lib/server/file-content-link.ts +++ b/apps/web/src/lib/server/file-content-link.ts @@ -1,8 +1,8 @@ import type { FileContentLinkResponse, FileSummary } from '@adrive/shared'; import { Effect } from 'effect'; -import { AppConfig } from './config'; import { InvalidRequest } from './errors'; import type { PrivateGrant } from './private-grant'; +import { currentContentOrigin } from './services/current-org'; import { Files } from './services/files'; import { GrantSecrets } from './services/grant-secrets'; @@ -56,7 +56,7 @@ export const resolveFileContentLink = ( requireGrant = false ) => Effect.gen(function* () { - const config = yield* AppConfig; + const config = { contentOrigin: yield* currentContentOrigin }; const files = yield* Files; const grantSecrets = yield* GrantSecrets; const resolveCurrentVersion = includeUnavailable || requireGrant; @@ -85,7 +85,6 @@ export const resolveFileContentLink = ( }; } const grant = yield* grantSecrets.mint({ - contentOrigin: config.contentOrigin, orgId: resolved.orgId, fileId: resolved.file.id, version: resolved.file.version @@ -111,7 +110,6 @@ export const resolveFileContentLink = ( content.file.public && !includeUnavailable && !requireGrant ? undefined : yield* grantSecrets.mint({ - contentOrigin: config.contentOrigin, orgId: content.orgId, fileId: content.file.id, version: content.file.version diff --git a/apps/web/src/lib/server/host-gate.ts b/apps/web/src/lib/server/host-gate.ts index 02e1585..ebe2d3b 100644 --- a/apps/web/src/lib/server/host-gate.ts +++ b/apps/web/src/lib/server/host-gate.ts @@ -2,9 +2,16 @@ import { MisdirectedRequest } from './errors'; export interface OriginConfig { readonly dashboardOrigin: string; - readonly contentOrigin: string; + // Host (and optional port) under which every org's content lives: + // `.`. No scheme; the scheme follows the dashboard. + readonly contentDomain: string; } +// The leading host label a content request is allowed to carry. The slug +// policy (slug-policy.ts) is stricter for slugs an owner may choose; the +// gate only has to reject hosts that can never name an org. +const HOST_SLUG_PATTERN = /^[a-z0-9-]{3,32}$/; + const origin = (value: string, label: string) => { const parsed = new URL(value); if (parsed.pathname !== '/' || parsed.search || parsed.hash) { @@ -15,13 +22,42 @@ const origin = (value: string, label: string) => { return parsed.origin; }; +const domain = (value: string, label: string) => { + const parsed = (() => { + try { + return new URL(`http://${value}/`); + } catch { + return null; + } + })(); + if ( + parsed === null || + parsed.host !== value || + value.includes('/') || + value.includes('@') + ) { + throw new Error( + `${label} must be a bare hostname with an optional port, like files.example` + ); + } + return parsed.host; +}; + export const normalizeOrigins = (config: OriginConfig) => { const dashboardOrigin = origin(config.dashboardOrigin, 'DASHBOARD_ORIGIN'); - const contentOrigin = origin(config.contentOrigin, 'CONTENT_ORIGIN'); - if (dashboardOrigin === contentOrigin) { - throw new Error('DASHBOARD_ORIGIN and CONTENT_ORIGIN must be different'); + const contentDomain = domain(config.contentDomain, 'CONTENT_DOMAIN'); + const dashboard = new URL(dashboardOrigin); + if ( + dashboard.host === contentDomain || + dashboard.host.endsWith(`.${contentDomain}`) + ) { + throw new Error('DASHBOARD_ORIGIN must not live under CONTENT_DOMAIN'); } - return { dashboardOrigin, contentOrigin }; + return { + dashboardOrigin, + contentDomain, + contentScheme: dashboard.protocol + }; }; export const classifyRoute = (pathname: string) => @@ -34,16 +70,50 @@ export const classifyRoute = (pathname: string) => ? 'content' : 'dashboard'; -export const assertHostRoute = (requestUrl: URL, config: OriginConfig) => { +// The org slug named by a request host, or null when the host is not a +// single label under the content domain. +export const contentSlugFromHost = (host: string, contentDomain: string) => { + const suffix = `.${contentDomain.toLowerCase()}`; + const lowered = host.toLowerCase(); + if (!lowered.endsWith(suffix)) return null; + const slug = lowered.slice(0, -suffix.length); + return HOST_SLUG_PATTERN.test(slug) ? slug : null; +}; + +export const contentOriginFor = ( + contentScheme: string, + contentDomain: string, + slug: string +) => `${contentScheme}//${slug}.${contentDomain}`; + +export type HostRoute = + | { readonly route: 'dashboard' } + | { readonly route: 'content'; readonly slug: string }; + +// Content routes must arrive on `.`, dashboard routes +// on the dashboard origin exactly. Anything else is a 421 candidate. +export const assertHostRoute = ( + requestUrl: URL, + config: OriginConfig +): HostRoute => { const origins = normalizeOrigins(config); const route = classifyRoute(requestUrl.pathname); - const requestOrigin = requestUrl.origin; - const expectedOrigin = - route === 'content' ? origins.contentOrigin : origins.dashboardOrigin; - - if (requestOrigin !== expectedOrigin) { + if (route === 'dashboard') { + if (requestUrl.origin !== origins.dashboardOrigin) { + throw new MisdirectedRequest({ + message: 'This route belongs on the dashboard origin' + }); + } + return { route }; + } + const slug = + requestUrl.protocol === origins.contentScheme + ? contentSlugFromHost(requestUrl.host, origins.contentDomain) + : null; + if (slug === null) { throw new MisdirectedRequest({ - message: `This route belongs on the ${route} origin` + message: 'This route belongs on the content origin' }); } + return { route, slug }; }; diff --git a/apps/web/src/lib/server/identity.ts b/apps/web/src/lib/server/identity.ts index 22f485f..e9e00c3 100644 --- a/apps/web/src/lib/server/identity.ts +++ b/apps/web/src/lib/server/identity.ts @@ -1,9 +1,18 @@ import type { ApiKeyScope } from '@adrive/shared'; +// The org a program runs for. userId is null for programs that act for +// an org without a signed-in member: content requests, whose host names +// the org. orgSlug names the org's content host (`.`); when a caller does not know it, the layer looks it up once. +export interface ProgramTenant { + readonly orgId: string; + readonly orgSlug?: string; + readonly userId: string | null; +} + // The tenant a program acts for. Requests take it from locals.auth, // background work names it explicitly. -export interface ProgramIdentity { - readonly orgId: string; +export interface ProgramIdentity extends ProgramTenant { readonly userId: string; } diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index a2df904..5526093 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -1,6 +1,8 @@ import { Effect, Layer } from 'effect'; import { ConfigLive } from './config'; -import type { ProgramIdentity } from './identity'; +import { StorageError } from './errors'; +import type { ProgramTenant } from './identity'; +import { PgSql } from './pg'; import { AuthGuardLive } from './services/auth-guard'; import { AuthLive } from './services/auth'; import { AuthGuardStore, Bucket, Jobs, Pg } from './services/bindings'; @@ -27,24 +29,57 @@ export const PgLive = Layer.unwrap( Effect.map(Pg, (hyperdrive) => pgLayer(hyperdrive)) ); +// The org for the program. A caller that already knows the slug (a +// request, a sweep) hands it over; one that only knows the id (a queued +// job) costs one lookup when the layer is built. +const currentOrgLayer = ( + tenant: ProgramTenant | null, + pg: Layer.Layer +) => { + if (tenant === null) return Layer.succeed(CurrentOrg, anonymousOrg); + if (tenant.orgSlug !== undefined) { + return Layer.succeed(CurrentOrg, { + id: tenant.orgId, + slug: tenant.orgSlug + }); + } + const orgId = tenant.orgId; + return Layer.effect( + CurrentOrg, + Effect.gen(function* () { + const sql = yield* PgSql; + const rows = yield* sql<{ slug: string }>` + SELECT slug FROM orgs WHERE id = ${orgId} LIMIT 1 + `; + const row = rows[0]; + if (!row) return yield* Effect.fail(`Org ${orgId} does not exist`); + return { id: orgId, slug: row.slug }; + }).pipe( + Effect.mapError( + (cause) => new StorageError({ operation: 'resolve org slug', cause }) + ) + ) + ).pipe(Layer.provide(pg)); +}; + // One layer per program, bound to one tenant. Services capture the org // when they are built, so a sweep that visits several orgs builds one // layer per org (cheap: the pool connects lazily) instead of threading // the org through every call. -export const requestLayer = (env: Env, identity: ProgramIdentity | null) => { - const bindings = Layer.mergeAll( +export const requestLayer = (env: Env, tenant: ProgramTenant | null) => { + const platform = Layer.mergeAll( Layer.succeed(Pg, env.HYPERDRIVE), Layer.succeed(Bucket, env.BUCKET), Layer.succeed(AuthGuardStore, env.AUTH_GUARD), Layer.succeed(Jobs, env.JOBS), - Layer.succeed(CurrentOrg, identity ? { id: identity.orgId } : anonymousOrg), Layer.succeed( CurrentUser, - identity ? { id: identity.userId } : anonymousUser + tenant?.userId ? { id: tenant.userId } : anonymousUser ), ConfigLive(env) ); - const pg = PgLive.pipe(Layer.provide(bindings)); + const pg = PgLive.pipe(Layer.provide(platform)); + const bindings = Layer.merge(platform, currentOrgLayer(tenant, pg)); const blobs = BlobsLive.pipe(Layer.provide(bindings)); const jobQueue = JobQueueLive.pipe(Layer.provide(bindings)); const infrastructure = Layer.mergeAll(bindings, pg, blobs, jobQueue); diff --git a/apps/web/src/lib/server/mcp/server.ts b/apps/web/src/lib/server/mcp/server.ts index 765f481..c8dcb6e 100644 --- a/apps/web/src/lib/server/mcp/server.ts +++ b/apps/web/src/lib/server/mcp/server.ts @@ -10,6 +10,7 @@ import { resolveFileContentLink } from '../file-content-link'; import type { AuthContext } from '../identity'; import { AuthGuard } from '../services/auth-guard'; import { Blobs } from '../services/blobs'; +import { currentContentOrigin } from '../services/current-org'; import { Files } from '../services/files'; import { Indexing } from '../services/indexing'; import { Search } from '../services/search'; @@ -105,7 +106,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { orgId: credential.orgId, userId: credential.userId, dashboardOrigin: config.dashboardOrigin, - contentOrigin: config.contentOrigin + contentOrigin: yield* currentContentOrigin }; }) ) @@ -167,7 +168,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { tags: (yield* tags.list).length, maxUploadBytes: config.maxUploadBytes, mcpMaxUploadBytes: MCP_MAX_UPLOAD_BYTES, - contentOrigin: config.contentOrigin, + contentOrigin: yield* currentContentOrigin, semantic: yield* indexing.status }; }) @@ -241,7 +242,6 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { Effect.gen(function* () { const files = yield* Files; const blobs = yield* Blobs; - const config = yield* AppConfig; const detail = yield* files.detail(id); const now = new Date().toISOString(); if (!fileIsLive(detail.file, now)) { @@ -255,7 +255,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { if (detail.file.kind === 'site') { return { file: detail.file, - url: `${config.contentOrigin}/s/${detail.file.id}/`, + url: `${yield* currentContentOrigin}/s/${detail.file.id}/`, expiresAt: null, public: true }; @@ -330,7 +330,6 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { Effect.gen(function* () { const authGuard = yield* AuthGuard; const files = yield* Files; - const config = yield* AppConfig; const rate = yield* authGuard.consume( 'upload', credential.credentialId @@ -362,7 +361,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { return { kind: 'ok' as const, file: result.file, - url: `${config.contentOrigin}/f/${result.file.id}`, + url: `${yield* currentContentOrigin}/f/${result.file.id}`, forcedPublic: result.forcedPublic }; }) @@ -529,7 +528,6 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { Effect.gen(function* () { const authGuard = yield* AuthGuard; const sites = yield* Sites; - const config = yield* AppConfig; const rate = yield* authGuard.consume( 'upload', credential.credentialId @@ -570,7 +568,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { return { kind: 'ok' as const, file: committed.file, - url: `${config.contentOrigin}/s/${committed.file.id}/`, + url: `${yield* currentContentOrigin}/s/${committed.file.id}/`, assetCount: committed.assetCount, cleanupPending: committed.cleanupPending }; diff --git a/apps/web/src/lib/server/security-headers.ts b/apps/web/src/lib/server/security-headers.ts index 09bf4fd..381c889 100644 --- a/apps/web/src/lib/server/security-headers.ts +++ b/apps/web/src/lib/server/security-headers.ts @@ -3,24 +3,32 @@ import { classifyRoute } from './host-gate'; export interface SecurityHeaderContext { readonly pathname: string; readonly requestOrigin: string; - readonly contentOrigin: string; + readonly contentDomain: string; + readonly contentScheme: string; } const HSTS_VALUE = 'max-age=31536000; includeSubDomains'; // The dashboard loads previews (images, media, iframes) and fetches nothing -// cross-origin except content served from CONTENT_ORIGIN. SvelteKit emits a -// nonce'd script-src for its hydration script (svelte.config.js); every other -// directive is owned here so the content origin can come from runtime config. -const dashboardCsp = (contentOrigin: string, scriptSrc: string) => +// cross-origin except content served from `.`, so the +// policy lists the wildcard host under the dashboard's scheme. SvelteKit +// emits a nonce'd script-src for its hydration script (svelte.config.js); +// every other directive is owned here so the domain can come from runtime +// config. +export const contentHostSource = ( + contentScheme: string, + contentDomain: string +) => `${contentScheme}//*.${contentDomain}`; + +const dashboardCsp = (contentSource: string, scriptSrc: string) => [ `default-src 'self'`, scriptSrc, `style-src 'self' 'unsafe-inline'`, - `img-src 'self' data: blob: ${contentOrigin}`, - `media-src 'self' blob: ${contentOrigin}`, - `connect-src 'self' ${contentOrigin}`, - `frame-src ${contentOrigin}`, + `img-src 'self' data: blob: ${contentSource}`, + `media-src 'self' blob: ${contentSource}`, + `connect-src 'self' ${contentSource}`, + `frame-src ${contentSource}`, `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, @@ -78,7 +86,7 @@ export const applySecurityHeaders = ( headers.set( 'Content-Security-Policy', dashboardCsp( - context.contentOrigin, + contentHostSource(context.contentScheme, context.contentDomain), extractScriptSrc(headers.get('Content-Security-Policy')) ) ); diff --git a/apps/web/src/lib/server/services/current-org.ts b/apps/web/src/lib/server/services/current-org.ts index 7540a16..109542d 100644 --- a/apps/web/src/lib/server/services/current-org.ts +++ b/apps/web/src/lib/server/services/current-org.ts @@ -1,11 +1,13 @@ -import { Context } from 'effect'; +import { Context, Effect } from 'effect'; +import { AppConfig } from '../config'; // The tenant a program runs for. Services yield these at construction and // add `org_id = ${org.id}` to every tenant-scoped statement, so the layer -// decides the tenant once and no query can forget it. +// decides the tenant once and no query can forget it. The slug names the +// org's content host; AppConfig.contentOriginFor(org.slug) is its origin. export class CurrentOrg extends Context.Service< CurrentOrg, - { readonly id: string } + { readonly id: string; readonly slug: string } >()('app/CurrentOrg') {} export class CurrentUser extends Context.Service< @@ -13,13 +15,21 @@ export class CurrentUser extends Context.Service< { readonly id: string } >()('app/CurrentUser') {} -const missing = (what: string) => ({ - get id(): string { +const missing = (what: string) => { + const read = (): never => { throw new Error( `No ${what} on this program: tenant-scoped work needs a signed-in caller or an explicit org` ); - } -}); + }; + return { + get id(): string { + return read(); + }, + get slug(): string { + return read(); + } + }; +}; // Stand-ins for programs that have no tenant: content routes, the queue // consumer, cross-org sweeps before they pick an org. They keep every @@ -30,3 +40,11 @@ export const anonymousUser: CurrentUser['Service'] = missing('user'); export const tenantOrgId = (org: CurrentOrg['Service']) => org === anonymousOrg ? null : org.id; + +// Where the current org's content is served from: +// `//.`. Dashboard responses hand this to +// the client and the CLI; grants and thumbnails are bound to it. +export const currentContentOrigin = Effect.map( + Effect.all([AppConfig, CurrentOrg]), + ([config, org]) => config.contentOriginFor(org.slug) +); diff --git a/apps/web/src/lib/server/services/grant-secrets.ts b/apps/web/src/lib/server/services/grant-secrets.ts index c5ec55b..0c9bdd8 100644 --- a/apps/web/src/lib/server/services/grant-secrets.ts +++ b/apps/web/src/lib/server/services/grant-secrets.ts @@ -1,7 +1,9 @@ import { Context, Effect, Layer, Schema } from 'effect'; +import { AppConfig } from '../config'; import { StorageError } from '../errors'; import { createObjectTtlCache } from '../isolate-cache'; import { PgSql } from '../pg'; +import { CurrentOrg } from './current-org'; import { mintPrivateGrant, verifyPrivateGrant, @@ -30,8 +32,18 @@ const signingKeyCache = createObjectTtlCache( SIGNING_KEY_CACHE_TTL_MS ); -type MintGrantInput = Omit; -type VerifyGrantInput = Omit; +// Grants are bound to the current org's content origin +// (`.`): a dashboard request mints for its own org, +// a content request verifies for the org its host names. Verification +// additionally requires the request to arrive on exactly that origin. +type MintGrantInput = Omit< + MintPrivateGrantOptions, + 'signingKey' | 'contentOrigin' +>; +type VerifyGrantInput = Omit< + VerifyPrivateGrantOptions, + 'signingKey' | 'contentOrigin' +>; export interface GrantSecretsShape { readonly mint: ( @@ -58,6 +70,8 @@ const randomSigningKey = () => { const makeGrantSecrets = Effect.gen(function* () { const sql = yield* PgSql; + const config = yield* AppConfig; + const org = yield* CurrentOrg; const signingKey = Effect.gen(function* () { const cached = signingKeyCache.get(signingKeyCacheKey); @@ -100,15 +114,17 @@ const makeGrantSecrets = Effect.gen(function* () { return GrantSecrets.of({ mint: Effect.fn('GrantSecrets.mint')(function* (input) { + const contentOrigin = config.contentOriginFor(org.slug); const key = yield* signingKey; return yield* Effect.promise(() => - mintPrivateGrant({ ...input, signingKey: key }) + mintPrivateGrant({ ...input, contentOrigin, signingKey: key }) ); }), verify: Effect.fn('GrantSecrets.verify')(function* (input) { + const contentOrigin = config.contentOriginFor(org.slug); const key = yield* signingKey; return yield* Effect.promise(() => - verifyPrivateGrant({ ...input, signingKey: key }) + verifyPrivateGrant({ ...input, contentOrigin, signingKey: key }) ); }) }); diff --git a/apps/web/src/lib/server/services/sites/read.ts b/apps/web/src/lib/server/services/sites/read.ts index ad3dc13..5c527f9 100644 --- a/apps/web/src/lib/server/services/sites/read.ts +++ b/apps/web/src/lib/server/services/sites/read.ts @@ -1,13 +1,14 @@ import { sitePathCandidates } from '@adrive/shared'; import { Effect } from 'effect'; import { InvalidRequest, NotFound } from '../../errors'; +import { tenantOrgId } from '../current-org'; import type { SiteInternals } from './internals'; import { SiteAssetRow, type SitesShape } from './types'; export const readOps = ( internals: SiteInternals ): Pick => { - const { all, sql } = internals; + const { all, sql, org } = internals; return { findAsset: Effect.fn('Sites.findAsset')(function* ( @@ -28,6 +29,9 @@ export const readOps = ( }); const includeUnavailable = options.includeUnavailable === true; const pinVersion = options.version !== undefined; + // Content requests carry the org their host names, so a site id + // from another org is a 404 on this host. + const orgId = tenantOrgId(org); const rows = yield* all( sql` SELECT f.org_id, a.path, a.r2_key, a.content_type, a.size_bytes @@ -35,6 +39,7 @@ export const readOps = ( JOIN site_assets a ON a.file_id = f.id AND a.version = f.current_version WHERE f.id = ${fileId} AND f.is_site = true AND f.public = true + AND (${orgId}::text IS NULL OR f.org_id = ${orgId}) AND ( ${includeUnavailable}::boolean OR ( diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index f4f25f5..18af689 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -6,7 +6,6 @@ import { supportsRenderedDashboardThumbnail } from '$lib/file-thumbnail'; import { listingMode } from '$lib/listing'; -import { AppConfig } from '$lib/server/config'; import { runWorkerProgram } from '$lib/server/edge'; import { GrantSecrets } from '$lib/server/services/grant-secrets'; import type { PageServerLoad } from './$types'; @@ -72,10 +71,8 @@ const grantedThumbnailPreloads = async ( Effect.all( thumbnailPreloadTargets(list).map(({ id, version }) => Effect.gen(function* () { - const config = yield* AppConfig; const secrets = yield* GrantSecrets; return yield* secrets.mint({ - contentOrigin: config.contentOrigin, orgId, fileId: id, version diff --git a/apps/web/src/routes/api/files/+server.ts b/apps/web/src/routes/api/files/+server.ts index bdf4754..7eb2831 100644 --- a/apps/web/src/routes/api/files/+server.ts +++ b/apps/web/src/routes/api/files/+server.ts @@ -8,6 +8,7 @@ import { authRateLimitResponse } from '$lib/server/auth-rate-limit-response'; import { InvalidRequest } from '$lib/server/errors'; import { parsePageSize } from '$lib/server/list-cursor'; import { AuthGuard } from '$lib/server/services/auth-guard'; +import { currentContentOrigin } from '$lib/server/services/current-org'; import { Files } from '$lib/server/services/files'; import { Indexing } from '$lib/server/services/indexing'; import { Tags } from '$lib/server/services/tags'; @@ -101,7 +102,7 @@ export const GET: RequestHandler = (event) => { files: listing.files, nextCursor: listing.nextCursor, tags: tagList ?? [], - contentOrigin: config.contentOrigin, + contentOrigin: yield* currentContentOrigin, maxUploadBytes: config.maxUploadBytes, semantic: status ?? { enabled: false, @@ -122,7 +123,6 @@ export const PUT: RequestHandler = async (event) => { Effect.gen(function* () { const authGuard = yield* AuthGuard; const files = yield* Files; - const config = yield* AppConfig; const credential = yield* requireWrite(event); const rateLimit = yield* authGuard.consume( 'upload', @@ -191,7 +191,7 @@ export const PUT: RequestHandler = async (event) => { response: Response.json( { file: result.file, - url: `${config.contentOrigin}/f/${result.file.id}`, + url: `${yield* currentContentOrigin}/f/${result.file.id}`, forcedPublic: result.forcedPublic }, { status: 201 } diff --git a/apps/web/src/routes/api/files/[id]/+server.ts b/apps/web/src/routes/api/files/[id]/+server.ts index a63a23f..98b3fe8 100644 --- a/apps/web/src/routes/api/files/[id]/+server.ts +++ b/apps/web/src/routes/api/files/[id]/+server.ts @@ -8,6 +8,7 @@ import { validateExpiration } from '$lib/server/auth-policy'; import { InvalidRequest } from '$lib/server/errors'; import { decodeJson } from '$lib/server/request-json'; import { parsePageSize } from '$lib/server/list-cursor'; +import { currentContentOrigin } from '$lib/server/services/current-org'; import { Files } from '$lib/server/services/files'; import { Tags } from '$lib/server/services/tags'; import { Indexing } from '$lib/server/services/indexing'; @@ -50,7 +51,7 @@ export const GET: RequestHandler = (event) => { return Response.json({ ...detail, availableTags: tagList, - contentOrigin: config.contentOrigin, + contentOrigin: yield* currentContentOrigin, maxUploadBytes: config.maxUploadBytes, semanticEnabled: semantic.enabled }); diff --git a/apps/web/src/routes/api/search/+server.ts b/apps/web/src/routes/api/search/+server.ts index 952e8e8..1525d3e 100644 --- a/apps/web/src/routes/api/search/+server.ts +++ b/apps/web/src/routes/api/search/+server.ts @@ -3,6 +3,7 @@ import { Effect } from 'effect'; import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; import { requireAuth } from '$lib/server/request-auth'; +import { currentContentOrigin } from '$lib/server/services/current-org'; import { Search } from '$lib/server/services/search'; import { Tags } from '$lib/server/services/tags'; import { Indexing } from '$lib/server/services/indexing'; @@ -33,7 +34,7 @@ export const GET: RequestHandler = (event) => { files: page.files, nextCursor: page.nextCursor, tags: tagList ?? [], - contentOrigin: config.contentOrigin, + contentOrigin: yield* currentContentOrigin, maxUploadBytes: config.maxUploadBytes, semantic: semantic ?? { enabled: false, diff --git a/apps/web/src/routes/api/sites/sessions/[id]/commit/+server.ts b/apps/web/src/routes/api/sites/sessions/[id]/commit/+server.ts index 07f4ca6..bfca5dd 100644 --- a/apps/web/src/routes/api/sites/sessions/[id]/commit/+server.ts +++ b/apps/web/src/routes/api/sites/sessions/[id]/commit/+server.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; -import { AppConfig } from '$lib/server/config'; import { runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; import { requireWrite } from '$lib/server/request-auth'; +import { currentContentOrigin } from '$lib/server/services/current-org'; import { Indexing } from '$lib/server/services/indexing'; import { Sites } from '$lib/server/services/sites'; @@ -11,7 +11,6 @@ export const POST: RequestHandler = async (event) => { const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const config = yield* AppConfig; const sites = yield* Sites; yield* requireWrite(event); const result = yield* sites.commit(params.id); @@ -20,7 +19,7 @@ export const POST: RequestHandler = async (event) => { response: Response.json( { ...result, - url: `${config.contentOrigin}/s/${result.file.id}/` + url: `${yield* currentContentOrigin}/s/${result.file.id}/` }, { status: 201 } ) diff --git a/apps/web/src/routes/f/[id]/+server.ts b/apps/web/src/routes/f/[id]/+server.ts index d1318bc..007cb17 100644 --- a/apps/web/src/routes/f/[id]/+server.ts +++ b/apps/web/src/routes/f/[id]/+server.ts @@ -54,7 +54,6 @@ const serveFile: RequestHandler = ({ params, platform, request, url }) => const expiresAtSeconds = Number(url.searchParams.get('e')); const signature = url.searchParams.get('g') ?? ''; const granted = yield* grantSecrets.verify({ - contentOrigin: config.contentOrigin, orgId: content.orgId, requestOrigin: url.origin, fileId: params.id, diff --git a/apps/web/src/routes/s/[id]/[...path]/+server.ts b/apps/web/src/routes/s/[id]/[...path]/+server.ts index 73eba3c..7d489a9 100644 --- a/apps/web/src/routes/s/[id]/[...path]/+server.ts +++ b/apps/web/src/routes/s/[id]/[...path]/+server.ts @@ -75,7 +75,6 @@ const serveSite: RequestHandler = ({ params, request, url }) => if (grant) { const grantSecrets = yield* GrantSecrets; const granted = yield* grantSecrets.verify({ - contentOrigin: config.contentOrigin, orgId: asset.orgId, requestOrigin: url.origin, fileId: params.id, @@ -86,7 +85,6 @@ const serveSite: RequestHandler = ({ params, request, url }) => if (!granted) return yield* new NotFound({ id: params.id }); if (thumbnailSource) { const thumbnailGranted = yield* grantSecrets.verify({ - contentOrigin: config.contentOrigin, orgId: asset.orgId, requestOrigin: url.origin, fileId: params.id, diff --git a/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts b/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts index 1a12e4f..bcf1039 100644 --- a/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts +++ b/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts @@ -20,10 +20,10 @@ import { storeEdgeCache, thumbnailCacheControl } from '$lib/server/content-cache'; -import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; import { NotFound, StorageError } from '$lib/server/errors'; import { Blobs } from '$lib/server/services/blobs'; +import { currentContentOrigin } from '$lib/server/services/current-org'; import { Files } from '$lib/server/services/files'; import { GrantSecrets } from '$lib/server/services/grant-secrets'; @@ -72,7 +72,9 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => const version = parsedVersion(params.version); if (version === null) return yield* new NotFound({ id: params.id }); - const config = yield* AppConfig; + // The host names the org; sources for the renderer are fetched + // from the same per-org origin. + const contentOrigin = yield* currentContentOrigin; const files = yield* Files; const grantSecrets = yield* GrantSecrets; const expiresAtSeconds = Number(url.searchParams.get('e')); @@ -108,7 +110,6 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => hasGrant || resolved.unavailable || !content.file.public; if (!content.file.public || hasGrant) { const granted = yield* grantSecrets.verify({ - contentOrigin: config.contentOrigin, orgId: content.orgId, requestOrigin: url.origin, fileId: params.id, @@ -167,7 +168,6 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => if (!hasGrant) return yield* new NotFound({ id: params.id }); const sourceGrant = yield* grantSecrets.mint({ - contentOrigin: config.contentOrigin, orgId: content.orgId, fileId: params.id, version: content.file.version, @@ -176,7 +176,6 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => const siteGrant = content.file.kind === 'site' ? yield* grantSecrets.mint({ - contentOrigin: config.contentOrigin, orgId: content.orgId, fileId: params.id, version: content.file.version @@ -184,7 +183,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => : null; const sourceUrl = siteGrant ? dashboardSiteThumbnailSourceUrl( - config.contentOrigin, + contentOrigin, params.id, content.file.version, { @@ -197,7 +196,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => } ) : dashboardThumbnailSourceUrl( - config.contentOrigin, + contentOrigin, params.id, content.file.version, { @@ -212,7 +211,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => url: sourceUrl.href, ...DASHBOARD_RENDERED_THUMBNAIL, allowRequestPattern: [ - dashboardRenderedThumbnailRequestPattern(config.contentOrigin) + dashboardRenderedThumbnailRequestPattern(contentOrigin) ] }) : await fetch(sourceUrl, { diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index 6e4dbe0..aa6ff40 100644 --- a/apps/web/worker-configuration.d.ts +++ b/apps/web/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: a2a3fe7c1a00316d379b37e6a139d74d) +// Generated by Wrangler by running `wrangler types` (hash: 6c1287863c727955d614dd4a3d807365) // Runtime types generated with workerd@1.20260722.1 2026-07-27 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { AUTH_GUARD: KVNamespace; @@ -21,7 +21,7 @@ interface __BaseEnv_Env { WORKOS_WEBHOOK_SECRET: string; CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: string; DASHBOARD_ORIGIN: string; - CONTENT_ORIGIN: string; + CONTENT_DOMAIN: string; } declare namespace Cloudflare { interface ProductionEnv { @@ -44,7 +44,7 @@ declare namespace Cloudflare { WORKOS_WEBHOOK_SECRET: string; CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: string; DASHBOARD_ORIGIN: string; - CONTENT_ORIGIN: string; + CONTENT_DOMAIN: string; } interface Env extends __BaseEnv_Env {} } @@ -53,7 +53,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 91e1414..d1dc178 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -8,11 +8,14 @@ }, "compatibility_date": "2026-07-27", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], - // Local dev defaults. To develop over Tailscale (or any LAN hostname), - // override both origins in apps/web/.dev.vars — see README "Local setup". + // Local dev defaults. Tenant content is served from + // `.` over the dashboard's scheme; browsers resolve + // `*.localhost` to loopback, so no DNS setup is needed. To develop over + // Tailscale (or any LAN hostname), override both values in + // apps/web/.dev.vars — see README "Local setup". "vars": { "DASHBOARD_ORIGIN": "http://localhost:5173", - "CONTENT_ORIGIN": "http://localhost:5174", + "CONTENT_DOMAIN": "localhost:5174", "MAX_UPLOAD_BYTES": "99614720", "SEMANTIC_SEARCH": "auto", "EMBEDDING_MODEL": "@cf/baai/bge-small-en-v1.5", @@ -75,7 +78,10 @@ // 2. Create the production R2 bucket and KV namespace; paste the KV id. // 3. Create the production job queue and its dead-letter queue // (docs/release.md, "Queues"). - // 4. Point DNS for both hostnames at the Worker (routes below). + // 4. Point DNS at the Worker: the dashboard custom domain, plus one + // proxied wildcard record (`AAAA *. 100::`) for + // tenant content (routes below). Universal SSL covers a + // first-level wildcard, so slugs stay one label deep. // 5. `wrangler secret put` MAINTENANCE_SECRET, WORKOS_API_KEY, // WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_WEBHOOK_SECRET // (all `--env production`) @@ -86,13 +92,20 @@ // routes still check visibility in Postgres, then serve from R2 or the // Cache API. "placement": { "mode": "smart" }, + // Content lives on a wildcard under CONTENT_DOMAIN: one proxied + // `*.files.davis7.space` DNS record and the route below. The bare + // custom domain stays until the throwaway content domain replaces + // files.davis7.space; tenant content is never served from the + // brand domain. "routes": [ { "pattern": "drive.davis7.space", "custom_domain": true }, - { "pattern": "files.davis7.space", "custom_domain": true } + { "pattern": "files.davis7.space", "custom_domain": true }, + { "pattern": "*.files.davis7.space/*", "zone_name": "davis7.space" } ], "vars": { "DASHBOARD_ORIGIN": "https://drive.davis7.space", - "CONTENT_ORIGIN": "https://files.davis7.space", + // A throwaway domain replaces this before launch. + "CONTENT_DOMAIN": "files.davis7.space", "MAX_UPLOAD_BYTES": "99614720", // required (not auto) so a missing AI binding fails the deploy // loudly instead of silently degrading to keyword search. diff --git a/docs/release.md b/docs/release.md index 6904365..99c5013 100644 --- a/docs/release.md +++ b/docs/release.md @@ -70,7 +70,7 @@ the repository root. ``` 6. In the Cloudflare dashboard, open **Images → Transformations**, select - the zone that owns `CONTENT_ORIGIN` (`davis7.space` for + the zone that owns `CONTENT_DOMAIN` (`davis7.space` for `files.davis7.space`), and enable transformations. Dashboard thumbnails require this zone-level setting. 7. From `apps/web`, set each secret with `wrangler secret put --env production`: diff --git a/scripts/check-wrangler-drift.mjs b/scripts/check-wrangler-drift.mjs index 9954b8b..3409cfa 100644 --- a/scripts/check-wrangler-drift.mjs +++ b/scripts/check-wrangler-drift.mjs @@ -126,7 +126,7 @@ const main = () => { const ENV_ONLY_VARS = new Set([ 'SEMANTIC_SEARCH', 'DASHBOARD_ORIGIN', - 'CONTENT_ORIGIN' + 'CONTENT_DOMAIN' ]); const localVars = { ...config.vars }; const prodVars = { ...prod.vars }; @@ -146,7 +146,7 @@ const main = () => { } } } - for (const key of ['DASHBOARD_ORIGIN', 'CONTENT_ORIGIN']) { + for (const key of ['DASHBOARD_ORIGIN', 'CONTENT_DOMAIN']) { if (!config.vars?.[key] || !prod.vars?.[key]) { drift.push( `vars.${key}: must be defined at both top level and env.${envName}` From 325963041d58aa7efe92f7669867358f2f3ab367 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:37:18 -0700 Subject: [PATCH 2/4] FEATURE: Let owners change their content slug Slug rules live in slug-policy.ts: 3-32 chars, lowercase alphanumerics with inner hyphens, and a reserved list. Migration 0004 adds orgs.slug_changed_at and org_slug_history. `PATCH /api/org { slug }` (write scope, owner role) validates, enforces one change per 30 days, parks the old slug in the history table, and purges the KV cache for both slugs; a parked slug cannot be claimed by another org while it still redirects, and a slug another org owns is a 409 through the unique constraint. The handle hook answers 301 on a released slug's host to the same path on the org's current host for 30 days (`{ movedTo }` in the slug cache). `GET /api/org` returns the org's settings and the settings page gains a slug field. Co-Authored-By: Claude Fable 5.1 --- apps/web/migrations-pg/0004_slug_history.sql | 19 +++ apps/web/src/hooks.server.ts | 13 +- .../src/lib/components/auth/OrgSlug.svelte | 67 ++++++++ apps/web/src/lib/dashboard/api.ts | 15 ++ apps/web/src/lib/dashboard/parse.ts | 15 ++ apps/web/src/lib/server/content-host.ts | 45 ++++- apps/web/src/lib/server/edge.ts | 2 + apps/web/src/lib/server/layer.ts | 3 + apps/web/src/lib/server/services/org.ts | 160 ++++++++++++++++++ apps/web/src/lib/server/slug-policy.ts | 69 ++++++++ apps/web/src/routes/api/org/+server.ts | 43 +++++ apps/web/src/routes/settings/+page.svelte | 17 +- packages/shared/src/index.ts | 14 ++ 13 files changed, 472 insertions(+), 10 deletions(-) create mode 100644 apps/web/migrations-pg/0004_slug_history.sql create mode 100644 apps/web/src/lib/components/auth/OrgSlug.svelte create mode 100644 apps/web/src/lib/server/services/org.ts create mode 100644 apps/web/src/lib/server/slug-policy.ts create mode 100644 apps/web/src/routes/api/org/+server.ts diff --git a/apps/web/migrations-pg/0004_slug_history.sql b/apps/web/migrations-pg/0004_slug_history.sql new file mode 100644 index 0000000..68671a2 --- /dev/null +++ b/apps/web/migrations-pg/0004_slug_history.sql @@ -0,0 +1,19 @@ +-- migrate:up +-- Slug changes. An owner may rename the org's content host once per 30 +-- days (orgs.slug_changed_at); the previous slug is parked here so the +-- old host redirects to the new one for the same window. A parked slug +-- cannot be taken by another org while it still redirects. + +ALTER TABLE orgs ADD COLUMN slug_changed_at timestamptz; + +CREATE TABLE org_slug_history ( + slug text PRIMARY KEY, + org_id text NOT NULL REFERENCES orgs (id) ON DELETE CASCADE, + released_at timestamptz NOT NULL +); + +CREATE INDEX org_slug_history_org_idx ON org_slug_history (org_id); + +-- migrate:down +DROP TABLE IF EXISTS org_slug_history; +ALTER TABLE orgs DROP COLUMN IF EXISTS slug_changed_at; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 4768b32..618a029 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -35,6 +35,7 @@ export const handle: Handle = async ({ event, resolve }) => { throw cause; } + const origins = normalizeOrigins(originConfig); event.locals.auth = null; event.locals.content = null; if (hostRoute.route === 'content') { @@ -49,6 +50,17 @@ export const handle: Handle = async ({ event, resolve }) => { if (resolved._tag === 'Missing') { return new Response('Not found', { status: 404 }); } + if (resolved._tag === 'Moved') { + const location = new URL(event.url); + location.host = `${resolved.slug}.${origins.contentDomain}`; + return new Response(null, { + status: 301, + headers: { + Location: location.href, + 'Cache-Control': 'public, max-age=300' + } + }); + } event.locals.content = resolved.host; } else { try { @@ -66,7 +78,6 @@ export const handle: Handle = async ({ event, resolve }) => { } } - const origins = normalizeOrigins(originConfig); return applySecurityHeaders(await resolve(event), { pathname: event.url.pathname, requestOrigin: event.url.origin, diff --git a/apps/web/src/lib/components/auth/OrgSlug.svelte b/apps/web/src/lib/components/auth/OrgSlug.svelte new file mode 100644 index 0000000..a1320cd --- /dev/null +++ b/apps/web/src/lib/components/auth/OrgSlug.svelte @@ -0,0 +1,67 @@ + + +
{ + event.preventDefault(); + void save(); + }} +> + + +
+

+ {#if current.nextSlugChangeAt} + Changed recently; it can change again on {formatDate( + current.nextSlugChangeAt + )}. + {:else} + Once per 30 days. The old address redirects for 30 days. + {/if} +

diff --git a/apps/web/src/lib/dashboard/api.ts b/apps/web/src/lib/dashboard/api.ts index f211cce..cf642a9 100644 --- a/apps/web/src/lib/dashboard/api.ts +++ b/apps/web/src/lib/dashboard/api.ts @@ -15,6 +15,7 @@ import { parseFileListResponse, parseFileMutationResponse, parseFileTagsResponse, + parseOrgSettings, parseTagResponse, parseUploadResponse } from './parse'; @@ -75,6 +76,20 @@ export const checkKey = async (token: string, signal?: AbortSignal) => { await request('/api/auth/check', token, { signal }); }; +export const getOrgSettings = async (token: string, signal?: AbortSignal) => { + const response = await request('/api/org', token, { signal }); + return json(parseOrgSettings, response); +}; + +export const changeOrgSlug = async (token: string, slug: string) => { + const response = await request('/api/org', token, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug }) + }); + return json(parseOrgSettings, response); +}; + export const listApiKeys = async (token: string, signal?: AbortSignal) => { const response = await request('/api/auth/keys', token, { signal }); return (await json(parseApiKeyListResponse, response)).keys; diff --git a/apps/web/src/lib/dashboard/parse.ts b/apps/web/src/lib/dashboard/parse.ts index d24ffc9..73e1c06 100644 --- a/apps/web/src/lib/dashboard/parse.ts +++ b/apps/web/src/lib/dashboard/parse.ts @@ -16,6 +16,7 @@ import type { FileSummary, FileTagsResponse, FileVersion, + OrgSettings, Tag, TagResponse, UploadResponse @@ -254,6 +255,20 @@ export const parseApiKeyListResponse = (value: unknown): ApiKeyListResponse => { }; }; +export const parseOrgSettings = (value: unknown): OrgSettings => { + const record = requireRecord(value, 'org'); + return { + id: text(record.id, 'org.id'), + name: text(record.name, 'org.name'), + slug: text(record.slug, 'org.slug'), + contentOrigin: text(record.contentOrigin, 'org.contentOrigin'), + nextSlugChangeAt: maybeString( + record.nextSlugChangeAt, + 'org.nextSlugChangeAt' + ) + }; +}; + export const parseApiKeyCreateResponse = ( value: unknown ): ApiKeyCreateResponse => { diff --git a/apps/web/src/lib/server/content-host.ts b/apps/web/src/lib/server/content-host.ts index 3d9e677..05806b5 100644 --- a/apps/web/src/lib/server/content-host.ts +++ b/apps/web/src/lib/server/content-host.ts @@ -3,6 +3,7 @@ import { runWorkerProgram } from './edge'; import { StorageError } from './errors'; import { PgSql } from './pg'; import { AuthGuardStore } from './services/bindings'; +import { SLUG_REDIRECT_WINDOW_MS } from './slug-policy'; // The org a content request is served for, as resolved from its host. export interface ContentHost { @@ -12,6 +13,9 @@ export interface ContentHost { export type ContentHostResolution = | { readonly _tag: 'Found'; readonly host: ContentHost } + // A slug the org gave up within the redirect window: the hook answers + // 301 to the same path on the org's current host. + | { readonly _tag: 'Moved'; readonly slug: string } // Unknown slug, or an org that is suspended: every path is a 404. | { readonly _tag: 'Missing' }; @@ -27,6 +31,7 @@ const NEGATIVE_CACHE_TTL_SECONDS = 60; const CachedSlug = Schema.Union([ Schema.Struct({ orgId: Schema.String, trust: Schema.String }), + Schema.Struct({ movedTo: Schema.String }), Schema.Struct({ missing: Schema.Literal(true) }) ]); @@ -43,10 +48,13 @@ const decodeCached = (value: string | null) => { const resolution = ( slug: string, entry: typeof CachedSlug.Type -): ContentHostResolution => - 'missing' in entry || entry.trust === 'suspended' +): ContentHostResolution => { + if ('missing' in entry) return { _tag: 'Missing' }; + if ('movedTo' in entry) return { _tag: 'Moved', slug: entry.movedTo }; + return entry.trust === 'suspended' ? { _tag: 'Missing' } : { _tag: 'Found', host: { orgId: entry.orgId, slug } }; +}; export const resolveContentSlug = Effect.fn('resolveContentSlug')(function* ( slug: string @@ -62,17 +70,40 @@ export const resolveContentSlug = Effect.fn('resolveContentSlug')(function* ( }) ); if (cached) return resolution(slug, cached); - const rows = yield* sql<{ id: string; trust: string }>` - SELECT id, trust FROM orgs WHERE slug = ${slug} LIMIT 1 + // The live slug wins; otherwise a slug the org released within the + // redirect window points at its current one (a suspended org's old + // slug still redirects, to a host that then answers 404). + const redirectCutoff = new Date( + Date.now() - SLUG_REDIRECT_WINDOW_MS + ).toISOString(); + const rows = yield* sql<{ + id: string | null; + trust: string | null; + moved_to: string | null; + }>` + SELECT id, trust, moved_to FROM ( + SELECT o.id, o.trust, NULL AS moved_to, 0 AS rank + FROM orgs o WHERE o.slug = ${slug} + UNION ALL + SELECT NULL, NULL, o.slug, 1 AS rank + FROM org_slug_history h + JOIN orgs o ON o.id = h.org_id + WHERE h.slug = ${slug} AND h.released_at > ${redirectCutoff} + ) candidates + ORDER BY rank + LIMIT 1 `.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'resolve org slug', cause }) ) ); const row = rows[0]; - const entry: typeof CachedSlug.Type = row - ? { orgId: row.id, trust: row.trust } - : { missing: true }; + const entry: typeof CachedSlug.Type = + row?.id !== null && row?.id !== undefined && row.trust !== null + ? { orgId: row.id, trust: row.trust } + : row?.moved_to + ? { movedTo: row.moved_to } + : { missing: true }; yield* Effect.tryPromise({ try: () => store.put(key, JSON.stringify(entry), { diff --git a/apps/web/src/lib/server/edge.ts b/apps/web/src/lib/server/edge.ts index 1a6ec85..521428c 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -33,6 +33,7 @@ import type { Indexing } from './services/indexing'; import type { Lifecycle } from './services/lifecycle'; import type { GrantSecrets } from './services/grant-secrets'; import type { JobQueue } from './services/jobs'; +import type { Org } from './services/org'; import type { WorkOSClient } from './services/workos'; export type AppServices = @@ -54,6 +55,7 @@ export type AppServices = | Lifecycle | GrantSecrets | JobQueue + | Org | WorkOSClient; export const isAppError = (failure: unknown): failure is AppError => diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index 5526093..a0d7785 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -23,6 +23,7 @@ import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; import { JobQueueLive } from './services/jobs'; +import { OrgLive } from './services/org'; import { WorkOSLive } from './services/workos'; export const PgLive = Layer.unwrap( @@ -93,6 +94,7 @@ export const requestLayer = (env: Env, tenant: ProgramTenant | null) => { Layer.provide(Layer.merge(infrastructure, workos)) ); const authGuard = AuthGuardLive().pipe(Layer.provide(bindings)); + const orgService = OrgLive.pipe(Layer.provide(infrastructure)); const grantSecrets = GrantSecretsLive.pipe(Layer.provide(infrastructure)); const tags = TagsLive.pipe(Layer.provide(infrastructure)); const search = SearchLive.pipe( @@ -115,6 +117,7 @@ export const requestLayer = (env: Env, tenant: ProgramTenant | null) => { workos, auth, authGuard, + orgService, grantSecrets, tags, search, diff --git a/apps/web/src/lib/server/services/org.ts b/apps/web/src/lib/server/services/org.ts new file mode 100644 index 0000000..66b9f90 --- /dev/null +++ b/apps/web/src/lib/server/services/org.ts @@ -0,0 +1,160 @@ +import { Context, Effect, Layer, Schema } from 'effect'; +import { SqlError } from 'effect/unstable/sql/SqlError'; +import { AppConfig } from '../config'; +import { forgetContentSlug } from '../content-host'; +import { InvalidRequest, StorageError } from '../errors'; +import { PgSql } from '../pg'; +import { + SLUG_REDIRECT_WINDOW_MS, + nextSlugChangeAt, + validateSlug +} from '../slug-policy'; +import { AuthGuardStore } from './bindings'; +import { CurrentOrg } from './current-org'; + +const OrgRow = Schema.Struct({ + id: Schema.String, + slug: Schema.String, + name: Schema.String, + slug_changed_at: Schema.NullOr(Schema.String) +}); + +export interface OrgSettings { + readonly id: string; + readonly name: string; + readonly slug: string; + readonly contentOrigin: string; + // When the slug may next change; null when it may now. + readonly nextSlugChangeAt: string | null; +} + +export interface OrgShape { + readonly settings: Effect.Effect; + // Renames the org's content host. The old slug redirects to the new one + // for 30 days and cannot be claimed meanwhile; both cache entries are + // dropped so the change is live before they would have expired. + readonly changeSlug: ( + slug: string + ) => Effect.Effect; +} + +export class Org extends Context.Service()('app/Org') {} + +const makeOrg = Effect.gen(function* () { + const sql = yield* PgSql; + const config = yield* AppConfig; + const org = yield* CurrentOrg; + const store = yield* AuthGuardStore; + + const storageError = (operation: string) => + Effect.mapError((cause: unknown) => new StorageError({ operation, cause })); + + const toSettings = (row: typeof OrgRow.Type, now: Date): OrgSettings => ({ + id: row.id, + name: row.name, + slug: row.slug, + contentOrigin: config.contentOriginFor(row.slug), + nextSlugChangeAt: nextSlugChangeAt(row.slug_changed_at, now) + }); + + const load = Effect.gen(function* () { + const rows = yield* sql` + SELECT id, slug, name, slug_changed_at + FROM orgs WHERE id = ${org.id} + `.pipe(storageError('load org')); + const decoded = Schema.decodeUnknownOption(OrgRow)(rows[0]); + if (decoded._tag === 'None') { + return yield* new StorageError({ + operation: 'load org', + cause: 'The current org has no row' + }); + } + return decoded.value; + }); + + return Org.of({ + settings: Effect.map(load, (row) => toSettings(row, new Date())).pipe( + Effect.withSpan('Org.settings') + ), + changeSlug: Effect.fn('Org.changeSlug')(function* (input) { + const validated = validateSlug(input); + if (!validated.ok) { + return yield* new InvalidRequest({ + status: 400, + message: validated.message + }); + } + const now = new Date(); + const current = yield* load; + if (validated.slug === current.slug) return toSettings(current, now); + const allowedAt = nextSlugChangeAt(current.slug_changed_at, now); + if (allowedAt !== null) { + return yield* new InvalidRequest({ + status: 409, + message: `The slug can change again on ${allowedAt.slice(0, 10)}` + }); + } + const redirectCutoff = new Date( + now.getTime() - SLUG_REDIRECT_WINDOW_MS + ).toISOString(); + const nowIso = now.toISOString(); + // The update fails on the unique slug when another org owns the + // name; a parked slug that still redirects is refused first unless + // this org is the one that parked it (taking it back is fine). + const changed = yield* sql + .withTransaction( + Effect.gen(function* () { + const parked = yield* sql<{ org_id: string }>` + SELECT org_id FROM org_slug_history + WHERE slug = ${validated.slug} AND released_at > ${redirectCutoff} + LIMIT 1 + `; + const holder = parked[0]; + if (holder && holder.org_id !== org.id) return null; + yield* sql` + DELETE FROM org_slug_history + WHERE slug = ${validated.slug} + OR (org_id = ${org.id} AND released_at <= ${redirectCutoff}) + `; + yield* sql` + INSERT INTO org_slug_history (slug, org_id, released_at) + VALUES (${current.slug}, ${org.id}, ${nowIso}) + ON CONFLICT (slug) DO UPDATE + SET org_id = EXCLUDED.org_id, released_at = EXCLUDED.released_at + `; + const rows = yield* sql` + UPDATE orgs + SET slug = ${validated.slug}, slug_changed_at = ${nowIso} + WHERE id = ${org.id} AND slug = ${current.slug} + RETURNING id, slug, name, slug_changed_at + `; + return Schema.decodeUnknownOption(OrgRow)(rows[0]); + }) + ) + .pipe( + Effect.catchIf( + (cause) => + cause instanceof SqlError && + cause.cause._tag === 'UniqueViolation', + () => Effect.succeed(null) + ), + storageError('change org slug') + ); + if (changed === null || changed._tag === 'None') { + return yield* new InvalidRequest({ + status: 409, + message: 'That slug is taken' + }); + } + yield* Effect.all( + [forgetContentSlug(current.slug), forgetContentSlug(validated.slug)], + { concurrency: 'unbounded' } + ).pipe(Effect.provideService(AuthGuardStore, store)); + // A session pins the slug it signed in with; it is refreshed on + // the next request through the membership row, so no cookie work. + return toSettings(changed.value, now); + }) + }); +}); + +export const OrgLive = Layer.effect(Org, makeOrg); diff --git a/apps/web/src/lib/server/slug-policy.ts b/apps/web/src/lib/server/slug-policy.ts new file mode 100644 index 0000000..972f7f5 --- /dev/null +++ b/apps/web/src/lib/server/slug-policy.ts @@ -0,0 +1,69 @@ +// Rules for the label an org's content is served under +// (`.`). The host gate accepts a superset of this +// (any 3-32 char [a-z0-9-] label) so an old slug can still redirect; only +// slugs an owner may choose go through validateSlug. + +export const SLUG_MIN_LENGTH = 3; +export const SLUG_MAX_LENGTH = 32; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Labels that would read as ours, or as infrastructure, on the content +// domain. Kept short on purpose: a slug is a hostname label, nothing more. +export const RESERVED_SLUGS: ReadonlySet = new Set([ + 'www', + 'api', + 'admin', + 'app', + 'mail', + 'static', + 'cdn', + 'files', + 'drive', + 'assets', + 'status', + 'help', + 'support', + 'login', + 'signup', + 'billing', + 'dashboard' +]); + +// One change per 30 days, and the old slug keeps redirecting for as long. +export const SLUG_CHANGE_INTERVAL_MS = 30 * 24 * 60 * 60 * 1_000; +export const SLUG_REDIRECT_WINDOW_MS = SLUG_CHANGE_INTERVAL_MS; + +export type SlugValidation = + | { readonly ok: true; readonly slug: string } + | { readonly ok: false; readonly message: string }; + +export const validateSlug = (value: string): SlugValidation => { + const slug = value.trim().toLowerCase(); + if (slug.length < SLUG_MIN_LENGTH || slug.length > SLUG_MAX_LENGTH) { + return { + ok: false, + message: `A slug is ${SLUG_MIN_LENGTH} to ${SLUG_MAX_LENGTH} characters` + }; + } + if (!SLUG_PATTERN.test(slug)) { + return { + ok: false, + message: + 'A slug uses lowercase letters, digits, and hyphens, and cannot start or end with a hyphen' + }; + } + if (RESERVED_SLUGS.has(slug)) { + return { ok: false, message: 'That slug is reserved' }; + } + return { ok: true, slug }; +}; + +// When an org may change its slug again, or null when it may now. +export const nextSlugChangeAt = ( + slugChangedAt: string | null, + now: Date = new Date() +) => { + if (slugChangedAt === null) return null; + const allowedAt = new Date(slugChangedAt).getTime() + SLUG_CHANGE_INTERVAL_MS; + return allowedAt > now.getTime() ? new Date(allowedAt).toISOString() : null; +}; diff --git a/apps/web/src/routes/api/org/+server.ts b/apps/web/src/routes/api/org/+server.ts new file mode 100644 index 0000000..51e0298 --- /dev/null +++ b/apps/web/src/routes/api/org/+server.ts @@ -0,0 +1,43 @@ +import { OrgUpdateSchema } from '@adrive/shared'; +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { InvalidRequest } from '$lib/server/errors'; +import { requireAuth, requireWrite } from '$lib/server/request-auth'; +import { decodeJson } from '$lib/server/request-json'; +import { Org } from '$lib/server/services/org'; + +export const GET: RequestHandler = (event) => { + const { request } = event; + return runEdge( + Effect.gen(function* () { + const org = yield* Org; + yield* requireAuth(event); + return Response.json(yield* org.settings); + }) + ); +}; + +// Renaming the content host moves every published link, so only an +// owner with a write credential may do it. +export const PATCH: RequestHandler = (event) => { + const { request } = event; + return runEdge( + Effect.gen(function* () { + const org = yield* Org; + const auth = yield* requireWrite(event); + if (auth.role !== 'owner') { + return yield* new InvalidRequest({ + status: 403, + message: 'Only an owner can change the slug' + }); + } + const input = yield* decodeJson( + request, + OrgUpdateSchema, + 'A slug is required' + ); + return Response.json(yield* org.changeSlug(input.slug)); + }) + ); +}; diff --git a/apps/web/src/routes/settings/+page.svelte b/apps/web/src/routes/settings/+page.svelte index ba080da..21511e4 100644 --- a/apps/web/src/routes/settings/+page.svelte +++ b/apps/web/src/routes/settings/+page.svelte @@ -1,8 +1,9 @@ @@ -68,7 +74,9 @@
Content origin
- {settings.current?.contentOrigin ?? 'Unavailable'} + {org.current?.contentOrigin ?? + settings.current?.contentOrigin ?? + 'Unavailable'}
@@ -92,6 +100,11 @@ .costNotice}

{/if} + {#if org.current} + {#key org.current.slug} + + {/key} + {/if}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2699df2..970c12f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -336,6 +336,20 @@ export const ApiKeyCreateResponseSchema = Schema.Struct({ token: Schema.String }); +export const OrgSettingsSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + slug: Schema.String, + contentOrigin: Schema.String, + nextSlugChangeAt: Schema.NullOr(Schema.String) +}); + +export type OrgSettings = typeof OrgSettingsSchema.Type; + +export const OrgUpdateSchema = Schema.Struct({ + slug: Schema.String +}); + export const DeviceAuthorizationCreateSchema = Schema.Struct({ name: Schema.String }); From 84ecc6c72ec2f270cdba0f1ea7b8f0b0089c35e0 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:14:04 -0700 Subject: [PATCH 3/4] FIX: Preserve tenant addresses and private links across renames --- README.md | 38 ++++--- apps/web/.dev.vars.example | 24 +++-- ...slug_history.sql => 0005_slug_history.sql} | 0 apps/web/src/app.d.ts | 1 + .../src/lib/components/auth/OrgSlug.svelte | 17 ++- apps/web/src/lib/server/host-gate.ts | 5 +- .../src/lib/server/services/grant-secrets.ts | 43 +++++++- apps/web/src/lib/server/services/org.ts | 101 ++++++++++++------ apps/web/src/lib/server/slug-claims.ts | 7 ++ apps/web/src/lib/server/slug-policy.ts | 2 +- apps/web/src/lib/server/tenants.ts | 28 ++++- apps/web/src/routes/+layout.server.ts | 1 + apps/web/src/routes/settings/+page.svelte | 43 +++++++- apps/web/wrangler.jsonc | 15 +-- docs/release.md | 25 ++++- 15 files changed, 267 insertions(+), 83 deletions(-) rename apps/web/migrations-pg/{0004_slug_history.sql => 0005_slug_history.sql} (100%) create mode 100644 apps/web/src/lib/server/slug-claims.ts diff --git a/README.md b/README.md index e657e53..f490d64 100644 --- a/README.md +++ b/README.md @@ -90,23 +90,35 @@ page shows your org's content origin. ### Developing over Tailscale (or another network hostname) -The dev server binds `0.0.0.0`, so other devices can use it — phones, -tablets, or a laptop pointed at a beefier dev box. Set both values in -`apps/web/.dev.vars` to the hostname the _browser_ will use. With -Tailscale MagicDNS that's your machine name plus tailnet domain (see -`tailscale status`): +The dev server binds `0.0.0.0`, so other devices on your tailnet can reach +it. MagicDNS resolves the dashboard's machine name, but does not resolve +tenant subdomains beneath that name. For Siva, set these values in +`apps/web/.dev.vars`: ```bash -DASHBOARD_ORIGIN="http://..ts.net:5173" -CONTENT_DOMAIN="..ts.net:5174" +DASHBOARD_ORIGIN="http://siva.otter-hawksbill.ts.net:5173" +CONTENT_DOMAIN="100.100.40.20.nip.io:5174" ``` -`CONTENT_DOMAIN` has no scheme (it follows the dashboard) and content is -served from `.`, so the browser also has to -resolve that wildcard to the machine: `*.localhost` does out of the box, -any other domain needs a wildcard DNS entry. The values just have to match -how the browser addresses the machine, since the Worker enforces its -host-routing rules even in dev. +`CONTENT_DOMAIN` has no scheme; it follows the dashboard. An org's content +URL is then `http://.100.100.40.20.nip.io:5174/`. The `nip.io` service +resolves these tenant hosts to Siva's Tailscale IP while each tenant keeps +its own hostname. For another machine, use its MagicDNS name and Tailscale +IP (`tailscale ip -4`). Alternatively, use a domain whose wildcard DNS +record points to that IP. Verify a sample tenant hostname resolves on the +device running the browser; its resolver may block public DNS answers +that point to private networks. + +Vite also needs to allow the dashboard hostname and content-domain suffix. +Set this in the shell when starting development, separately from +`.dev.vars`, and adjust both entries if you changed the domains: + +```bash +__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="siva.otter-hawksbill.ts.net,.100.100.40.20.nip.io" bun --filter @adrive/web dev +``` + +The content proxy preserves the tenant Host header, so the Worker still +enforces its host-routing rules in development. Production passcode login creates a seven-day, host-only `__Host-adrive-session` cookie. That cookie is always `Secure`, `HttpOnly`, and diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index 62f9e51..cd1b516 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -19,14 +19,16 @@ WORKOS_WEBHOOK_SECRET="replace" # these credentials. Wrangler points the HYPERDRIVE binding at it. CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://adrive:adrive@127.0.0.1:5432/adrive" -# Optional: develop from other devices (e.g. over Tailscale). The dev -# server binds 0.0.0.0, so any hostname that reaches this machine works — -# set both values to how the BROWSER will address it. Content is served -# from `.` (no scheme; it follows the dashboard), -# so the browser must resolve `.` to this machine: -# `*.localhost` does so out of the box, other domains need a wildcard DNS -# entry. With Tailscale MagicDNS that's your machine name plus your -# tailnet domain, e.g.: -# DASHBOARD_ORIGIN="http://..ts.net:5173" -# CONTENT_DOMAIN="..ts.net:5174" -# (find yours with `tailscale status`; omit these lines for localhost-only) +# Optional: develop over Tailscale. Both dev servers bind 0.0.0.0. +# MagicDNS resolves the dashboard machine name, but not tenant subdomains. +# On Siva, use nip.io to resolve each tenant host to the Tailscale IP: +# DASHBOARD_ORIGIN="http://siva.otter-hawksbill.ts.net:5173" +# CONTENT_DOMAIN="100.100.40.20.nip.io:5174" +# Content is served from `.` over the dashboard's +# scheme. For another machine, replace the dashboard hostname and the IP +# (`tailscale ip -4`), or use your own wildcard DNS record. Verify a sample +# tenant hostname resolves on the browser device; its DNS policy may block +# these answers. Omit the overrides for localhost development. +# Vite's allowlist is a SHELL setting, not a Worker variable. Start with: +# __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="siva.otter-hawksbill.ts.net,.100.100.40.20.nip.io" bun --filter @adrive/web dev +# Adjust both allowlist entries to match your dashboard/content hostnames. diff --git a/apps/web/migrations-pg/0004_slug_history.sql b/apps/web/migrations-pg/0005_slug_history.sql similarity index 100% rename from apps/web/migrations-pg/0004_slug_history.sql rename to apps/web/migrations-pg/0005_slug_history.sql diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index dd9081d..127db9b 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -8,6 +8,7 @@ declare global { interface PageData { session?: { readonly user: { readonly email: string }; + readonly role: AuthContext['role']; readonly org: { readonly name: string; readonly slug: string }; } | null; } diff --git a/apps/web/src/lib/components/auth/OrgSlug.svelte b/apps/web/src/lib/components/auth/OrgSlug.svelte index a1320cd..3c93b1f 100644 --- a/apps/web/src/lib/components/auth/OrgSlug.svelte +++ b/apps/web/src/lib/components/auth/OrgSlug.svelte @@ -7,7 +7,15 @@ import { getToasts } from '$lib/dashboard/toast.svelte'; import Button from '$lib/components/ui/Button.svelte'; - let { token, org }: { token: string; org: OrgSettings } = $props(); + let { + token, + org, + onchanged + }: { + token: string; + org: OrgSettings; + onchanged: (updated: OrgSettings) => void; + } = $props(); const toasts = getToasts(); // The settings page keys this component on the slug, so the prop only // seeds local state; a successful change replaces it from the response. @@ -24,8 +32,11 @@ try { current = await changeOrgSlug(token, slug); slug = current.slug; + onchanged(current); toasts.success(`Content now lives at ${current.contentOrigin}`); - await invalidateAll(); + await invalidateAll().catch((cause) => { + toasts.error(cause, 'Could not refresh the page'); + }); } catch (cause) { toasts.error(cause, 'Could not change the slug'); } finally { @@ -35,7 +46,7 @@
{ event.preventDefault(); void save(); diff --git a/apps/web/src/lib/server/host-gate.ts b/apps/web/src/lib/server/host-gate.ts index ebe2d3b..978c937 100644 --- a/apps/web/src/lib/server/host-gate.ts +++ b/apps/web/src/lib/server/host-gate.ts @@ -9,8 +9,9 @@ export interface OriginConfig { // The leading host label a content request is allowed to carry. The slug // policy (slug-policy.ts) is stricter for slugs an owner may choose; the -// gate only has to reject hosts that can never name an org. -const HOST_SLUG_PATTERN = /^[a-z0-9-]{3,32}$/; +// gate also accepts longer slugs generated before the owner policy was +// introduced, up to the DNS label limit. Existing URLs keep working. +const HOST_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; const origin = (value: string, label: string) => { const parsed = new URL(value); diff --git a/apps/web/src/lib/server/services/grant-secrets.ts b/apps/web/src/lib/server/services/grant-secrets.ts index 0c9bdd8..28dc3ec 100644 --- a/apps/web/src/lib/server/services/grant-secrets.ts +++ b/apps/web/src/lib/server/services/grant-secrets.ts @@ -6,6 +6,7 @@ import { PgSql } from '../pg'; import { CurrentOrg } from './current-org'; import { mintPrivateGrant, + PRIVATE_GRANT_TTL_SECONDS, verifyPrivateGrant, type MintPrivateGrantOptions, type PrivateGrant, @@ -122,10 +123,48 @@ const makeGrantSecrets = Effect.gen(function* () { }), verify: Effect.fn('GrantSecrets.verify')(function* (input) { const contentOrigin = config.contentOriginFor(org.slug); + if (input.requestOrigin !== contentOrigin || input.orgId !== org.id) { + return false; + } const key = yield* signingKey; - return yield* Effect.promise(() => - verifyPrivateGrant({ ...input, contentOrigin, signingKey: key }) + const now = input.now ?? new Date(); + const granted = yield* Effect.promise(() => + verifyPrivateGrant({ ...input, contentOrigin, signingKey: key, now }) + ); + if (granted) return true; + + // A rename redirects old links to this authorized current host. Keep + // their signatures valid for the remainder of their original lifetime, + // considering only signing origins this same org recently released. + const cutoff = new Date( + now.getTime() - PRIVATE_GRANT_TTL_SECONDS * 1_000 + ).toISOString(); + const previous = yield* sql<{ slug: string }>` + SELECT slug FROM org_slug_history + WHERE org_id = ${org.id} + AND released_at >= ${cutoff} AND released_at <= ${now.toISOString()} + `.pipe( + Effect.mapError( + (cause) => + new StorageError({ operation: 'load grant signing origins', cause }) + ) ); + for (const { slug } of previous) { + const signedOrigin = config.contentOriginFor(slug); + const valid = yield* Effect.promise(() => + verifyPrivateGrant({ + ...input, + contentOrigin: signedOrigin, + // The actual request host was authorized above; the primitive now + // checks the original signed scope without changing its rules. + requestOrigin: signedOrigin, + signingKey: key, + now + }) + ); + if (valid) return true; + } + return false; }) }); }); diff --git a/apps/web/src/lib/server/services/org.ts b/apps/web/src/lib/server/services/org.ts index 66b9f90..ae056af 100644 --- a/apps/web/src/lib/server/services/org.ts +++ b/apps/web/src/lib/server/services/org.ts @@ -4,6 +4,7 @@ import { AppConfig } from '../config'; import { forgetContentSlug } from '../content-host'; import { InvalidRequest, StorageError } from '../errors'; import { PgSql } from '../pg'; +import { lockSlugClaims } from '../slug-claims'; import { SLUG_REDIRECT_WINDOW_MS, nextSlugChangeAt, @@ -84,33 +85,58 @@ const makeOrg = Effect.gen(function* () { message: validated.message }); } - const now = new Date(); - const current = yield* load; - if (validated.slug === current.slug) return toSettings(current, now); - const allowedAt = nextSlugChangeAt(current.slug_changed_at, now); - if (allowedAt !== null) { - return yield* new InvalidRequest({ - status: 409, - message: `The slug can change again on ${allowedAt.slice(0, 10)}` - }); - } - const redirectCutoff = new Date( - now.getTime() - SLUG_REDIRECT_WINDOW_MS - ).toISOString(); - const nowIso = now.toISOString(); - // The update fails on the unique slug when another org owns the - // name; a parked slug that still redirects is refused first unless - // this org is the one that parked it (taking it back is fine). const changed = yield* sql .withTransaction( Effect.gen(function* () { + // A slug moves between the live and parked tables. Serialize that + // namespace before reading either table, including this org's + // cooldown. A separate statement gives waiters a fresh snapshot. + // Renames happen at most once per org per 30 days, so a brief + // global lock avoids a more complex multi-slug lock protocol. + yield* lockSlugClaims(sql); + const current = yield* load; + const now = new Date(); + if (validated.slug === current.slug) { + return { settings: toSettings(current, now), previousSlug: null }; + } + const allowedAt = nextSlugChangeAt(current.slug_changed_at, now); + if (allowedAt !== null) { + return yield* new InvalidRequest({ + status: 409, + message: `The slug can change again on ${allowedAt.slice(0, 10)}` + }); + } + const redirectCutoff = new Date( + now.getTime() - SLUG_REDIRECT_WINDOW_MS + ).toISOString(); + const nowIso = now.toISOString(); const parked = yield* sql<{ org_id: string }>` SELECT org_id FROM org_slug_history WHERE slug = ${validated.slug} AND released_at > ${redirectCutoff} LIMIT 1 `; const holder = parked[0]; - if (holder && holder.org_id !== org.id) return null; + if (holder && holder.org_id !== org.id) { + return yield* new InvalidRequest({ + status: 409, + message: 'That slug is taken' + }); + } + const rows = yield* sql` + UPDATE orgs + SET slug = ${validated.slug}, slug_changed_at = ${nowIso} + WHERE id = ${org.id} AND slug = ${current.slug} + RETURNING id, slug, name, slug_changed_at + `; + const updated = Schema.decodeUnknownOption(OrgRow)(rows[0]); + if (updated._tag === 'None') { + // A writer outside this protocol changed the row. Fail inside + // the transaction so no history changes can be committed. + return yield* new InvalidRequest({ + status: 409, + message: 'The organization changed; try again' + }); + } yield* sql` DELETE FROM org_slug_history WHERE slug = ${validated.slug} @@ -122,13 +148,10 @@ const makeOrg = Effect.gen(function* () { ON CONFLICT (slug) DO UPDATE SET org_id = EXCLUDED.org_id, released_at = EXCLUDED.released_at `; - const rows = yield* sql` - UPDATE orgs - SET slug = ${validated.slug}, slug_changed_at = ${nowIso} - WHERE id = ${org.id} AND slug = ${current.slug} - RETURNING id, slug, name, slug_changed_at - `; - return Schema.decodeUnknownOption(OrgRow)(rows[0]); + return { + settings: toSettings(updated.value, now), + previousSlug: current.slug + }; }) ) .pipe( @@ -136,23 +159,31 @@ const makeOrg = Effect.gen(function* () { (cause) => cause instanceof SqlError && cause.cause._tag === 'UniqueViolation', - () => Effect.succeed(null) + () => + Effect.fail( + new InvalidRequest({ + status: 409, + message: 'That slug is taken' + }) + ) ), - storageError('change org slug') + Effect.catchTag('SqlError', (cause) => + Effect.fail( + new StorageError({ operation: 'change org slug', cause }) + ) + ) ); - if (changed === null || changed._tag === 'None') { - return yield* new InvalidRequest({ - status: 409, - message: 'That slug is taken' - }); - } + if (changed.previousSlug === null) return changed.settings; yield* Effect.all( - [forgetContentSlug(current.slug), forgetContentSlug(validated.slug)], + [ + forgetContentSlug(changed.previousSlug), + forgetContentSlug(validated.slug) + ], { concurrency: 'unbounded' } ).pipe(Effect.provideService(AuthGuardStore, store)); // A session pins the slug it signed in with; it is refreshed on // the next request through the membership row, so no cookie work. - return toSettings(changed.value, now); + return changed.settings; }) }); }); diff --git a/apps/web/src/lib/server/slug-claims.ts b/apps/web/src/lib/server/slug-claims.ts new file mode 100644 index 0000000..cf00f41 --- /dev/null +++ b/apps/web/src/lib/server/slug-claims.ts @@ -0,0 +1,7 @@ +import type { PgClient } from '@effect/sql-pg'; + +// Call inside the transaction before reading live or parked slug claims. +// Org creation and renaming share this namespace (ASCII "slug"). Keep the +// lock in its own statement so a waiter reads a fresh snapshot afterward. +export const lockSlugClaims = (sql: PgClient.PgClient) => + sql`SELECT pg_advisory_xact_lock(1936487783, 0)`; diff --git a/apps/web/src/lib/server/slug-policy.ts b/apps/web/src/lib/server/slug-policy.ts index 972f7f5..f411c40 100644 --- a/apps/web/src/lib/server/slug-policy.ts +++ b/apps/web/src/lib/server/slug-policy.ts @@ -1,6 +1,6 @@ // Rules for the label an org's content is served under // (`.`). The host gate accepts a superset of this -// (any 3-32 char [a-z0-9-] label) so an old slug can still redirect; only +// (DNS-valid labels of 3-63 characters) so old slugs keep working; only // slugs an owner may choose go through validateSlug. export const SLUG_MIN_LENGTH = 3; diff --git a/apps/web/src/lib/server/tenants.ts b/apps/web/src/lib/server/tenants.ts index f64a29b..07b66bb 100644 --- a/apps/web/src/lib/server/tenants.ts +++ b/apps/web/src/lib/server/tenants.ts @@ -1,5 +1,8 @@ import type { PgClient } from '@effect/sql-pg'; import { Effect } from 'effect'; +import { StorageError } from './errors'; +import { lockSlugClaims } from './slug-claims'; +import { SLUG_MAX_LENGTH, SLUG_REDIRECT_WINDOW_MS } from './slug-policy'; export interface TenantRows { readonly orgId: string; @@ -20,6 +23,27 @@ export interface TenantRows { export const ensureTenant = (sql: PgClient.PgClient, tenant: TenantRows) => sql.withTransaction( Effect.gen(function* () { + yield* lockSlugClaims(sql); + const existing = yield* sql<{ id: string }>` + SELECT id FROM orgs WHERE id = ${tenant.orgId} + `; + if (existing.length === 0) { + const redirectCutoff = new Date( + Date.now() - SLUG_REDIRECT_WINDOW_MS + ).toISOString(); + const reserved = yield* sql<{ org_id: string }>` + SELECT org_id FROM org_slug_history + WHERE slug = ${tenant.slug} AND org_id <> ${tenant.orgId} + AND released_at > ${redirectCutoff} + LIMIT 1 + `; + if (reserved.length > 0) { + return yield* new StorageError({ + operation: 'create tenant', + cause: 'The organization slug is reserved by another organization' + }); + } + } yield* sql` INSERT INTO orgs (id, slug, name) VALUES (${tenant.orgId}, ${tenant.slug}, ${tenant.name}) @@ -62,6 +86,8 @@ export const personalOrgFor = (email: string) => { const local = email.split('@')[0] ?? email; return { name: `${local}'s drive`, - slug: `${slugify(local)}-${randomHex(2)}` + slug: `${slugify(local) + .slice(0, SLUG_MAX_LENGTH - 5) + .replace(/-+$/, '')}-${randomHex(2)}` }; }; diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index c03c967..1500086 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -10,6 +10,7 @@ export const load: LayoutServerLoad = ({ locals, setHeaders, url }) => { auth && auth.via === 'session' ? { user: { email: auth.email }, + role: auth.role, org: { name: auth.orgName, slug: auth.orgSlug } } : null, diff --git a/apps/web/src/routes/settings/+page.svelte b/apps/web/src/routes/settings/+page.svelte index 21511e4..9268ac1 100644 --- a/apps/web/src/routes/settings/+page.svelte +++ b/apps/web/src/routes/settings/+page.svelte @@ -1,13 +1,16 @@