diff --git a/README.md b/README.md index a2785a4..3884bb6 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,12 @@ commands, including an explicitly requested `--reset`, keep their existing database selection behavior. Migration runs serialize through a Postgres advisory lock and wait at most 30 seconds to acquire it. -Replace the example `PASSCODE` with a long local-only value. Copy the API key -printed by the final command, then start both local origins: +Set `WORKOS_DEV_FAKE=true` and leave `WORKOS_API_KEY` empty to use the +in-memory WorkOS fake in the SvelteKit development server, as configured by +`.dev.vars.example`. The sign-in button then signs you in as `user_local` +with no credentials. Production requires real WorkOS credentials even when +the fake flag is set. Copy the API key printed by the final command, then +start both local origins: ```bash bun --filter @adrive/web dev @@ -170,7 +174,7 @@ Run `bun --filter @adrive/web types:worker` after changing Wrangler bindings. The checked-in `worker-configuration.d.ts` is generated from `wrangler.jsonc`. The Cloudflare adapter wrapper emits a standard module Worker with both `fetch` and `scheduled` exports. Its signed internal maintenance request is authenticated -with a short-lived HMAC derived from `PASSCODE`; the endpoint cannot be invoked +with a short-lived HMAC derived from `MAINTENANCE_SECRET`; the endpoint cannot be invoked with a static or public header. The `search_documents` table is derived keyword-search state. After restoring @@ -189,10 +193,11 @@ 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 -the passcode as a secret: +the secrets (`MAINTENANCE_SECRET`, `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`, +`WORKOS_COOKIE_PASSWORD`, `WORKOS_WEBHOOK_SECRET`): ```bash -cd apps/web && bun x wrangler secret put PASSCODE +cd apps/web && bun x wrangler secret put MAINTENANCE_SECRET ``` No remote resource is created or modified by the local setup above. diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index f4bdf72..6831827 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -1,5 +1,19 @@ -# Copy to apps/web/.dev.vars and replace this with a long local-only passcode. -PASSCODE="replace-with-at-least-12-characters" +# Copy to apps/web/.dev.vars. + +# Signs the Worker facade's scheduled and queue self-requests. Any long +# random string; production sets it with `wrangler secret put`. +MAINTENANCE_SECRET="replace-with-at-least-12-characters" + +# WorkOS AuthKit. WORKOS_DEV_FAKE=true explicitly enables the fake in the +# SvelteKit development server when the API key is empty or starts with +# `fake:`. /auth/sign-in then signs in as `user_local` without credentials. +# Production rejects missing/fake keys even with this flag. To use real +# WorkOS, supply all four credentials; the cookie password must be 32+ chars. +WORKOS_DEV_FAKE="true" +WORKOS_API_KEY="" +WORKOS_CLIENT_ID="client_replace" +WORKOS_COOKIE_PASSWORD="replace-with-at-least-32-characters-of-random-text" +WORKOS_WEBHOOK_SECRET="replace" # Local Postgres. `docker compose up -d` at the repo root starts one with # these credentials. Wrangler points the HYPERDRIVE binding at it. diff --git a/apps/web/migrations-pg/0004_tenancy.sql b/apps/web/migrations-pg/0004_tenancy.sql new file mode 100644 index 0000000..95f9a9a --- /dev/null +++ b/apps/web/migrations-pg/0004_tenancy.sql @@ -0,0 +1,266 @@ +-- migrate:up +-- Tenancy. Every tenant row carries org_id; users sign in through WorkOS +-- and land in a personal org. This migration runs against a fresh +-- database (the one-off import from the old drive creates its org first, +-- then copies rows), so the org_id columns are added NOT NULL directly +-- with no backfill. Existing single-tenant databases stay untouched; +-- provision a separate target and follow docs/release.md for cutover. +-- +-- Row level security is the second line of defence, never the first. +-- Every query carries its own `org_id = $1` predicate. Inside a +-- transaction the app additionally runs +-- `SELECT set_config('app.current_org', $1, true)` and the policies below +-- refuse rows from any other org. Outside a transaction the setting is +-- absent (Hyperdrive pools in transaction mode, so session settings are +-- not reliable) and the policies allow the row: those statements rely on +-- their WHERE clause alone. The docker development role is a superuser +-- and bypasses RLS entirely. Production uses a restricted runtime login +-- granted the NOLOGIN adrive_app role; migration credentials stay separate. + +-- Block writes while checking so even a newly created session cannot be +-- lost between the empty-target check and the authentication table drops. +LOCK TABLE files, file_versions, file_chunks, search_documents, tags, + file_tags, site_assets, api_keys, device_codes, dashboard_sessions, + site_upload_sessions, staged_site_assets, pending_site_asset_deletes, + instance_secrets, credential_state IN ACCESS EXCLUSIVE MODE; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM files) + OR EXISTS (SELECT 1 FROM file_versions) + OR EXISTS (SELECT 1 FROM file_chunks) + OR EXISTS (SELECT 1 FROM search_documents) + OR EXISTS (SELECT 1 FROM tags) + OR EXISTS (SELECT 1 FROM file_tags) + OR EXISTS (SELECT 1 FROM site_assets) + OR EXISTS (SELECT 1 FROM api_keys) + OR EXISTS (SELECT 1 FROM device_codes) + OR EXISTS (SELECT 1 FROM dashboard_sessions) + OR EXISTS (SELECT 1 FROM site_upload_sessions) + OR EXISTS (SELECT 1 FROM staged_site_assets) + OR EXISTS (SELECT 1 FROM pending_site_asset_deletes) + OR EXISTS (SELECT 1 FROM instance_secrets) + OR EXISTS (SELECT 1 FROM credential_state) + THEN + RAISE EXCEPTION 'Tenancy bootstrap requires an empty target database' + USING ERRCODE = '55000', + HINT = 'Keep the existing drive database. Create a separate hosted target, then follow the supported import and cutover procedure in docs/release.md. Do not use --reset on existing drive data.'; + END IF; +END $$; + +CREATE TABLE orgs ( + id text PRIMARY KEY, + slug text NOT NULL UNIQUE, + name text NOT NULL, + trust text NOT NULL DEFAULT 'new' CHECK ( + trust IN ('new', 'verified', 'established', 'suspended') + ), + plan text NOT NULL DEFAULT 'free', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + id text PRIMARY KEY, + email text NOT NULL, + email_verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE memberships ( + org_id text NOT NULL REFERENCES orgs (id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'owner', + PRIMARY KEY (org_id, user_id) +); + +CREATE INDEX memberships_user_idx ON memberships (user_id); + +CREATE TABLE org_usage ( + org_id text PRIMARY KEY REFERENCES orgs (id) ON DELETE CASCADE, + stored_bytes bigint NOT NULL DEFAULT 0 CHECK (stored_bytes >= 0), + file_count integer NOT NULL DEFAULT 0 CHECK (file_count >= 0) +); + +-- WorkOS sessions replace the passcode session table and its rotation +-- record; nothing is carried over. +DROP TABLE dashboard_sessions; +DROP TABLE credential_state; + +ALTER TABLE files ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE file_versions ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE file_chunks ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE search_documents ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE tags ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE api_keys ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); +ALTER TABLE api_keys ADD COLUMN user_id text NOT NULL REFERENCES users (id); +ALTER TABLE site_upload_sessions ADD COLUMN org_id text NOT NULL REFERENCES orgs (id); + +-- A device code is created by the CLI before anyone is signed in, so it +-- has no org until the dashboard approves it; approval stamps both +-- columns and the minted key copies them. +ALTER TABLE device_codes ADD COLUMN org_id text REFERENCES orgs (id); +ALTER TABLE device_codes ADD COLUMN user_id text REFERENCES users (id); + +ALTER TABLE tags DROP CONSTRAINT tags_normalized_name_key; +ALTER TABLE tags ADD CONSTRAINT tags_org_normalized_name_key + UNIQUE (org_id, normalized_name); + +CREATE INDEX files_org_active_updated_idx + ON files (org_id, deleted_at, updated_at DESC); +CREATE INDEX tags_org_idx ON tags (org_id); +CREATE INDEX api_keys_org_idx ON api_keys (org_id); +CREATE INDEX search_documents_org_idx ON search_documents (org_id); +CREATE INDEX file_chunks_org_idx ON file_chunks (org_id); + +-- True when no org is pinned on the transaction (plain statements rely on +-- their WHERE clause) or when the row belongs to the pinned org. +CREATE OR REPLACE FUNCTION app_org_visible(row_org text) + RETURNS boolean + LANGUAGE sql + STABLE + PARALLEL SAFE +AS $$ + SELECT NULLIF(current_setting('app.current_org', true), '') IS NULL + OR row_org = current_setting('app.current_org', true) +$$; + +ALTER TABLE files ENABLE ROW LEVEL SECURITY; +ALTER TABLE files FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON files USING (app_org_visible(org_id)); + +ALTER TABLE file_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE file_versions FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON file_versions USING (app_org_visible(org_id)); + +ALTER TABLE file_chunks ENABLE ROW LEVEL SECURITY; +ALTER TABLE file_chunks FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON file_chunks USING (app_org_visible(org_id)); + +ALTER TABLE search_documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE search_documents FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON search_documents + USING (app_org_visible(org_id)); + +ALTER TABLE tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE tags FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON tags USING (app_org_visible(org_id)); + +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON api_keys USING (app_org_visible(org_id)); + +ALTER TABLE device_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE device_codes FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON device_codes + USING (org_id IS NULL OR app_org_visible(org_id)); + +ALTER TABLE site_upload_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE site_upload_sessions FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON site_upload_sessions + USING (app_org_visible(org_id)); + +ALTER TABLE file_tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE file_tags FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON file_tags USING ( + EXISTS ( + SELECT 1 FROM files f + WHERE f.id = file_tags.file_id AND app_org_visible(f.org_id) + ) +); + +ALTER TABLE site_assets ENABLE ROW LEVEL SECURITY; +ALTER TABLE site_assets FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON site_assets USING ( + EXISTS ( + SELECT 1 FROM files f + WHERE f.id = site_assets.file_id AND app_org_visible(f.org_id) + ) +); + +ALTER TABLE staged_site_assets ENABLE ROW LEVEL SECURITY; +ALTER TABLE staged_site_assets FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON staged_site_assets USING ( + EXISTS ( + SELECT 1 FROM site_upload_sessions s + WHERE s.id = staged_site_assets.session_id AND app_org_visible(s.org_id) + ) +); + +-- The application role. Managed Postgres providers may withhold CREATE +-- ROLE from the migration user; that is logged, not fatal, so the schema +-- still lands and the role is created by hand. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'adrive_app') THEN + BEGIN + CREATE ROLE adrive_app NOLOGIN; + EXCEPTION WHEN insufficient_privilege THEN + RAISE NOTICE 'adrive_app role not created: insufficient privilege'; + END; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'adrive_app') THEN + GRANT USAGE ON SCHEMA public TO adrive_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public + TO adrive_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO adrive_app; + END IF; +END $$; + +-- migrate:down +DROP POLICY IF EXISTS org_isolation ON staged_site_assets; +DROP POLICY IF EXISTS org_isolation ON site_assets; +DROP POLICY IF EXISTS org_isolation ON file_tags; +DROP POLICY IF EXISTS org_isolation ON site_upload_sessions; +DROP POLICY IF EXISTS org_isolation ON device_codes; +DROP POLICY IF EXISTS org_isolation ON api_keys; +DROP POLICY IF EXISTS org_isolation ON tags; +DROP POLICY IF EXISTS org_isolation ON search_documents; +DROP POLICY IF EXISTS org_isolation ON file_chunks; +DROP POLICY IF EXISTS org_isolation ON file_versions; +DROP POLICY IF EXISTS org_isolation ON files; +ALTER TABLE staged_site_assets DISABLE ROW LEVEL SECURITY; +ALTER TABLE site_assets DISABLE ROW LEVEL SECURITY; +ALTER TABLE file_tags DISABLE ROW LEVEL SECURITY; +ALTER TABLE site_upload_sessions DISABLE ROW LEVEL SECURITY; +ALTER TABLE device_codes DISABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys DISABLE ROW LEVEL SECURITY; +ALTER TABLE tags DISABLE ROW LEVEL SECURITY; +ALTER TABLE search_documents DISABLE ROW LEVEL SECURITY; +ALTER TABLE file_chunks DISABLE ROW LEVEL SECURITY; +ALTER TABLE file_versions DISABLE ROW LEVEL SECURITY; +ALTER TABLE files DISABLE ROW LEVEL SECURITY; +DROP FUNCTION IF EXISTS app_org_visible(text); +DROP INDEX IF EXISTS file_chunks_org_idx; +DROP INDEX IF EXISTS search_documents_org_idx; +DROP INDEX IF EXISTS api_keys_org_idx; +DROP INDEX IF EXISTS tags_org_idx; +DROP INDEX IF EXISTS files_org_active_updated_idx; +ALTER TABLE tags DROP CONSTRAINT tags_org_normalized_name_key; +ALTER TABLE tags ADD CONSTRAINT tags_normalized_name_key UNIQUE (normalized_name); +ALTER TABLE device_codes DROP COLUMN user_id; +ALTER TABLE device_codes DROP COLUMN org_id; +ALTER TABLE site_upload_sessions DROP COLUMN org_id; +ALTER TABLE api_keys DROP COLUMN user_id; +ALTER TABLE api_keys DROP COLUMN org_id; +ALTER TABLE tags DROP COLUMN org_id; +ALTER TABLE search_documents DROP COLUMN org_id; +ALTER TABLE file_chunks DROP COLUMN org_id; +ALTER TABLE file_versions DROP COLUMN org_id; +ALTER TABLE files DROP COLUMN org_id; +DROP TABLE IF EXISTS org_usage; +DROP TABLE IF EXISTS memberships; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS orgs; +CREATE TABLE credential_state ( + id integer PRIMARY KEY CHECK (id = 1), + passcode_hash text NOT NULL CHECK (length(passcode_hash) = 64), + rotated_at timestamptz NOT NULL +); +CREATE TABLE dashboard_sessions ( + token_hash text PRIMARY KEY, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + last_used_at timestamptz NOT NULL +); +CREATE INDEX dashboard_sessions_expiry_idx ON dashboard_sessions (expires_at); diff --git a/apps/web/package.json b/apps/web/package.json index 8687537..7589b52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@types/node": "^26.1.1", "@types/pg": "^8.23.1", "@typescript/native": "npm:typescript@^7.0.2", + "@workos-inc/node": "10.13.0", "concurrently": "^10.0.4", "svelte": "5.56.8", "svelte-check": "4.7.4", diff --git a/apps/web/scripts/cloudflare-adapter.mjs b/apps/web/scripts/cloudflare-adapter.mjs index e4824f9..bf345a4 100644 --- a/apps/web/scripts/cloudflare-adapter.mjs +++ b/apps/web/scripts/cloudflare-adapter.mjs @@ -15,10 +15,10 @@ export * from ${JSON.stringify(`./${svelteKitWorker}`)}; const toHex = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join(''); -const hmacSign = async (passcode, message) => { +const hmacSign = async (secret, message) => { const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(passcode), + new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] @@ -28,12 +28,12 @@ const hmacSign = async (passcode, message) => { ); }; -const scheduledSignature = (passcode, scheduledTime, cron) => - hmacSign(passcode, \`\${scheduledTime}\\n\${cron}\`); +const scheduledSignature = (secret, scheduledTime, cron) => + hmacSign(secret, \`\${scheduledTime}\\n\${cron}\`); // Mirrors verifyJobsRequest in src/lib/server/cron-auth.ts. -const jobsSignature = (passcode, timestamp, body) => - hmacSign(passcode, \`jobs\\n\${timestamp}\\n\${body}\`); +const jobsSignature = (secret, timestamp, body) => + hmacSign(secret, \`jobs\\n\${timestamp}\\n\${body}\`); export default { fetch(request, env, ctx) { @@ -44,7 +44,7 @@ export default { ctx.waitUntil( (async () => { const signature = await scheduledSignature( - env.PASSCODE, + env.MAINTENANCE_SECRET, scheduledTime, controller.cron ); @@ -80,7 +80,7 @@ export default { body: message.body })) }); - const signature = await jobsSignature(env.PASSCODE, timestamp, body); + const signature = await jobsSignature(env.MAINTENANCE_SECRET, timestamp, body); const response = await sveltekit.fetch( new Request(new URL('/api/internal/jobs', env.DASHBOARD_ORIGIN), { method: 'POST', diff --git a/apps/web/scripts/d1-to-postgres.mjs b/apps/web/scripts/d1-to-postgres.mjs index c01af56..09f2e33 100644 --- a/apps/web/scripts/d1-to-postgres.mjs +++ b/apps/web/scripts/d1-to-postgres.mjs @@ -11,8 +11,14 @@ // Each file is replayed into an in-memory SQLite database, then copied row // by row with type conversion (0/1 -> boolean, ISO text -> timestamptz). // -// bun apps/web/scripts/d1-to-postgres.mjs --dump d1/ --url postgres://... -// bun apps/web/scripts/d1-to-postgres.mjs --dump d1/ --url ... --wipe # truncate first +// bun apps/web/scripts/d1-to-postgres.mjs --dump d1/ --url postgres://... \ +// --org org_01ABC --user user_01ABC --email you@example.com [--slug you] +// ... --wipe # truncate first +// +// The target is multi-tenant, so the import needs the WorkOS org and user +// ids the drive's owner signed up with (sign in once on the hosted app +// first, then read them from the orgs/users tables or the WorkOS +// dashboard). Every copied row lands in that org. // // R2 objects do not move. Keyword search documents are rebuilt from the // copied rows; semantic vectors are not carried over (Vectorize is gone), @@ -31,12 +37,22 @@ const arg = (flag) => { const dumpPath = arg('--dump'); const url = arg('--url') ?? process.env.DATABASE_URL; const wipe = process.argv.includes('--wipe'); -if (!dumpPath || !url) { +const orgId = arg('--org'); +const userId = arg('--user'); +const email = arg('--email'); +if (!dumpPath || !url || !orgId || !userId || !email) { console.error( - 'Usage: d1-to-postgres.mjs --dump --url [--wipe]' + 'Usage: d1-to-postgres.mjs --dump --url --org --user --email [--slug ] [--wipe]' ); process.exit(1); } +const slug = + arg('--slug') ?? + email + .split('@')[0] + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); const sqlite = new DatabaseSync(':memory:', { enableForeignKeyConstraints: false @@ -131,14 +147,31 @@ try { await client.query('BEGIN'); if (wipe) { await client.query( - 'TRUNCATE files, tags, api_keys, device_codes, dashboard_sessions, credential_state, site_upload_sessions, pending_site_asset_deletes, instance_secrets CASCADE' + 'TRUNCATE files, tags, api_keys, device_codes, site_upload_sessions, pending_site_asset_deletes, instance_secrets CASCADE' ); } + await client.query( + `INSERT INTO orgs (id, slug, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, + [orgId, slug, `${slug}'s drive`] + ); + await client.query( + `INSERT INTO users (id, email, email_verified) VALUES ($1, $2, true) ON CONFLICT (id) DO NOTHING`, + [userId, email] + ); + await client.query( + `INSERT INTO memberships (org_id, user_id, role) VALUES ($1, $2, 'owner') ON CONFLICT DO NOTHING`, + [orgId, userId] + ); + await client.query( + `INSERT INTO org_usage (org_id) VALUES ($1) ON CONFLICT DO NOTHING`, + [orgId] + ); await copy( client, 'files', [ 'id', + 'org_id', 'display_name', 'content_type', 'kind', @@ -167,6 +200,7 @@ try { ], (row) => ({ ...row, + org_id: orgId, public: bool(row.public), is_site: bool(row.is_site), created_at: ts(row.created_at), @@ -191,6 +225,7 @@ try { 'file_versions', [ 'file_id', + 'org_id', 'version', 'r2_key', 'size_bytes', @@ -203,6 +238,7 @@ try { ], (row) => ({ ...row, + org_id: orgId, created_at: ts(row.created_at), text_content: row.text_content == null @@ -214,8 +250,8 @@ try { await copy( client, 'tags', - ['id', 'name', 'normalized_name', 'color', 'created_at'], - (row) => ({ ...row, created_at: ts(row.created_at) }) + ['id', 'org_id', 'name', 'normalized_name', 'color', 'created_at'], + (row) => ({ ...row, org_id: orgId, created_at: ts(row.created_at) }) ); await copy(client, 'file_tags', ['file_id', 'tag_id'], (row) => row); await copy( @@ -229,6 +265,8 @@ try { 'api_keys', [ 'id', + 'org_id', + 'user_id', 'name', 'prefix', 'secret_hash', @@ -240,6 +278,8 @@ try { ], (row) => ({ ...row, + org_id: orgId, + user_id: userId, scope: row.scope ?? 'read-write', created_at: ts(row.created_at), expires_at: ts(row.expires_at), @@ -273,11 +313,27 @@ try { queuedStagedAssets += queued.rowCount; } console.log(`staged_site_assets queued for cleanup: ${queuedStagedAssets}`); + // The org's stored-byte counter is derived from what was copied. + await client.query( + `UPDATE org_usage u SET stored_bytes = COALESCE(( + SELECT SUM(CASE + WHEN f.is_site THEN f.size_bytes + ( + SELECT COALESCE(SUM(v.thumbnail_size_bytes), 0) + FROM file_versions v WHERE v.file_id = f.id + ) + ELSE (SELECT COALESCE(SUM(v.size_bytes + v.thumbnail_size_bytes), 0) + FROM file_versions v WHERE v.file_id = f.id) + END) + FROM files f WHERE f.org_id = u.org_id + ), 0) WHERE u.org_id = $1`, + [orgId] + ); + // Sessions, device codes, and the passcode hash are not carried over; // everyone signs in again. Keyword search documents are rebuilt. await client.query(` - INSERT INTO search_documents (file_id, chunk_no, name, tags, body) - SELECT f.id, 0, f.display_name, + INSERT INTO search_documents (file_id, org_id, chunk_no, name, tags, body) + SELECT f.id, f.org_id, 0, f.display_name, COALESCE((SELECT string_agg(t.name, ' ' ORDER BY t.normalized_name) FROM file_tags ft JOIN tags t ON t.id = ft.tag_id WHERE ft.file_id = f.id), ''), left(COALESCE(v.text_content, ''), 65536) diff --git a/apps/web/scripts/pg-rebuild-search.mjs b/apps/web/scripts/pg-rebuild-search.mjs index 98b7c40..ed33c11 100644 --- a/apps/web/scripts/pg-rebuild-search.mjs +++ b/apps/web/scripts/pg-rebuild-search.mjs @@ -20,9 +20,10 @@ try { await client.query('BEGIN'); await client.query('DELETE FROM search_documents'); const inserted = await client.query( - `INSERT INTO search_documents (file_id, chunk_no, name, tags, body) + `INSERT INTO search_documents (file_id, org_id, chunk_no, name, tags, body) SELECT f.id, + f.org_id, 0, f.display_name, COALESCE(( diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 98fc166..403a84c 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,7 +1,20 @@ import type { Env } from '../worker-configuration'; +import type { AuthContext } from '$lib/server/identity'; declare global { namespace App { + // Set by the root layout load for every page. + interface PageData { + session?: { + readonly user: { readonly email: string }; + readonly org: { readonly name: string; readonly slug: string }; + } | null; + } + interface Locals { + // 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; + } interface Platform { env: Env; ctx: ExecutionContext; diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts index 12d82cd..83d54a0 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -1,11 +1,24 @@ +// Secrets come from .dev.vars locally and `wrangler secret put` in +// production; `wrangler types` only declares them when .dev.vars has them, +// so they are pinned here for clean checkouts. declare global { interface Env { - PASSCODE: string; + MAINTENANCE_SECRET: string; + WORKOS_API_KEY?: string; + WORKOS_DEV_FAKE?: string; + WORKOS_CLIENT_ID?: string; + WORKOS_COOKIE_PASSWORD?: string; + WORKOS_WEBHOOK_SECRET?: string; } namespace Cloudflare { interface Env { - PASSCODE: string; + MAINTENANCE_SECRET: string; + WORKOS_API_KEY?: string; + WORKOS_DEV_FAKE?: string; + WORKOS_CLIENT_ID?: string; + WORKOS_COOKIE_PASSWORD?: string; + WORKOS_WEBHOOK_SECRET?: string; } } } diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index f9adada..44fb9dc 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,6 +1,8 @@ import type { Handle } from '@sveltejs/kit'; +import { SESSION_COOKIE, sessionCookieOptions } from '$lib/server/auth-policy'; 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'; export const handle: Handle = async ({ event, resolve }) => { @@ -21,6 +23,26 @@ 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 + ); + } + } catch (cause) { + console.error( + JSON.stringify({ + message: 'request identity could not be resolved', + cause: String(cause) + }) + ); + return new Response('Storage unavailable', { status: 502 }); + } + return applySecurityHeaders(await resolve(event), { pathname: event.url.pathname, requestOrigin: event.url.origin, diff --git a/apps/web/src/lib/components/Dashboard.svelte b/apps/web/src/lib/components/Dashboard.svelte index 59a2ed0..9229048 100644 --- a/apps/web/src/lib/components/Dashboard.svelte +++ b/apps/web/src/lib/components/Dashboard.svelte @@ -251,7 +251,7 @@ {#if !session.ready} {:else if !session.token} - + {:else} {#if deviceCode} {code} only if it - matches the CLI you started. Expires in {remainingLabel}. + matches the CLI you started. + {#if orgName}The key will belong to {orgName}.{/if} + Expires in {remainingLabel}. {/if}

diff --git a/apps/web/src/lib/components/auth/SignIn.svelte b/apps/web/src/lib/components/auth/SignIn.svelte index 3ec43a4..31dbd28 100644 --- a/apps/web/src/lib/components/auth/SignIn.svelte +++ b/apps/web/src/lib/components/auth/SignIn.svelte @@ -1,68 +1,22 @@

Sign in to your drive

-
{ - event.preventDefault(); - void session.connect(passcode); - }} + - - - -
- - Sign in with an API key - -

- Use this on plain-HTTP origins where the secure session cookie cannot be - set. -

-
{ - event.preventDefault(); - void session.connectApiKey(apiKey); - }} - > - - -
-
+ Sign in +
diff --git a/apps/web/src/lib/dashboard/api.ts b/apps/web/src/lib/dashboard/api.ts index 83e53d8..f211cce 100644 --- a/apps/web/src/lib/dashboard/api.ts +++ b/apps/web/src/lib/dashboard/api.ts @@ -15,7 +15,6 @@ import { parseFileListResponse, parseFileMutationResponse, parseFileTagsResponse, - parseSessionsRevokedResponse, parseTagResponse, parseUploadResponse } from './parse'; @@ -76,25 +75,6 @@ export const checkKey = async (token: string, signal?: AbortSignal) => { await request('/api/auth/check', token, { signal }); }; -export const loginWithPasscode = async (passcode: string) => { - await request('/api/auth/session', BROWSER_SESSION, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passcode }) - }); -}; - -export const logoutSession = async () => { - await request('/api/auth/session', BROWSER_SESSION, { method: 'DELETE' }); -}; - -export const logoutEverywhere = async (token: string) => { - const response = await request('/api/auth/sessions', token, { - method: 'DELETE' - }); - return json(parseSessionsRevokedResponse, 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 f0b411c..d24ffc9 100644 --- a/apps/web/src/lib/dashboard/parse.ts +++ b/apps/web/src/lib/dashboard/parse.ts @@ -24,7 +24,6 @@ import type { // Response envelopes that @adrive/shared does not name as types. type ApiKeyListResponse = { readonly keys: ReadonlyArray }; type ApiKeyCreateResponse = { readonly key: ApiKey; readonly token: string }; -type SessionsRevokedResponse = { readonly revoked: number }; type SemanticStatus = { readonly enabled: boolean; readonly indexedChunks: number; @@ -264,12 +263,3 @@ export const parseApiKeyCreateResponse = ( token: text(record.token, 'key.token') }; }; - -export const parseSessionsRevokedResponse = ( - value: unknown -): SessionsRevokedResponse => { - const record = requireRecord(value, 'sessions'); - return { - revoked: integer(record.revoked, 'sessions.revoked') - }; -}; diff --git a/apps/web/src/lib/dashboard/session.svelte.ts b/apps/web/src/lib/dashboard/session.svelte.ts index 1157364..e7eced3 100644 --- a/apps/web/src/lib/dashboard/session.svelte.ts +++ b/apps/web/src/lib/dashboard/session.svelte.ts @@ -1,31 +1,10 @@ import { Context } from 'runed'; -import { - ApiError, - BROWSER_SESSION, - checkKey, - loginWithPasscode, - logoutSession -} from './api'; - -const SESSION_KEY = 'adrive.dashboard.api-key'; - -const readSavedKey = () => { - try { - return sessionStorage.getItem(SESSION_KEY) ?? ''; - } catch { - return ''; - } -}; - -const saveKey = (value: string) => { - try { - if (value) sessionStorage.setItem(SESSION_KEY, value); - else sessionStorage.removeItem(SESSION_KEY); - } catch { - // Storage can be unavailable in privacy-restricted browser contexts. - } -}; +import { ApiError, BROWSER_SESSION, checkKey } from './api'; +// The browser session is a WorkOS cookie the server already validated +// when it rendered the page, so `token` is either the browser-session +// marker or empty. Sign-in and sign-out are full navigations through +// /auth/sign-in and /auth/sign-out. export class DashboardSession { token = $state(''); ready = $state(false); @@ -35,98 +14,24 @@ export class DashboardSession { constructor(browserSession = false) { if (browserSession) { this.token = BROWSER_SESSION; - this.ready = true; } + this.ready = true; } - async connect(value: string) { - this.connecting = true; + // Re-checks the cookie after a client-side navigation that may have + // outlived it (the session expired or was signed out elsewhere). + async restore() { this.error = ''; try { - await loginWithPasscode(value.trim()); await checkKey(BROWSER_SESSION); - saveKey(''); this.token = BROWSER_SESSION; } catch (cause) { - const message = - cause instanceof Error - ? cause.message - : 'Could not verify the passcode'; - this.error = - location.protocol === 'http:' - ? `${message}. This plain-HTTP origin cannot keep the secure session cookie; sign in with an API key instead.` - : message; - } finally { - this.connecting = false; - } - } - - async connectApiKey(value: string) { - const next = value.trim(); - this.connecting = true; - this.error = ''; - try { - await checkKey(next); - saveKey(next); - this.token = next; - } catch (cause) { - this.error = - cause instanceof Error ? cause.message : 'Could not verify the API key'; - } finally { - this.connecting = false; - } - } - - async disconnect() { - if (this.connecting || !this.token) return false; - this.connecting = true; - this.error = ''; - try { - if (this.token === BROWSER_SESSION) await logoutSession(); - saveKey(''); this.token = ''; - return true; - } catch (cause) { - this.error = - cause instanceof Error ? cause.message : 'Could not sign out'; - return false; - } finally { - this.connecting = false; - } - } - - async restore() { - if (this.ready) return; - this.error = ''; - const savedKey = readSavedKey(); - try { - if (savedKey) { - try { - await checkKey(savedKey); - this.token = savedKey; - return; - } catch (cause) { - saveKey(''); - if (!(cause instanceof ApiError && cause.status === 401)) { - this.error = - cause instanceof Error - ? cause.message - : 'Could not restore the API key'; - } - } - } - try { - await checkKey(BROWSER_SESSION); - this.token = BROWSER_SESSION; - this.error = ''; - } catch (cause) { - this.token = ''; - if (!(cause instanceof ApiError && cause.status === 401)) { - this.error = - cause instanceof Error - ? cause.message - : 'Could not restore the session'; - } + if (!(cause instanceof ApiError && cause.status === 401)) { + this.error = + cause instanceof Error + ? cause.message + : 'Could not restore the session'; } } finally { this.ready = true; diff --git a/apps/web/src/lib/device-approval.ts b/apps/web/src/lib/device-approval.ts new file mode 100644 index 0000000..9409bd6 --- /dev/null +++ b/apps/web/src/lib/device-approval.ts @@ -0,0 +1,17 @@ +// Authentication may return only to the dashboard's device approval flow. +// Never carry an arbitrary path or URL through the provider callback. +export const deviceApprovalParams = (input: URLSearchParams) => { + const params = new URLSearchParams(); + const code = input.get('device'); + if (!code || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/i.test(code)) return params; + params.set('device', code.toUpperCase()); + const expires = Number(input.get('expires')); + if ( + Number.isSafeInteger(expires) && + expires > 0 && + Number.isSafeInteger(expires * 1_000) + ) { + params.set('expires', String(expires)); + } + return params; +}; diff --git a/apps/web/src/lib/server/auth-policy.ts b/apps/web/src/lib/server/auth-policy.ts index ed94737..25c12f3 100644 Binary files a/apps/web/src/lib/server/auth-policy.ts and b/apps/web/src/lib/server/auth-policy.ts differ diff --git a/apps/web/src/lib/server/auth-rate-limit-response.ts b/apps/web/src/lib/server/auth-rate-limit-response.ts index aa808d1..57766ec 100644 --- a/apps/web/src/lib/server/auth-rate-limit-response.ts +++ b/apps/web/src/lib/server/auth-rate-limit-response.ts @@ -2,13 +2,9 @@ import type { BlockedAuthAttempt } from './services/auth-guard'; export const authRateLimitResponse = ( decision: BlockedAuthAttempt, - rateLimitMessage = 'Too many authentication requests. Try again later.' -) => { - const message = - decision.reason === 'lockout' - ? 'Too many incorrect passcode attempts. Try again later.' - : rateLimitMessage; - return Response.json( + message = 'Too many authentication requests. Try again later.' +) => + Response.json( { message }, { status: 429, @@ -18,4 +14,3 @@ export const authRateLimitResponse = ( } } ); -}; diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index a5c6c12..513e98e 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -1,12 +1,23 @@ +import { dev } from '$app/environment'; import { Context, Layer } from 'effect'; import { normalizeOrigins } from './host-gate'; +// A null API key is reserved for the explicitly enabled development fake. +// Production always requires real WorkOS credentials. +export interface WorkOSConfig { + readonly apiKey: string | null; + readonly clientId: string; + readonly cookiePassword: string; + readonly webhookSecret: string; +} + export interface AppConfigShape { readonly dashboardOrigin: string; readonly contentOrigin: string; readonly maxUploadBytes: number; - readonly maxTotalBytes: number; - readonly passcode: string; + // Signs the Worker facade's cron and queue self-requests. + readonly maintenanceSecret: string; + readonly workos: WorkOSConfig; readonly semanticSearch: 'off' | 'auto' | 'required'; readonly embeddingModel: '@cf/baai/bge-small-en-v1.5'; readonly embeddingPooling: 'cls'; @@ -28,6 +39,41 @@ const semanticMode = (value: string) => { } }; +const optionalString = (value: unknown) => + typeof value === 'string' ? value : ''; + +const isFakeWorkOSKey = (apiKey: string) => + apiKey === '' || apiKey.startsWith('fake:'); + +const workosFromEnv = (env: Env): WorkOSConfig => { + const rawApiKey = optionalString(env.WORKOS_API_KEY).trim(); + const clientId = optionalString(env.WORKOS_CLIENT_ID); + const cookiePassword = optionalString(env.WORKOS_COOKIE_PASSWORD); + const webhookSecret = optionalString(env.WORKOS_WEBHOOK_SECRET); + if (isFakeWorkOSKey(rawApiKey)) { + if (dev && env.WORKOS_DEV_FAKE === 'true') { + return { apiKey: null, clientId, cookiePassword, webhookSecret }; + } + throw new Error( + 'WORKOS_API_KEY is required; fake authentication requires WORKOS_DEV_FAKE=true in development' + ); + } + if (!clientId) { + throw new Error('WORKOS_CLIENT_ID is required alongside WORKOS_API_KEY'); + } + if (cookiePassword.length < 32) { + throw new Error( + 'WORKOS_COOKIE_PASSWORD must contain at least 32 characters' + ); + } + if (!webhookSecret) { + throw new Error( + 'WORKOS_WEBHOOK_SECRET is required alongside WORKOS_API_KEY' + ); + } + return { apiKey: rawApiKey, clientId, cookiePassword, webhookSecret }; +}; + export const configFromEnv = (env: Env) => { const origins = normalizeOrigins({ dashboardOrigin: env.DASHBOARD_ORIGIN, @@ -37,18 +83,12 @@ export const configFromEnv = (env: Env) => { if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes <= 0) { throw new Error('MAX_UPLOAD_BYTES must be a positive safe integer'); } - // Global cap on stored bytes across all live file versions. Defaults to - // 100 GiB when unset so a leaked credential cannot fill the bucket. - const rawMaxTotalBytes = env.MAX_TOTAL_BYTES as string | undefined; - const maxTotalBytes = - rawMaxTotalBytes === undefined || rawMaxTotalBytes === '' - ? 100 * 1024 ** 3 - : Number(rawMaxTotalBytes); - if (!Number.isSafeInteger(maxTotalBytes) || maxTotalBytes <= 0) { - throw new Error('MAX_TOTAL_BYTES must be a positive safe integer'); - } - if (typeof env.PASSCODE !== 'string' || env.PASSCODE.length < 12) { - throw new Error('PASSCODE must contain at least 12 characters'); + + if ( + typeof env.MAINTENANCE_SECRET !== 'string' || + env.MAINTENANCE_SECRET.length < 12 + ) { + throw new Error('MAINTENANCE_SECRET must contain at least 12 characters'); } const semanticSearch = semanticMode(String(env.SEMANTIC_SEARCH)); if (env.EMBEDDING_MODEL !== '@cf/baai/bge-small-en-v1.5') { @@ -63,8 +103,8 @@ export const configFromEnv = (env: Env) => { return { ...origins, maxUploadBytes, - maxTotalBytes, - passcode: env.PASSCODE, + maintenanceSecret: env.MAINTENANCE_SECRET, + workos: workosFromEnv(env), semanticSearch, embeddingModel: '@cf/baai/bge-small-en-v1.5', embeddingPooling: 'cls', diff --git a/apps/web/src/lib/server/cron-auth.ts b/apps/web/src/lib/server/cron-auth.ts index 6e92920..3c3088b 100644 --- a/apps/web/src/lib/server/cron-auth.ts +++ b/apps/web/src/lib/server/cron-auth.ts @@ -12,10 +12,10 @@ const hexToBytes = (value: string) => ) : new Uint8Array(32); -const hmacKey = (passcode: string, usage: KeyUsage) => +const hmacKey = (secret: string, usage: KeyUsage) => crypto.subtle.importKey( 'raw', - new TextEncoder().encode(passcode), + new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [usage] @@ -25,18 +25,18 @@ const scheduledMessage = (scheduledTime: string, cron: string) => new TextEncoder().encode(`${scheduledTime}\n${cron}`); export const signScheduledRequest = async ( - passcode: string, + secret: string, scheduledTime: string, cron: string ) => { - const key = await hmacKey(passcode, 'sign'); + const key = await hmacKey(secret, 'sign'); return bytesToHex( await crypto.subtle.sign('HMAC', key, scheduledMessage(scheduledTime, cron)) ); }; export const verifyScheduledRequest = async ( - passcode: string, + secret: string, scheduledTime: string | null, cron: string | null, signature: string | null, @@ -50,7 +50,7 @@ export const verifyScheduledRequest = async ( ) { return false; } - const key = await hmacKey(passcode, 'verify'); + const key = await hmacKey(secret, 'verify'); return crypto.subtle.verify( 'HMAC', key, @@ -65,18 +65,18 @@ const jobsMessage = (timestamp: string, body: string) => new TextEncoder().encode(`jobs\n${timestamp}\n${body}`); export const signJobsRequest = async ( - passcode: string, + secret: string, timestamp: string, body: string ) => { - const key = await hmacKey(passcode, 'sign'); + const key = await hmacKey(secret, 'sign'); return bytesToHex( await crypto.subtle.sign('HMAC', key, jobsMessage(timestamp, body)) ); }; export const verifyJobsRequest = async ( - passcode: string, + secret: string, timestamp: string | null, body: string, signature: string | null, @@ -90,7 +90,7 @@ export const verifyJobsRequest = async ( ) { return false; } - const key = await hmacKey(passcode, 'verify'); + const key = await hmacKey(secret, 'verify'); return crypto.subtle.verify( 'HMAC', key, diff --git a/apps/web/src/lib/server/edge.ts b/apps/web/src/lib/server/edge.ts index 2161055..3a6380c 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -7,7 +7,6 @@ import { type RequestEvent } from '@sveltejs/kit'; import { Cause, Effect, Exit } from 'effect'; -import type { SqlClient } from 'effect/unstable/sql'; import { InvalidRequest, MisdirectedRequest, @@ -16,12 +15,14 @@ import { Unauthorized, type AppError } from './errors'; +import type { ProgramIdentity } from './identity'; import { requestLayer } from './layer'; -import type { PgSql } from './pg'; +import { PgSql } from './pg'; import type { AppConfig } from './config'; import type { AuthGuard } from './services/auth-guard'; import type { Auth } from './services/auth'; import type { Blobs } from './services/blobs'; +import type { CurrentOrg, CurrentUser } from './services/current-org'; import type { Files } from './services/files'; import type { Search } from './services/search'; import type { Sites } from './services/sites'; @@ -31,6 +32,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 { WorkOSClient } from './services/workos'; export type AppServices = | PgSql @@ -38,6 +40,8 @@ export type AppServices = | AuthGuard | Auth | Blobs + | CurrentOrg + | CurrentUser | Files | Search | Sites @@ -47,7 +51,8 @@ export type AppServices = | Indexing | Lifecycle | GrantSecrets - | JobQueue; + | JobQueue + | WorkOSClient; export const isAppError = (failure: unknown): failure is AppError => failure instanceof InvalidRequest || @@ -141,6 +146,10 @@ 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. const runWithEvent = async ( event: RequestEvent, program: Effect.Effect @@ -149,7 +158,7 @@ const runWithEvent = async ( if (!env) error(500, 'Cloudflare bindings unavailable'); const exit = await Effect.runPromiseExit( - program.pipe(Effect.provide(requestLayer(env))) + program.pipe(Effect.provide(requestLayer(env, event.locals.auth))) ); if (Exit.isSuccess(exit)) return exit.value; return throwCauseAsHttp(exit.cause); @@ -163,13 +172,59 @@ export const runEdge = (program: Effect.Effect) => { export const runEdgeWithEvent = runWithEvent; export const handleCause = throwCauseAsHttp; +// Background and scheduled work runs outside a request, so the caller +// names the tenant. Work that touches no tenant rows (queue plumbing, +// credential lookups, tests of platform tables) passes null. export const runWorkerProgram = async ( env: Env, - program: Effect.Effect + program: Effect.Effect, + identity: ProgramIdentity | null = null ) => { const exit = await Effect.runPromiseExit( - program.pipe(Effect.provide(requestLayer(env))) + program.pipe(Effect.provide(requestLayer(env, identity))) ); if (Exit.isSuccess(exit)) return exit.value; throw new Error(Cause.pretty(exit.cause)); }; + +// Reconciliation sweeps visit a random handful of live orgs per tick so a +// busy tenant cannot starve the rest. The program runs once per org with +// that org as CurrentOrg; one org's failure is logged and the loop moves +// on. Returns each org's result. +export const runAcrossOrgs = async ( + env: Env, + program: Effect.Effect, + options: { readonly limit?: number } = {} +) => { + const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); + const orgIds = await runWorkerProgram( + env, + Effect.flatMap( + PgSql, + (sql) => sql<{ id: string }>` + SELECT id 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) { + try { + const value = await runWorkerProgram(env, program, { + orgId, + userId: 'system' + }); + results.push({ orgId, value }); + } catch (cause) { + console.error( + JSON.stringify({ + message: 'per-org program failed', + orgId, + cause: String(cause) + }) + ); + } + } + return results; +}; diff --git a/apps/web/src/lib/server/file-content-link.ts b/apps/web/src/lib/server/file-content-link.ts index e679a54..f9cde7e 100644 --- a/apps/web/src/lib/server/file-content-link.ts +++ b/apps/web/src/lib/server/file-content-link.ts @@ -86,6 +86,7 @@ export const resolveFileContentLink = ( } const grant = yield* grantSecrets.mint({ contentOrigin: config.contentOrigin, + orgId: resolved.orgId, fileId: resolved.file.id, version: resolved.file.version }); @@ -111,6 +112,7 @@ export const resolveFileContentLink = ( ? 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/identity.ts b/apps/web/src/lib/server/identity.ts new file mode 100644 index 0000000..22f485f --- /dev/null +++ b/apps/web/src/lib/server/identity.ts @@ -0,0 +1,31 @@ +import type { ApiKeyScope } from '@adrive/shared'; + +// The tenant a program acts for. Requests take it from locals.auth, +// background work names it explicitly. +export interface ProgramIdentity { + readonly orgId: string; + readonly userId: string; +} + +// What a request is allowed to act as. Both credential kinds (WorkOS +// session, adr_ API key) resolve to this one shape so routes and services +// never branch on how the caller signed in. +export interface AuthContext extends ProgramIdentity { + readonly role: string; + readonly via: 'session' | 'api-key'; + readonly scope: ApiKeyScope; + // API key id, or the WorkOS session id for browser sessions. Rate + // limits key on it so one leaked credential cannot exhaust another's + // budget. + readonly credentialId: string; + readonly email: string; + readonly orgName: string; + readonly orgSlug: string; +} + +export interface ResolvedCredential { + readonly auth: AuthContext | null; + // A re-sealed session WorkOS issued while validating this request; the + // handle hook writes it back so the next request skips the refresh. + readonly refreshedSession: string | null; +} diff --git a/apps/web/src/lib/server/indexing-sql.ts b/apps/web/src/lib/server/indexing-sql.ts index 7771f52..c40f620 100644 --- a/apps/web/src/lib/server/indexing-sql.ts +++ b/apps/web/src/lib/server/indexing-sql.ts @@ -3,6 +3,7 @@ import { Data, Effect } from 'effect'; import { refreshSearchDocument } from './search-index'; export interface IndexLease { + readonly orgId: string; readonly fileId: string; readonly version: number; readonly attempt: number; @@ -41,7 +42,8 @@ const staleAsFalse = ( const holdLease = (sql: PgClient.PgClient, lease: IndexLease) => sql<{ held: number }>` SELECT 1 AS held FROM files - WHERE id = ${lease.fileId} AND current_version = ${lease.version} + WHERE id = ${lease.fileId} AND org_id = ${lease.orgId} + AND current_version = ${lease.version} AND index_state = 'running' AND index_lease_token = ${lease.token} FOR UPDATE`.pipe( Effect.flatMap((rows) => @@ -50,7 +52,8 @@ const holdLease = (sql: PgClient.PgClient, lease: IndexLease) => ); const leaseUpdateFilter = (sql: PgClient.PgClient, lease: IndexLease) => - sql`id = ${lease.fileId} AND current_version = ${lease.version} + sql`id = ${lease.fileId} AND org_id = ${lease.orgId} + AND current_version = ${lease.version} AND index_state = 'running' AND index_lease_token = ${lease.token}`; export const claimIndex = ( @@ -65,7 +68,8 @@ export const claimIndex = ( SET index_state = 'running', index_attempts = index_attempts + 1, index_error = NULL, index_next_run_at = ${leaseUntil}, index_lease_token = ${lease.token} - WHERE id = ${lease.fileId} AND current_version = ${lease.version} + WHERE id = ${lease.fileId} AND org_id = ${lease.orgId} + AND current_version = ${lease.version} AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ${now}) AND index_attempts = ${lease.attempt - 1} @@ -92,11 +96,12 @@ export const storeExtractedText = ( // cleanup before writing either the source text or keyword index. yield* sql` UPDATE file_versions SET text_content = ${text.replaceAll('\u0000', '')} - WHERE file_id = ${lease.fileId} AND version = ${lease.version}`; + WHERE file_id = ${lease.fileId} AND org_id = ${lease.orgId} + AND version = ${lease.version}`; yield* sql` UPDATE files SET index_cursor = 1 WHERE ${leaseUpdateFilter(sql, lease)}`; - yield* refreshSearchDocument(sql, lease.fileId); + yield* refreshSearchDocument(sql, lease.fileId, lease.orgId); }) ) .pipe(staleAsFalse); @@ -111,7 +116,8 @@ export const finishKeywordOnly = (sql: PgClient.PgClient, lease: IndexLease) => RETURNING id`.pipe(Effect.map((rows) => rows.length === 1)); // One multi-row statement: the columns arrive as parallel arrays and are -// zipped by unnest, and the embeddings travel in pgvector's text form. +// zipped by unnest, and the embeddings travel in pgvector's text form. The +// org comes from the file row so a chunk can never land in another org. export const upsertFileChunks = ( sql: PgClient.PgClient, rows: ReadonlyArray @@ -120,9 +126,10 @@ export const upsertFileChunks = ( ? Effect.void : sql` INSERT INTO file_chunks ( - file_id, version, ordinal, char_start, char_end, embedding + file_id, version, ordinal, char_start, char_end, embedding, org_id ) - SELECT * + SELECT u.file_id, u.version, u.ordinal, u.char_start, u.char_end, + u.embedding, f.org_id FROM unnest( ${rows.map((row) => row.fileId)}::text[], ${rows.map((row) => row.version)}::integer[], @@ -130,7 +137,8 @@ export const upsertFileChunks = ( ${rows.map((row) => row.charStart)}::integer[], ${rows.map((row) => row.charEnd)}::integer[], ${rows.map((row) => `[${row.values.join(',')}]`)}::vector[] - ) + ) AS u(file_id, version, ordinal, char_start, char_end, embedding) + JOIN files f ON f.id = u.file_id ON CONFLICT (file_id, version, ordinal) DO UPDATE SET char_start = EXCLUDED.char_start, char_end = EXCLUDED.char_end, @@ -151,7 +159,7 @@ export const semanticCommit = ( yield* holdLease(sql, lease); yield* sql` DELETE FROM file_chunks - WHERE file_id = ${lease.fileId} + WHERE file_id = ${lease.fileId} AND org_id = ${lease.orgId} AND (version <> ${lease.version} OR ordinal >= ${chunks.length})`; yield* upsertFileChunks(sql, chunks); const updated = yield* sql<{ id: string }>` diff --git a/apps/web/src/lib/server/isolate-cache.ts b/apps/web/src/lib/server/isolate-cache.ts index ec3acf7..916f40c 100644 --- a/apps/web/src/lib/server/isolate-cache.ts +++ b/apps/web/src/lib/server/isolate-cache.ts @@ -18,3 +18,31 @@ export const createObjectTtlCache = (ttlMs: number) => { } }; }; + +// String-keyed variant for per-org caches. Expired entries are dropped on +// read and the map is bounded so a long-lived isolate serving many orgs +// cannot grow without limit. +export const createTtlCache = (ttlMs: number, maxEntries = 1_000) => { + const store = new Map(); + return { + get(key: string) { + const cached = store.get(key); + if (!cached) return; + if (cached.expiresAt <= Date.now()) { + store.delete(key); + return; + } + return cached.value; + }, + set(key: string, value: V) { + if (store.size >= maxEntries) { + const oldest = store.keys().next().value; + if (oldest !== undefined) store.delete(oldest); + } + store.set(key, { expiresAt: Date.now() + ttlMs, value }); + }, + delete(key: string) { + store.delete(key); + } + }; +}; diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index 5a33859..a2df904 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -1,10 +1,17 @@ import { Effect, Layer } from 'effect'; import { ConfigLive } from './config'; +import type { ProgramIdentity } from './identity'; import { AuthGuardLive } from './services/auth-guard'; import { AuthLive } from './services/auth'; import { AuthGuardStore, Bucket, Jobs, Pg } from './services/bindings'; import { pgLayer } from './pg'; import { BlobsLive } from './services/blobs'; +import { + anonymousOrg, + anonymousUser, + CurrentOrg, + CurrentUser +} from './services/current-org'; import { FilesLive } from './services/files'; import { SearchLive } from './services/search'; import { SitesLive } from './services/sites'; @@ -14,17 +21,27 @@ import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; import { JobQueueLive } from './services/jobs'; +import { WorkOSLive } from './services/workos'; export const PgLive = Layer.unwrap( Effect.map(Pg, (hyperdrive) => pgLayer(hyperdrive)) ); -export const requestLayer = (env: Env) => { +// 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( 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 + ), ConfigLive(env) ); const pg = PgLive.pipe(Layer.provide(bindings)); @@ -36,7 +53,10 @@ export const requestLayer = (env: Env) => { const semantic = SemanticBindingsLive(env).pipe( Layer.provide(infrastructure) ); - const auth = AuthLive.pipe(Layer.provide(infrastructure)); + const workos = WorkOSLive.pipe(Layer.provide(bindings)); + const auth = AuthLive.pipe( + Layer.provide(Layer.merge(infrastructure, workos)) + ); const authGuard = AuthGuardLive().pipe(Layer.provide(bindings)); const grantSecrets = GrantSecretsLive.pipe(Layer.provide(infrastructure)); const tags = TagsLive.pipe(Layer.provide(infrastructure)); @@ -57,6 +77,7 @@ export const requestLayer = (env: Env) => { return Layer.mergeAll( infrastructure, semantic, + workos, auth, authGuard, grantSecrets, diff --git a/apps/web/src/lib/server/mcp/auth.ts b/apps/web/src/lib/server/mcp/auth.ts index 2087af9..610bce6 100644 --- a/apps/web/src/lib/server/mcp/auth.ts +++ b/apps/web/src/lib/server/mcp/auth.ts @@ -1,32 +1,21 @@ -import { Effect } from 'effect'; -import { bearerToken } from '../auth-policy'; -import { Unauthorized } from '../errors'; -import type { AuthShape } from '../services/auth'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { AuthContext } from '../identity'; +import type { McpRunResult } from './run'; -export const authorizeMcp = (auth: AuthShape, request: Request, url: URL) => { - const token = bearerToken(request.headers.get('authorization')); - if (!token) { - return Effect.fail( - new Unauthorized({ message: 'A valid API key is required' }) - ); +// MCP accepts API keys only: the handle hook already resolved the bearer +// into locals.auth, so this just refuses sessions and missing credentials. +export const authorizeMcp = ( + event: Pick +): McpRunResult => { + const auth = event.locals.auth; + if (!auth || auth.via !== 'api-key') { + return { + ok: false, + message: 'A valid API key is required', + status: 401 + }; } - return auth - .authorize({ - authorization: `Bearer ${token}`, - sessionToken: undefined, - requestOrigin: url.origin, - origin: request.headers.get('origin'), - method: request.method - }) - .pipe( - Effect.flatMap((credential) => - credential.kind === 'api-key' - ? Effect.succeed(credential) - : Effect.fail( - new Unauthorized({ message: 'A valid API key is required' }) - ) - ) - ); + return { ok: true, value: auth }; }; export const mcpUnauthorizedResponse = ( diff --git a/apps/web/src/lib/server/mcp/handler.ts b/apps/web/src/lib/server/mcp/handler.ts index dd11948..acecee1 100644 --- a/apps/web/src/lib/server/mcp/handler.ts +++ b/apps/web/src/lib/server/mcp/handler.ts @@ -1,14 +1,12 @@ import { createMcpHandler } from 'agents/mcp/server'; import { McpServer } from '@modelcontextprotocol/server'; import type { RequestEvent } from '@sveltejs/kit'; -import { Effect } from 'effect'; -import { Auth } from '../services/auth'; import { authorizeMcp, mcpAuthFailureResponse, mcpUnauthorizedResponse } from './auth'; -import { runMcp } from './run'; + import { createAdriveMcpServer } from './server'; export const mcpAllowedHostnames = (dashboardOrigin: string) => { @@ -37,13 +35,7 @@ export const handleMcpRequest = async (event: RequestEvent) => { )(event.request, env, ctx); } - const authorized = await runMcp( - env, - Effect.gen(function* () { - const auth = yield* Auth; - return yield* authorizeMcp(auth, event.request, event.url); - }) - ); + const authorized = authorizeMcp(event); if (!authorized.ok) { return authorized.status === 401 ? mcpUnauthorizedResponse(authorized.message) diff --git a/apps/web/src/lib/server/mcp/run.ts b/apps/web/src/lib/server/mcp/run.ts index 0f79796..cbe8054 100644 --- a/apps/web/src/lib/server/mcp/run.ts +++ b/apps/web/src/lib/server/mcp/run.ts @@ -1,6 +1,7 @@ import { Cause, Effect, Exit } from 'effect'; import { isAppError, runWorkerProgram, type AppServices } from '../edge'; import type { AppError } from '../errors'; +import type { ProgramIdentity } from '../identity'; import { requestLayer } from '../layer'; import { Indexing } from '../services/indexing'; @@ -52,10 +53,11 @@ export const failureFromCause = ( export const runMcp = async ( env: Env, + identity: ProgramIdentity, program: Effect.Effect ): Promise> => { const exit = await Effect.runPromiseExit( - program.pipe(Effect.provide(requestLayer(env))) + program.pipe(Effect.provide(requestLayer(env, identity))) ); if (Exit.isSuccess(exit)) return { ok: true, value: exit.value }; return failureFromCause(exit.cause); @@ -64,6 +66,7 @@ export const runMcp = async ( export const scheduleIndex = ( env: Env, ctx: ExecutionContext, + identity: ProgramIdentity, fileId: string ) => { ctx.waitUntil( @@ -72,7 +75,8 @@ export const scheduleIndex = ( Effect.gen(function* () { const indexing = yield* Indexing; yield* indexing.process(fileId); - }) + }), + identity ) ); }; diff --git a/apps/web/src/lib/server/mcp/server.ts b/apps/web/src/lib/server/mcp/server.ts index 586d1c6..765f481 100644 --- a/apps/web/src/lib/server/mcp/server.ts +++ b/apps/web/src/lib/server/mcp/server.ts @@ -7,7 +7,7 @@ import { AppConfig } from '../config'; import { InvalidRequest, validate } from '../errors'; import { maxPreviewBytes, previewKind } from '../file-preview'; import { resolveFileContentLink } from '../file-content-link'; -import type { AuthorizedCredential } from '../services/auth'; +import type { AuthContext } from '../identity'; import { AuthGuard } from '../services/auth-guard'; import { Blobs } from '../services/blobs'; import { Files } from '../services/files'; @@ -50,14 +50,14 @@ export const WRITE_TOOL_NAMES = [ export interface McpServerInput { readonly env: Env; readonly ctx: ExecutionContext; - readonly credential: AuthorizedCredential; + readonly credential: AuthContext; } const toolValue = async ( - env: Env, + input: McpServerInput, program: Effect.Effect ) => { - const result = await runMcp(env, program); + const result = await runMcp(input.env, input.credential, program); return result.ok ? jsonResult(result.value) : errorResult(result.message, result.status); @@ -95,13 +95,15 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async () => toolValue( - env, + input, Effect.gen(function* () { const config = yield* AppConfig; return { - kind: credential.kind, + kind: credential.via, scope: credential.scope, credentialId: credential.credentialId, + orgId: credential.orgId, + userId: credential.userId, dashboardOrigin: config.dashboardOrigin, contentOrigin: config.contentOrigin }; @@ -117,7 +119,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async () => toolValue( - env, + input, Effect.gen(function* () { const files = yield* Files; const tags = yield* Tags; @@ -183,7 +185,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async ({ cursor, limit }) => toolValue( - env, + input, Effect.gen(function* () { const files = yield* Files; const page = { @@ -207,7 +209,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async ({ query, tag_ids, cursor }) => toolValue( - env, + input, Effect.gen(function* () { const search = yield* Search; const page = yield* search.files({ @@ -235,7 +237,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async ({ id, include_text }) => toolValue( - env, + input, Effect.gen(function* () { const files = yield* Files; const blobs = yield* Blobs; @@ -292,7 +294,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }, async () => toolValue( - env, + input, Effect.gen(function* () { const tags = yield* Tags; return { tags: yield* tags.list }; @@ -324,6 +326,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { if (!decoded.ok) return errorResult(decoded.message, decoded.status); const uploaded = await runMcp( env, + credential, Effect.gen(function* () { const authGuard = yield* AuthGuard; const files = yield* Files; @@ -368,7 +371,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { if (uploaded.value.kind === 'rate-limit') { return errorResult(uploaded.value.message, 429); } - scheduleIndex(env, ctx, uploaded.value.file.id); + scheduleIndex(env, ctx, credential, uploaded.value.file.id); const { kind: _kind, ...value } = uploaded.value; return jsonResult(value); } @@ -386,13 +389,14 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { async ({ id, display_name }) => { const result = await runMcp( env, + credential, Effect.gen(function* () { const files = yield* Files; return yield* files.rename(id, display_name); }) ); if (!result.ok) return errorResult(result.message, result.status); - scheduleIndex(env, ctx, result.value.file.id); + scheduleIndex(env, ctx, credential, result.value.file.id); return jsonResult(result.value); } ); @@ -408,7 +412,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { }, async ({ name, color }) => toolValue( - env, + input, Effect.gen(function* () { const tags = yield* Tags; return { tag: yield* tags.create({ name, color }) }; @@ -428,7 +432,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { }, async ({ id, name, color }) => toolValue( - env, + input, Effect.gen(function* () { const tags = yield* Tags; return { tag: yield* tags.update(id, { name, color }) }; @@ -446,7 +450,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { }, async ({ id }) => toolValue( - env, + input, Effect.gen(function* () { const tags = yield* Tags; yield* tags.remove(id); @@ -466,7 +470,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { }, async ({ file_id, names }) => toolValue( - env, + input, Effect.gen(function* () { const tags = yield* Tags; const files = yield* Files; @@ -521,6 +525,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { } const published = await runMcp( env, + credential, Effect.gen(function* () { const authGuard = yield* AuthGuard; const sites = yield* Sites; @@ -576,7 +581,7 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { if (published.value.kind === 'rate-limit') { return errorResult(published.value.message, 429); } - scheduleIndex(env, ctx, published.value.file.id); + scheduleIndex(env, ctx, credential, published.value.file.id); const { kind: _kind, ...value } = published.value; return jsonResult(value); } diff --git a/apps/web/src/lib/server/pg.ts b/apps/web/src/lib/server/pg.ts index 8c842bd..6ae11c4 100644 --- a/apps/web/src/lib/server/pg.ts +++ b/apps/web/src/lib/server/pg.ts @@ -2,6 +2,7 @@ import { PgClient } from '@effect/sql-pg'; import { Context, Effect, Layer } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; import Pg from 'pg'; +import { CurrentOrg, tenantOrgId } from './services/current-org'; // Postgres wire types come back as strings by default. The app stores // timestamps as ISO strings and compares them lexically, and treats every @@ -33,13 +34,38 @@ export interface PgConnection { readonly connectionString: string; } -// Distinct from SqlClient so Postgres and D1 can coexist in one request -// layer while the port is in progress. Services move over by yielding -// PgSql instead of SqlClient. Once D1 is gone this is the only client. +// The only SQL client. Every service yields it. export class PgSql extends Context.Service()( 'app/PgSql' ) {} +// Row level security keys on the transaction-local `app.current_org` +// setting. When a CurrentOrg is in context, every transaction pins it +// right after BEGIN so the policies in 0004_tenancy.sql refuse rows from +// other orgs even if a query forgets its predicate. Plain statements +// outside a transaction do not pin (Hyperdrive pools per transaction, so +// a session-level setting could leak between requests); they rely on +// their own `org_id = $1` clause, which every scoped query carries. +const orgPinned = (base: PgClient.PgClient): PgClient.PgClient => { + const withTransaction = (effect: Effect.Effect) => + base.withTransaction( + Effect.flatMap(Effect.serviceOption(CurrentOrg), (org) => { + const orgId = org._tag === 'Some' ? tenantOrgId(org.value) : null; + return orgId === null + ? effect + : base`SELECT set_config('app.current_org', ${orgId}, true)`.pipe( + Effect.andThen(effect) + ); + }) + ); + return new Proxy(base, { + get: (target, property, receiver) => + property === 'withTransaction' + ? withTransaction + : Reflect.get(target, property, receiver) + }); +}; + // Hyperdrive hands each request a connection string for its pooler, so the // pool lives for the request layer only. Hyperdrive already multiplexes // real connections, so a small local pool is enough. @@ -60,6 +86,9 @@ export const pgLayer = (connection: PgConnection) => { ); return Layer.effect( PgSql, - PgClient.fromPool({ acquire, applicationName: 'adrive' }) + Effect.map( + PgClient.fromPool({ acquire, applicationName: 'adrive' }), + orgPinned + ) ).pipe(Layer.provide(Reactivity.layer)); }; diff --git a/apps/web/src/lib/server/plans.ts b/apps/web/src/lib/server/plans.ts new file mode 100644 index 0000000..c656977 --- /dev/null +++ b/apps/web/src/lib/server/plans.ts @@ -0,0 +1,17 @@ +// Per-plan limits. Stack F replaces the lookup with an Autumn check; until +// then the org's plan column picks a row here. +const GIB = 1024 ** 3; + +export const PLAN_LIMITS = { + free: { storedBytes: 2 * GIB }, + pro: { storedBytes: 100 * GIB } +} as const; + +export type Plan = keyof typeof PLAN_LIMITS; + +const isPlan = (value: string): value is Plan => value in PLAN_LIMITS; + +// Unknown plans fall back to free so a typo in the column can only make +// an org smaller, never unlimited. +export const planLimits = (plan: string) => + isPlan(plan) ? PLAN_LIMITS[plan] : PLAN_LIMITS.free; diff --git a/apps/web/src/lib/server/private-grant.ts b/apps/web/src/lib/server/private-grant.ts index 010752d..a12b3df 100644 --- a/apps/web/src/lib/server/private-grant.ts +++ b/apps/web/src/lib/server/private-grant.ts @@ -5,6 +5,9 @@ export const PRIVATE_GRANT_TTL_SECONDS = 15 * 60; interface PrivateGrantScope { readonly contentOrigin: string; + // Grants are minted for one org and verified against the org that owns + // the file, so a link cannot be replayed across tenants. + readonly orgId: string; readonly fileId: string; readonly version: number; readonly expiresAtSeconds: number; @@ -33,14 +36,16 @@ export interface PrivateGrant { const grantPayload = ({ contentOrigin, + orgId, fileId, version, expiresAtSeconds, purpose }: PrivateGrantScope) => { const payload = [ - 'adrive-private-file-grant-v1', + 'adrive-private-file-grant-v2', contentOrigin, + orgId, fileId, String(version), String(expiresAtSeconds) @@ -78,7 +83,13 @@ const decodeBase64Url = (value: string) => { return bytes; }; -const validScope = ({ fileId, version, expiresAtSeconds }: PrivateGrantScope) => +const validScope = ({ + orgId, + fileId, + version, + expiresAtSeconds +}: PrivateGrantScope) => + orgId.length > 0 && fileId.length > 0 && Number.isSafeInteger(version) && version > 0 && @@ -88,6 +99,7 @@ const validScope = ({ fileId, version, expiresAtSeconds }: PrivateGrantScope) => export const mintPrivateGrant = async ({ signingKey, contentOrigin, + orgId, fileId, version, purpose, @@ -95,7 +107,14 @@ export const mintPrivateGrant = async ({ }: MintPrivateGrantOptions) => { const expiresAtSeconds = Math.floor(now.getTime() / 1_000) + PRIVATE_GRANT_TTL_SECONDS; - const scope = { contentOrigin, fileId, version, expiresAtSeconds, purpose }; + const scope = { + contentOrigin, + orgId, + fileId, + version, + expiresAtSeconds, + purpose + }; const key = await importSigningKey(signingKey); const signature = base64Url( await crypto.subtle.sign( diff --git a/apps/web/src/lib/server/purge-sql.ts b/apps/web/src/lib/server/purge-sql.ts index a10273a..5243e87 100644 --- a/apps/web/src/lib/server/purge-sql.ts +++ b/apps/web/src/lib/server/purge-sql.ts @@ -1,24 +1,45 @@ import type { PgClient } from '@effect/sql-pg'; import { Effect } from 'effect'; import { StorageError } from './errors'; +import { releaseStoredBytes } from './storage-quota'; // Runs after R2 confirmed the blobs are gone. Deleting the files row // cascades to file_versions, file_tags, site_assets, file_chunks, and // search_documents, so completion is only the ownership hand-off: mark -// the pending claim done, then drop the row. Both steps must hit exactly -// one row or the transaction rolls back and a later sweep retries. -export const completePurge = (sql: PgClient.PgClient, fileId: string) => +// the pending claim done, hand the bytes back to the org, then drop the +// row. Both steps must hit exactly one row or the transaction rolls back +// and a later sweep retries. +export const completePurge = ( + sql: PgClient.PgClient, + orgId: string, + fileId: string +) => sql .withTransaction( Effect.gen(function* () { const done = yield* sql<{ id: string }>` UPDATE files SET purge_state = 'done', purge_error = NULL, purge_next_run_at = NULL - WHERE id = ${fileId} AND purge_state = 'pending' + WHERE id = ${fileId} AND org_id = ${orgId} AND purge_state = 'pending' RETURNING id`; + // A site's row carries its asset total; file versions carry + // their own bytes. Both kinds charge thumbnails per version. + const held = yield* sql<{ bytes: number }>` + SELECT (CASE WHEN f.is_site THEN f.size_bytes ELSE 0 END) + + COALESCE(( + SELECT SUM( + (CASE WHEN f.is_site THEN 0 ELSE v.size_bytes END) + + v.thumbnail_size_bytes + ) + FROM file_versions v + WHERE v.file_id = f.id AND v.org_id = ${orgId} + ), 0) AS bytes + FROM files f + WHERE f.id = ${fileId} AND f.org_id = ${orgId}`; + yield* releaseStoredBytes(sql, orgId, held[0]?.bytes ?? 0); const deleted = yield* sql<{ id: string }>` DELETE FROM files - WHERE id = ${fileId} AND purge_state = 'done' + WHERE id = ${fileId} AND org_id = ${orgId} AND purge_state = 'done' RETURNING id`; if (done.length !== 1 || deleted.length !== 1) { return yield* new StorageError({ diff --git a/apps/web/src/lib/server/request-auth.ts b/apps/web/src/lib/server/request-auth.ts new file mode 100644 index 0000000..7faa153 --- /dev/null +++ b/apps/web/src/lib/server/request-auth.ts @@ -0,0 +1,105 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import { Effect } from 'effect'; +import { + allowsCredentialOrigin, + bearerToken, + SESSION_COOKIE +} from './auth-policy'; +import { AppConfig } from './config'; +import { runWorkerProgram } from './edge'; +import { InvalidRequest, MisdirectedRequest, Unauthorized } from './errors'; +import { classifyRoute } from './host-gate'; +import type { ResolvedCredential } from './identity'; +import { Auth } from './services/auth'; + +type AuthEvent = Pick; + +// Routes read the identity the handle hook resolved. The origin check is +// repeated here so a handler reached with a foreign URL (tests, misrouted +// internal fetches) still refuses even though the host gate ran first. +export const requireAuth = (event: AuthEvent) => + Effect.gen(function* () { + const config = yield* AppConfig; + if (event.url.origin !== config.dashboardOrigin) { + return yield* new MisdirectedRequest({ + message: 'Credentials are accepted only on the dashboard origin' + }); + } + const auth = event.locals.auth; + if (!auth) { + return yield* new Unauthorized({ + message: 'A valid credential is required' + }); + } + return auth; + }); + +// For routes that create, change, or delete data. Read-only API keys are +// authenticated but rejected here with a 403 rather than a 401. +export const requireWrite = (event: AuthEvent) => + requireAuth(event).pipe( + Effect.flatMap((auth) => + auth.scope === 'read-write' + ? Effect.succeed(auth) + : Effect.fail( + new InvalidRequest({ + status: 403, + message: 'This API key is read-only' + }) + ) + ) + ); + +export interface CredentialInput { + readonly authorization: string | null; + readonly sessionCookie: string | undefined; + readonly method: string; + readonly origin: string | null; +} + +const anonymous: ResolvedCredential = { auth: null, refreshedSession: null }; + +// Turns a request's credential into locals.auth. An adr_ bearer key wins +// over a cookie. A cookie on a state-changing request must arrive with the +// dashboard's own Origin header, which the browser sets and a cross-site +// form cannot forge. Anything invalid resolves to null; the route decides +// whether that is a 401. +export const resolveCredential = (env: Env, input: CredentialInput) => + runWorkerProgram( + env, + Effect.gen(function* () { + const auth = yield* Auth; + const config = yield* AppConfig; + const bearer = bearerToken(input.authorization); + if (bearer) { + const resolved = yield* auth.resolveApiKey(bearer); + return { auth: resolved, refreshedSession: null }; + } + if (!input.sessionCookie) return anonymous; + if ( + !allowsCredentialOrigin( + input.method, + input.origin, + config.dashboardOrigin + ) + ) { + return anonymous; + } + return yield* auth.resolveSession(input.sessionCookie); + }).pipe(Effect.catchTag('Unauthorized', () => Effect.succeed(anonymous))) + ); + +// Credentials only mean something on the dashboard origin; content routes +// authorize with signed grants instead and never look at them. +export const resolveEventAuth = ( + env: Env, + event: Pick +) => + classifyRoute(event.url.pathname) === 'content' + ? Promise.resolve(anonymous) + : resolveCredential(env, { + authorization: event.request.headers.get('authorization'), + sessionCookie: event.cookies.get(SESSION_COOKIE), + method: event.request.method, + origin: event.request.headers.get('origin') + }); diff --git a/apps/web/src/lib/server/search-candidates.ts b/apps/web/src/lib/server/search-candidates.ts index d1e12f5..b0179d8 100644 --- a/apps/web/src/lib/server/search-candidates.ts +++ b/apps/web/src/lib/server/search-candidates.ts @@ -17,6 +17,7 @@ export interface RankedRow { } export interface CandidateFilter { + readonly orgId: string; readonly now: string; readonly tagIds: ReadonlyArray; } @@ -34,7 +35,8 @@ export const selectedTagFilter = ( )`; const visibleFile = (sql: PgClient.PgClient, filter: CandidateFilter) => - sql`f.deleted_at IS NULL + sql`f.org_id = ${filter.orgId} + AND f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${filter.now}) ${selectedTagFilter(sql, filter.tagIds)}`; @@ -56,7 +58,8 @@ export const fullTextCandidates = ( ELSE english_query END AS query FROM (SELECT websearch_to_tsquery('english', ${query}) AS english_query) parsed ) q - WHERE d.tsv @@ q.query + WHERE d.org_id = ${filter.orgId} + AND d.tsv @@ q.query AND ${visibleFile(sql, filter)} GROUP BY d.file_id ORDER BY score DESC, d.file_id @@ -73,7 +76,8 @@ export const trigramCandidates = ( SELECT d.file_id, word_similarity(${query}, d.name) AS score FROM search_documents d JOIN files f ON f.id = d.file_id - WHERE d.chunk_no = 0 + WHERE d.org_id = ${filter.orgId} + AND d.chunk_no = 0 AND d.name %> ${query} AND word_similarity(${query}, d.name) > ${TRIGRAM_THRESHOLD}::real AND ${visibleFile(sql, filter)} diff --git a/apps/web/src/lib/server/search-index.ts b/apps/web/src/lib/server/search-index.ts index fce2f28..10768cb 100644 --- a/apps/web/src/lib/server/search-index.ts +++ b/apps/web/src/lib/server/search-index.ts @@ -1,50 +1,58 @@ import type { PgClient } from '@effect/sql-pg'; import { Effect } from 'effect'; -// Every keyword writer shares this transaction lock. Acquire it in a -// separate statement so reads after waiting use a fresh database snapshot. +// Every keyword writer within an org shares this transaction lock. Acquire +// it separately so reads after waiting use a fresh database snapshot. // A nested helper retains the lock until its caller's transaction commits. -// Stable advisory namespace: ASCII "adri" / "sear" for aDrive search. -const keywordWriteLock = (sql: PgClient.PgClient) => - sql`SELECT pg_advisory_xact_lock(1633972841, 1936023922)`; +// Stable advisory namespace: ASCII "adri", followed by the org hash. +const keywordWriteLock = (sql: PgClient.PgClient, orgId: string) => + sql`SELECT pg_advisory_xact_lock(1633972841, hashtext(${orgId}))`; // Postgres keyword index. One search_documents row per file today, so a // refresh is delete then insert; chunk_no exists for splitting long bodies // later. Tag renames and document replacements must serialize together. -export const refreshSearchDocument = (sql: PgClient.PgClient, fileId: string) => +export const refreshSearchDocument = ( + sql: PgClient.PgClient, + fileId: string, + orgId: string +) => sql.withTransaction( Effect.gen(function* () { - yield* keywordWriteLock(sql); - yield* sql`DELETE FROM search_documents WHERE file_id = ${fileId}`; - yield* sql`INSERT INTO search_documents (file_id, chunk_no, name, tags, body) + yield* keywordWriteLock(sql, orgId); + yield* sql`DELETE FROM search_documents + WHERE file_id = ${fileId} AND org_id = ${orgId}`; + yield* sql`INSERT INTO search_documents (file_id, org_id, chunk_no, name, tags, body) SELECT f.id, + f.org_id, 0, f.display_name, COALESCE(( SELECT string_agg(t.name, ' ' ORDER BY t.normalized_name) FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.file_id = f.id + WHERE ft.file_id = f.id AND t.org_id = ${orgId} ), ''), left(COALESCE(v.text_content, ''), 65536) FROM files f JOIN file_versions v - ON v.file_id = f.id AND v.version = f.current_version - WHERE f.id = ${fileId}`; + ON v.file_id = f.id AND v.org_id = ${orgId} + AND v.version = f.current_version + WHERE f.id = ${fileId} AND f.org_id = ${orgId}`; }) ); -export const refreshAllIndexedTags = (sql: PgClient.PgClient) => +export const refreshAllIndexedTags = (sql: PgClient.PgClient, orgId: string) => sql.withTransaction( Effect.gen(function* () { - yield* keywordWriteLock(sql); + yield* keywordWriteLock(sql, orgId); yield* sql`UPDATE search_documents d SET tags = COALESCE(( SELECT string_agg(t.name, ' ' ORDER BY t.normalized_name) FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.file_id = d.file_id - ), '')`; + WHERE ft.file_id = d.file_id AND t.org_id = ${orgId} + ), '') + WHERE d.org_id = ${orgId}`; }) ); diff --git a/apps/web/src/lib/server/services/auth-guard.ts b/apps/web/src/lib/server/services/auth-guard.ts index 6457163..2305654 100644 --- a/apps/web/src/lib/server/services/auth-guard.ts +++ b/apps/web/src/lib/server/services/auth-guard.ts @@ -7,18 +7,7 @@ const CounterState = Schema.Struct({ resetAtMs: Schema.Int }); -const FailureState = Schema.Struct({ - failures: Schema.Int, - windowStartedAtMs: Schema.Int, - lockedUntilMs: Schema.NullOr(Schema.Int) -}); - const ratePolicies = { - passcodeLogin: { - key: 'passcode-login', - limit: 10, - windowSeconds: 5 * 60 - }, deviceCreate: { key: 'device-create', limit: 5, @@ -53,7 +42,7 @@ export interface AllowedAuthAttempt { export interface BlockedAuthAttempt { readonly allowed: false; - readonly reason: 'lockout' | 'rate-limit'; + readonly reason: 'rate-limit'; readonly retryAfterSeconds: number; readonly resetAtMs: number; } @@ -65,24 +54,12 @@ export interface AuthGuardShape { policy: AuthRatePolicy, clientId: string ) => Effect.Effect; - readonly checkPasscodeLock: ( - clientId: string - ) => Effect.Effect; - readonly recordPasscodeFailure: ( - clientId: string - ) => Effect.Effect; - readonly clearPasscodeFailures: ( - clientId: string - ) => Effect.Effect; } export class AuthGuard extends Context.Service()( 'app/AuthGuard' ) {} -const PASSCODE_FAILURE_LIMIT = 5; -const PASSCODE_FAILURE_WINDOW_SECONDS = 15 * 60; -const PASSCODE_LOCKOUT_SECONDS = 30 * 60; const MINIMUM_KV_TTL_SECONDS = 60; const retryAfter = (resetAtMs: number, nowMs: number) => @@ -95,14 +72,10 @@ const allowed = (remaining: number, resetAtMs: number) => resetAtMs }) satisfies AllowedAuthAttempt; -const blocked = ( - reason: BlockedAuthAttempt['reason'], - resetAtMs: number, - nowMs: number -) => +const blocked = (resetAtMs: number, nowMs: number) => ({ allowed: false, - reason, + reason: 'rate-limit', retryAfterSeconds: retryAfter(resetAtMs, nowMs), resetAtMs }) satisfies BlockedAuthAttempt; @@ -166,31 +139,6 @@ const makeAuthGuard = (now: () => Date) => catch: (cause) => new StorageError({ operation, cause }) }); - const writeOrBlock = ( - key: string, - value: unknown, - expirationTtl: number, - operation: string, - currentTime: number - ) => - write(key, value, expirationTtl, operation).pipe( - Effect.match({ - onFailure: () => - blocked( - 'rate-limit', - currentTime + MINIMUM_KV_TTL_SECONDS * 1_000, - currentTime - ), - onSuccess: () => null - }) - ); - - const remove = (key: string, operation: string) => - Effect.tryPromise({ - try: () => store.delete(key), - catch: (cause) => new StorageError({ operation, cause }) - }); - const clientKey = Effect.fn('AuthGuard.clientKey')(function* ( prefix: string, clientId: string @@ -219,94 +167,37 @@ const makeAuthGuard = (now: () => Date) => : current.count; if (count >= policy.limit) { - return blocked('rate-limit', resetAtMs, currentTime); + return blocked(resetAtMs, currentTime); } const nextCount = count + 1; + const persist = write( + key, + { count: nextCount, resetAtMs }, + retryAfter(resetAtMs, currentTime) + MINIMUM_KV_TTL_SECONDS, + `update ${policy.key} rate limit` + ); // High-frequency policies (uploads) hit KV's ~1 write/sec/key // ceiling under normal concurrent use; failing the counter // write must not fail the legitimate request there. Abuse - // bootstrap policies (login, device) stay fail-closed. + // bootstrap policies (device flow) stay fail-closed: a + // contended write turns into a short rate limit. if ('tolerateWriteFailure' in policy && policy.tolerateWriteFailure) { - yield* write( - key, - { count: nextCount, resetAtMs }, - retryAfter(resetAtMs, currentTime) + MINIMUM_KV_TTL_SECONDS, - `update ${policy.key} rate limit` - ).pipe(Effect.ignore); + yield* persist.pipe(Effect.ignore); return allowed(policy.limit - nextCount, resetAtMs); } - const writeFailure = yield* writeOrBlock( - key, - { count: nextCount, resetAtMs }, - retryAfter(resetAtMs, currentTime) + MINIMUM_KV_TTL_SECONDS, - `update ${policy.key} rate limit`, - currentTime + const outcome = yield* persist.pipe( + Effect.match({ + onFailure: () => + blocked( + currentTime + MINIMUM_KV_TTL_SECONDS * 1_000, + currentTime + ), + onSuccess: () => allowed(policy.limit - nextCount, resetAtMs) + }) ); - if (writeFailure) return writeFailure; - return allowed(policy.limit - nextCount, resetAtMs); - }), - checkPasscodeLock: Effect.fn('AuthGuard.checkPasscodeLock')( - function* (clientId) { - const key = yield* clientKey('passcode-failures', clientId); - const currentTime = now().getTime(); - const state = yield* read(key, FailureState, 'read passcode lockout'); - if (state?.lockedUntilMs && state.lockedUntilMs > currentTime) { - return blocked('lockout', state.lockedUntilMs, currentTime); - } - return allowed(PASSCODE_FAILURE_LIMIT, currentTime); - } - ), - recordPasscodeFailure: Effect.fn('AuthGuard.recordPasscodeFailure')( - function* (clientId) { - const key = yield* clientKey('passcode-failures', clientId); - const currentTime = now().getTime(); - const current = yield* read( - key, - FailureState, - 'read passcode failures' - ); - if (current?.lockedUntilMs && current.lockedUntilMs > currentTime) { - return blocked('lockout', current.lockedUntilMs, currentTime); - } - - const inWindow = - current !== null && - currentTime - current.windowStartedAtMs < - PASSCODE_FAILURE_WINDOW_SECONDS * 1_000; - const failures = inWindow ? current.failures + 1 : 1; - const windowStartedAtMs = inWindow - ? current.windowStartedAtMs - : currentTime; - const lockedUntilMs = - failures >= PASSCODE_FAILURE_LIMIT - ? currentTime + PASSCODE_LOCKOUT_SECONDS * 1_000 - : null; - const state = { failures, windowStartedAtMs, lockedUntilMs }; - const expiresAtMs = - lockedUntilMs ?? - windowStartedAtMs + PASSCODE_FAILURE_WINDOW_SECONDS * 1_000; - - const writeFailure = yield* writeOrBlock( - key, - state, - retryAfter(expiresAtMs, currentTime) + MINIMUM_KV_TTL_SECONDS, - 'update passcode failures', - currentTime - ); - if (writeFailure) return writeFailure; - - return lockedUntilMs === null - ? allowed(PASSCODE_FAILURE_LIMIT - failures, expiresAtMs) - : blocked('lockout', lockedUntilMs, currentTime); - } - ), - clearPasscodeFailures: Effect.fn('AuthGuard.clearPasscodeFailures')( - function* (clientId) { - const key = yield* clientKey('passcode-failures', clientId); - yield* remove(key, 'clear passcode failures'); - } - ) + return outcome; + }) }); }); diff --git a/apps/web/src/lib/server/services/auth.ts b/apps/web/src/lib/server/services/auth.ts index 5e2f446..9d9b2fb 100644 --- a/apps/web/src/lib/server/services/auth.ts +++ b/apps/web/src/lib/server/services/auth.ts @@ -1,27 +1,24 @@ import { API_KEY_PATTERN, type ApiKey, type ApiKeyScope } from '@adrive/shared'; -import type { Cookies } from '@sveltejs/kit'; import { Context, Effect, Layer, Schema } from 'effect'; import { DEVICE_CODE_TTL_SECONDS, DEVICE_POLL_INTERVAL_SECONDS, - allowsCredentialOrigin, - bearerToken, normalizeApiKeyName, normalizeUserCode, - SESSION_COOKIE, - SESSION_MAX_AGE_SECONDS, shouldTouchLastUsed, validateExpiration } from '../auth-policy'; -import { AppConfig } from '../config'; import { InvalidRequest, - MisdirectedRequest, StorageError, Unauthorized, validate } from '../errors'; +import type { AuthContext, ResolvedCredential } from '../identity'; import { PgSql } from '../pg'; +import { ensureTenant, personalOrgFor } from '../tenants'; +import { CurrentOrg, CurrentUser } from './current-org'; +import { WorkOSClient } from './workos'; const ApiKeyRow = Schema.Struct({ id: Schema.String, @@ -35,10 +32,27 @@ const ApiKeyRow = Schema.Struct({ revoked_at: Schema.NullOr(Schema.String) }); -const SessionRow = Schema.Struct({ - token_hash: Schema.String, - expires_at: Schema.String, - last_used_at: Schema.String +const MembershipRow = Schema.Struct({ + org_id: Schema.String, + user_id: Schema.String, + role: Schema.String, + email: Schema.String, + org_name: Schema.String, + org_slug: Schema.String +}); + +const ApiKeyCredentialRow = Schema.Struct({ + id: Schema.String, + scope: Schema.Literals(['read-only', 'read-write']), + secret_hash: Schema.String, + expires_at: Schema.NullOr(Schema.String), + last_used_at: Schema.NullOr(Schema.String), + org_id: Schema.String, + user_id: Schema.String, + role: Schema.NullOr(Schema.String), + email: Schema.NullOr(Schema.String), + org_name: Schema.NullOr(Schema.String), + org_slug: Schema.NullOr(Schema.String) }); const DeviceCodeRow = Schema.Struct({ @@ -51,14 +65,6 @@ const DeviceCodeRow = Schema.Struct({ name: Schema.String }); -export interface AuthorizeInput { - readonly authorization: string | null; - readonly sessionToken: string | undefined; - readonly requestOrigin: string; - readonly origin: string | null; - readonly method: string; -} - export interface DeviceAuthorization { readonly deviceCode: string; readonly userCode: string; @@ -70,24 +76,34 @@ export type DevicePollResult = | { readonly status: 'authorization_pending' | 'slow_down' } | { readonly status: 'complete'; readonly apiKey: string }; -export interface AuthorizedCredential { - readonly credentialId: string; - readonly kind: 'api-key' | 'session'; - readonly scope: ApiKeyScope; -} - export interface AuthShape { - readonly authorize: ( - input: AuthorizeInput + // Turns a presented credential into the identity the request acts as, + // or Unauthorized when it is unknown, expired, or revoked. The handle + // hook calls one of these once per request. + readonly resolveApiKey: ( + bearer: string + ) => Effect.Effect; + readonly resolveSession: ( + sessionCookie: string + ) => Effect.Effect; + // Exchanges the AuthKit callback code for a sealed session, bootstraps + // a personal org on first sign-in, and mirrors the user, org, and + // membership into Postgres. Returns the cookie value to set. + readonly completeSignIn: ( + code: string ) => Effect.Effect< - AuthorizedCredential, - MisdirectedRequest | Unauthorized | StorageError + { readonly sealedSession: string }, + Unauthorized | StorageError >; - readonly createSession: ( - passcode: string - ) => Effect.Effect; - readonly revokeSession: ( - sessionToken: string | undefined + readonly logoutUrl: ( + sessionCookie: string | undefined, + returnTo: string + ) => Effect.Effect; + // Webhook mirrors: WorkOS is the source of truth for accounts. + readonly removeUser: (userId: string) => Effect.Effect; + readonly removeMembership: ( + orgId: string, + userId: string ) => Effect.Effect; readonly listApiKeys: Effect.Effect, StorageError>; readonly createApiKey: ( @@ -119,15 +135,6 @@ export interface AuthShape { InvalidRequest | Unauthorized | StorageError >; readonly sweepExpired: (limit: number) => Effect.Effect; - // Revokes every browser session and outstanding device code. API keys - // survive; revoke those individually from the dashboard. - readonly revokeAllSessions: Effect.Effect; - // Compares the deployed PASSCODE with the recorded hash; on change, - // revokes all sessions and device codes and records the rotation time. - readonly enforcePasscodeRotation: Effect.Effect< - { readonly rotated: boolean; readonly revoked: number }, - StorageError - >; } export class Auth extends Context.Service()('app/Auth') {} @@ -144,41 +151,6 @@ const parseUserCode = (value: string) => }) }); -export const authorizeRequest = ( - auth: AuthShape, - request: Request, - url: URL, - cookies: Cookies -) => - auth.authorize({ - authorization: request.headers.get('authorization'), - sessionToken: cookies.get(SESSION_COOKIE), - requestOrigin: url.origin, - origin: request.headers.get('origin'), - method: request.method - }); - -// For routes that create, change, or delete data. Read-only API keys are -// authenticated but rejected here with a 403 rather than a 401. -export const authorizeWriteRequest = ( - auth: AuthShape, - request: Request, - url: URL, - cookies: Cookies -) => - authorizeRequest(auth, request, url, cookies).pipe( - Effect.flatMap((credential) => - credential.scope === 'read-write' - ? Effect.succeed(credential) - : Effect.fail( - new InvalidRequest({ - status: 403, - message: 'This API key is read-only' - }) - ) - ) - ); - const randomToken = (bytes = 32) => { const value = new Uint8Array(bytes); crypto.getRandomValues(value); @@ -248,13 +220,59 @@ const toApiKey = (row: typeof ApiKeyRow.Type): ApiKey => ({ revokedAt: row.revoked_at }); +const invalidCredential = () => + new Unauthorized({ message: 'A valid credential is required' }); + const makeAuth = Effect.gen(function* () { const sql = yield* PgSql; - const config = yield* AppConfig; + const workos = yield* WorkOSClient; + const org = yield* CurrentOrg; + const user = yield* CurrentUser; const storageError = (operation: string) => Effect.mapError((cause: unknown) => new StorageError({ operation, cause })); + const membershipSelect = sql.literal(` + SELECT m.org_id, m.user_id, m.role, u.email, + o.name AS org_name, o.slug AS org_slug + FROM memberships m + JOIN users u ON u.id = m.user_id + JOIN orgs o ON o.id = m.org_id + `); + + // The org a user acts in: the one on the session when WorkOS names it, + // otherwise their first membership (a session created before the org + // bootstrap, or a user WorkOS knows in one org without pinning it). + const findMembership = Effect.fn('Auth.findMembership')(function* ( + userId: string, + orgId: string | null + ) { + const rows = yield* sql` + ${membershipSelect} + WHERE ${sql.and([ + sql`m.user_id = ${userId}`, + ...(orgId === null ? [] : [sql`m.org_id = ${orgId}`]) + ])} + ORDER BY o.created_at, o.id + LIMIT 1`.pipe(storageError('find membership')); + return decodeRows(MembershipRow, rows)[0] ?? null; + }); + + const sessionContext = ( + sessionId: string, + membership: typeof MembershipRow.Type + ): AuthContext => ({ + orgId: membership.org_id, + userId: membership.user_id, + role: membership.role, + via: 'session', + scope: 'read-write', + credentialId: sessionId, + email: membership.email, + orgName: membership.org_name, + orgSlug: membership.org_slug + }); + const makeApiKey = Effect.fn('Auth.makeApiKey')(function* (name: string) { const normalizedName = yield* validate(() => normalizeApiKeyName(name)); const prefix = randomHex(4); @@ -274,175 +292,226 @@ const makeAuth = Effect.gen(function* () { }; }); - const listApiKeys = sql` - SELECT id, name, prefix, scope, secret_hash, created_at, expires_at, - last_used_at, revoked_at - FROM api_keys - ORDER BY created_at DESC, id - `.pipe( - Effect.map((rows) => decodeRows(ApiKeyRow, rows).map(toApiKey)), - Effect.mapError( - (cause) => new StorageError({ operation: 'list API keys', cause }) - ), - Effect.withSpan('Auth.listApiKeys') - ); + const listApiKeys = Effect.gen(function* () { + const rows = yield* sql` + SELECT id, name, prefix, scope, secret_hash, created_at, expires_at, + last_used_at, revoked_at + FROM api_keys + WHERE org_id = ${org.id} + ORDER BY created_at DESC, id + `.pipe(storageError('list API keys')); + return decodeRows(ApiKeyRow, rows).map(toApiKey); + }).pipe(Effect.withSpan('Auth.listApiKeys')); return Auth.of({ - authorize: Effect.fn('Auth.authorize')(function* ({ - authorization, - sessionToken, - requestOrigin, - origin, - method - }) { - if (requestOrigin !== config.dashboardOrigin) { - return yield* new MisdirectedRequest({ - message: 'Credentials are accepted only on the dashboard origin' - }); - } - - const bearer = bearerToken(authorization); - if (bearer) { - const match = API_KEY_PATTERN.exec(bearer); - if (!match) { - return yield* new Unauthorized({ - message: 'A valid credential is required' - }); - } - const rows = yield* sql` - SELECT id, name, prefix, scope, secret_hash, created_at, expires_at, - last_used_at, revoked_at - FROM api_keys - WHERE prefix = ${match[1]} AND revoked_at IS NULL - LIMIT 1 - `.pipe( - Effect.mapError( - (cause) => new StorageError({ operation: 'look up API key', cause }) - ) - ); - const row = decodeRows(ApiKeyRow, rows)[0]; - const actualHash = yield* hashToken(bearer); - if ( - !row || - !constantTimeEqual(hexBytes(actualHash), hexBytes(row.secret_hash)) - ) { - return yield* new Unauthorized({ - message: 'A valid credential is required' - }); - } - const now = new Date(); - const nowIso = now.toISOString(); - if (row.expires_at !== null && row.expires_at <= nowIso) { - return yield* new Unauthorized({ - message: 'This API key has expired' - }); - } - if (shouldTouchLastUsed(row.last_used_at, now)) { - yield* sql` - UPDATE api_keys - SET last_used_at = ${nowIso} - WHERE id = ${row.id} - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'update API key usage', cause }) - ) - ); - } - return { - credentialId: row.id, - kind: 'api-key' as const, - scope: row.scope - }; - } - - if (!sessionToken) { - return yield* new Unauthorized({ - message: 'A valid credential is required' - }); + // The prefix lookup is global (prefixes are unique across orgs); the + // org and user the key acts as come from the key row itself. + resolveApiKey: Effect.fn('Auth.resolveApiKey')(function* (bearer) { + const match = API_KEY_PATTERN.exec(bearer); + if (!match) return yield* invalidCredential(); + const rows = yield* sql` + SELECT k.id, k.scope, k.secret_hash, k.expires_at, k.last_used_at, + k.org_id, k.user_id, m.role, u.email, + o.name AS org_name, o.slug AS org_slug + FROM api_keys k + LEFT JOIN memberships m ON m.org_id = k.org_id AND m.user_id = k.user_id + LEFT JOIN users u ON u.id = k.user_id + LEFT JOIN orgs o ON o.id = k.org_id + WHERE k.prefix = ${match[1]} AND k.revoked_at IS NULL + LIMIT 1 + `.pipe(storageError('look up API key')); + const row = decodeRows(ApiKeyCredentialRow, rows)[0]; + const actualHash = yield* hashToken(bearer); + if ( + !row || + !constantTimeEqual(hexBytes(actualHash), hexBytes(row.secret_hash)) + ) { + return yield* invalidCredential(); } - if (!allowsCredentialOrigin(method, origin, config.dashboardOrigin)) { + const now = new Date(); + const nowIso = now.toISOString(); + if (row.expires_at !== null && row.expires_at <= nowIso) { return yield* new Unauthorized({ - message: 'The request origin is not allowed' + message: 'This API key has expired' }); } - const tokenHash = yield* hashToken(sessionToken); - const now = new Date(); - const nowIso = now.toISOString(); - const rows = yield* sql` - SELECT token_hash, expires_at, last_used_at - FROM dashboard_sessions - WHERE token_hash = ${tokenHash} AND expires_at > ${nowIso} - LIMIT 1 - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'look up dashboard session', cause }) - ) - ); - const row = decodeRows(SessionRow, rows)[0]; - if (!row) { + // A key whose owner left the org stops working with it. + if ( + row.role === null || + row.email === null || + row.org_name === null || + row.org_slug === null + ) { return yield* new Unauthorized({ - message: 'A valid credential is required' + message: 'This API key no longer belongs to an organization member' }); } if (shouldTouchLastUsed(row.last_used_at, now)) { yield* sql` - UPDATE dashboard_sessions SET last_used_at = ${nowIso} - WHERE token_hash = ${tokenHash} - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ - operation: 'update dashboard session', - cause - }) - ) - ); + UPDATE api_keys + SET last_used_at = ${nowIso} + WHERE id = ${row.id} + `.pipe(storageError('update API key usage')); } return { - credentialId: row.token_hash, - kind: 'session' as const, - scope: 'read-write' as const + orgId: row.org_id, + userId: row.user_id, + role: row.role, + via: 'api-key' as const, + scope: row.scope, + credentialId: row.id, + email: row.email, + orgName: row.org_name, + orgSlug: row.org_slug }; }), - createSession: Effect.fn('Auth.createSession')(function* (passcode) { - const expected = yield* hashToken(config.passcode); - const actual = yield* hashToken(passcode); - if (!constantTimeEqual(hexBytes(expected), hexBytes(actual))) { - return yield* new Unauthorized({ message: 'Passcode is incorrect' }); + // The access token inside the sealed cookie is verified locally + // against WorkOS's JWKS; only an expired token costs an API call + // (the refresh), whose new cookie travels back to the hook. + resolveSession: Effect.fn('Auth.resolveSession')(function* (sessionCookie) { + let loaded = yield* workos.loadSession(sessionCookie); + let refreshedSession: string | null = null; + if (!loaded.authenticated && loaded.refreshable) { + refreshedSession = yield* workos.refresh(sessionCookie); + if (refreshedSession === null) return yield* invalidCredential(); + loaded = yield* workos.loadSession(refreshedSession); } - const token = randomToken(); - const tokenHash = yield* hashToken(token); - const createdAt = new Date(); - const expiresAt = new Date( - createdAt.getTime() + SESSION_MAX_AGE_SECONDS * 1000 - ); - yield* sql` - INSERT INTO dashboard_sessions ( - token_hash, created_at, expires_at, last_used_at - ) VALUES ( - ${tokenHash}, ${createdAt.toISOString()}, ${expiresAt.toISOString()}, - ${createdAt.toISOString()} - ) - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'create dashboard session', cause }) + if (!loaded.authenticated) return yield* invalidCredential(); + const membership = yield* findMembership(loaded.userId, loaded.orgId); + // A user WorkOS still knows but Postgres does not (deleted through + // the webhook, or a session that predates the org bootstrap) has + // to go through the callback again. + if (!membership) return yield* invalidCredential(); + // Organization-bearing sessions carry the verified provider role. + // Keep the mirror current so API keys follow the same permissions. + const role = + loaded.orgId === null ? membership.role : (loaded.role ?? 'member'); + if (role !== membership.role) { + yield* sql`UPDATE memberships SET role = ${role} + WHERE org_id = ${membership.org_id} AND user_id = ${membership.user_id}`.pipe( + storageError('update organization role') + ); + } + return { + auth: sessionContext(loaded.sessionId, { ...membership, role }), + refreshedSession + }; + }), + completeSignIn: Effect.fn('Auth.completeSignIn')(function* (code) { + const exchanged = yield* workos.exchangeCode(code); + const orgId = yield* sql + .withTransaction( + Effect.gen(function* () { + // Serialize first callbacks for one user before checking membership. + // The separate statement gives a waiter a fresh snapshot after the + // previous bootstrap commits. Namespace is ASCII "sign". + yield* sql`SELECT pg_advisory_xact_lock(1936287598, hashtext(${exchanged.user.id}))`; + const existing = yield* findMembership( + exchanged.user.id, + exchanged.organizationId + ); + const role = + exchanged.organizationId === null + ? (existing?.role ?? 'owner') + : (exchanged.role ?? 'member'); + if (existing) { + if (role !== existing.role) { + yield* sql`UPDATE memberships SET role = ${role} + WHERE org_id = ${existing.org_id} AND user_id = ${existing.user_id}`.pipe( + storageError('update signed-in organization role') + ); + } + yield* sql` + UPDATE users + SET email = ${exchanged.user.email}, + email_verified = ${exchanged.user.emailVerified} + WHERE id = ${exchanged.user.id} + `.pipe(storageError('update signed-in user')); + } + const orgId = + existing?.org_id ?? + exchanged.organizationId ?? + (yield* Effect.gen(function* () { + // First sign-in: WorkOS does not create a personal org, so + // mint one there first, then mirror it. The membership is + // created WorkOS-side so the session can be pinned to it. + const personal = personalOrgFor(exchanged.user.email); + const created = yield* workos.createOrganization(personal.name); + yield* workos.createOrganizationMembership({ + organizationId: created.id, + userId: exchanged.user.id, + roleSlug: 'owner' + }); + return created.id; + })); + if (!existing) { + const personal = personalOrgFor(exchanged.user.email); + yield* ensureTenant(sql, { + orgId, + userId: exchanged.user.id, + slug: personal.slug, + name: personal.name, + email: exchanged.user.email, + emailVerified: exchanged.user.emailVerified, + role + }).pipe(storageError('create tenant rows')); + } + return orgId; + }) ) - ); - return token; + .pipe( + Effect.catchTag('SqlError', (cause) => + Effect.fail( + new StorageError({ operation: 'complete sign-in', cause }) + ) + ) + ); + // Pin the org on the session so every later request carries it. + const pinned = + exchanged.organizationId === orgId + ? exchanged.sealedSession + : yield* workos.refresh(exchanged.sealedSession, orgId); + return { sealedSession: pinned ?? exchanged.sealedSession }; + }), + logoutUrl: Effect.fn('Auth.logoutUrl')(function* (sessionCookie, returnTo) { + if (!sessionCookie) return returnTo; + const loaded = yield* workos.loadSession(sessionCookie); + if (!loaded.authenticated) return returnTo; + return yield* workos.logoutUrl(loaded.sessionId, returnTo); }), - revokeSession: Effect.fn('Auth.revokeSession')(function* (sessionToken) { - if (!sessionToken) return; - const hash = yield* hashToken(sessionToken); - yield* sql`DELETE FROM dashboard_sessions WHERE token_hash = ${hash}`.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'revoke dashboard session', cause }) + removeUser: Effect.fn('Auth.removeUser')(function* (userId) { + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql` + UPDATE device_codes + SET status = 'denied', api_key_id = NULL, user_id = NULL + WHERE user_id = ${userId} + OR api_key_id IN (SELECT id FROM api_keys WHERE user_id = ${userId})`; + yield* sql`DELETE FROM api_keys WHERE user_id = ${userId}`; + yield* sql`DELETE FROM memberships WHERE user_id = ${userId}`; + yield* sql`DELETE FROM users WHERE id = ${userId}`; + }) ) - ); + .pipe(storageError('remove user')); }), + removeMembership: Effect.fn('Auth.removeMembership')( + function* (orgId, userId) { + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql` + UPDATE api_keys SET revoked_at = ${new Date().toISOString()} + WHERE org_id = ${orgId} AND user_id = ${userId} + AND revoked_at IS NULL`; + yield* sql` + DELETE FROM memberships + WHERE org_id = ${orgId} AND user_id = ${userId}`; + }) + ) + .pipe(storageError('remove membership')); + } + ), listApiKeys, createApiKey: Effect.fn('Auth.createApiKey')(function* (name, options) { const scope = options?.scope ?? 'read-write'; @@ -459,17 +528,14 @@ const makeAuth = Effect.gen(function* () { const generated = yield* makeApiKey(name); yield* sql` INSERT INTO api_keys ( - id, name, prefix, scope, secret_hash, created_at, expires_at + id, name, prefix, scope, secret_hash, created_at, expires_at, + org_id, user_id ) VALUES ( ${generated.row.id}, ${generated.row.name}, ${generated.row.prefix}, ${scope}, ${generated.secretHash}, ${generated.row.createdAt}, - ${expiresAt} + ${expiresAt}, ${org.id}, ${user.id} ) - `.pipe( - Effect.mapError( - (cause) => new StorageError({ operation: 'create API key', cause }) - ) - ); + `.pipe(storageError('create API key')); return { key: { ...generated.row, @@ -485,7 +551,7 @@ const makeAuth = Effect.gen(function* () { const rows = yield* sql` UPDATE api_keys SET revoked_at = ${new Date().toISOString()} - WHERE id = ${id} AND revoked_at IS NULL + WHERE id = ${id} AND org_id = ${org.id} AND revoked_at IS NULL RETURNING id `.pipe(storageError('revoke API key')); if (rows.length !== 1) { @@ -537,9 +603,12 @@ const makeAuth = Effect.gen(function* () { approveDevice: Effect.fn('Auth.approveDevice')(function* (userCode) { const code = yield* parseUserCode(userCode); const now = new Date().toISOString(); + // Approval binds the code to the approving user's org; the key + // minted when the CLI next polls copies both columns. const rows = yield* sql` UPDATE device_codes - SET status = 'approved', approved_at = ${now} + SET status = 'approved', approved_at = ${now}, + org_id = ${org.id}, user_id = ${user.id} WHERE user_code = ${code} AND status = 'pending' AND expires_at > ${now} RETURNING user_code `.pipe(storageError('approve device')); @@ -580,12 +649,7 @@ const makeAuth = Effect.gen(function* () { FROM device_codes WHERE device_code_hash = ${hash} LIMIT 1 - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'poll device authorization', cause }) - ) - ); + `.pipe(storageError('poll device authorization')); const row = decodeRows(DeviceCodeRow, rows)[0]; if (!row) { return yield* new Unauthorized({ @@ -597,15 +661,7 @@ const makeAuth = Effect.gen(function* () { yield* sql` UPDATE device_codes SET status = 'expired' WHERE device_code_hash = ${hash} AND status IN ('pending', 'approved') - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ - operation: 'expire device authorization', - cause - }) - ) - ); + `.pipe(storageError('expire device authorization')); return yield* new Unauthorized({ message: 'Device authorization expired' }); @@ -622,12 +678,7 @@ const makeAuth = Effect.gen(function* () { yield* sql` UPDATE device_codes SET last_polled_at = ${now.toISOString()} WHERE device_code_hash = ${hash} - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'record device poll', cause }) - ) - ); + `.pipe(storageError('record device poll')); if (row.status === 'pending') { return { status: 'authorization_pending' as const }; } @@ -641,6 +692,7 @@ const makeAuth = Effect.gen(function* () { const consumedAt = now.toISOString(); // Lock the approval before inserting a key. A concurrent poll must // observe the consumed state before it can mint another credential. + // The key inherits the org and user stamped at approval. const completed = yield* sql .withTransaction( Effect.gen(function* () { @@ -650,14 +702,19 @@ const makeAuth = Effect.gen(function* () { FOR UPDATE `; if (approved.length !== 1) return false; - yield* sql` + const inserted = yield* sql` INSERT INTO api_keys ( - id, name, prefix, secret_hash, created_at + id, name, prefix, secret_hash, created_at, org_id, user_id ) - VALUES (${generated.row.id}, ${generated.row.name}, + SELECT ${generated.row.id}, ${generated.row.name}, ${generated.row.prefix}, ${generated.secretHash}, - ${generated.row.createdAt}) + ${generated.row.createdAt}, org_id, user_id + FROM device_codes + WHERE device_code_hash = ${hash} AND status = 'approved' + AND org_id IS NOT NULL AND user_id IS NOT NULL + RETURNING id `; + if (inserted.length !== 1) return false; const consumed = yield* sql` UPDATE device_codes SET status = 'consumed', consumed_at = ${consumedAt}, @@ -676,106 +733,24 @@ const makeAuth = Effect.gen(function* () { } return { status: 'complete' as const, apiKey: generated.token }; }), - revokeAllSessions: sql - .withTransaction( - Effect.gen(function* () { - const sessions = yield* sql` - DELETE FROM dashboard_sessions RETURNING token_hash - `; - const codes = yield* sql` - UPDATE device_codes SET status = 'denied' - WHERE status IN ('pending', 'approved') - RETURNING device_code_hash - `; - return sessions.length + codes.length; - }) - ) - .pipe( - storageError('revoke all sessions'), - Effect.withSpan('Auth.revokeAllSessions') - ), - enforcePasscodeRotation: Effect.gen(function* () { - const passcodeHash = yield* hashToken(config.passcode); - const now = new Date().toISOString(); - // One transaction: seed the row on first boot without revoking - // anything, revoke while the stored hash still differs from the - // deployed one, then record the new hash. A failure anywhere rolls - // the whole claim back, so a rotation can never be marked recorded - // with revocation skipped. - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* sql` - INSERT INTO credential_state (id, passcode_hash, rotated_at) - VALUES (1, ${passcodeHash}, ${now}) - ON CONFLICT (id) DO NOTHING - `; - const sessions = yield* sql` - DELETE FROM dashboard_sessions - WHERE EXISTS ( - SELECT 1 FROM credential_state - WHERE id = 1 AND passcode_hash <> ${passcodeHash} - ) - RETURNING token_hash - `; - const codes = yield* sql` - UPDATE device_codes SET status = 'denied' - WHERE status IN ('pending', 'approved') - AND EXISTS ( - SELECT 1 FROM credential_state - WHERE id = 1 AND passcode_hash <> ${passcodeHash} - ) - RETURNING device_code_hash - `; - const recorded = yield* sql` - UPDATE credential_state - SET passcode_hash = ${passcodeHash}, rotated_at = ${now} - WHERE id = 1 AND passcode_hash <> ${passcodeHash} - RETURNING id - `; - const rotated = recorded.length === 1; - return { - rotated, - revoked: rotated ? sessions.length + codes.length : 0 - }; - }) - ) - .pipe(storageError('enforce passcode rotation')); - }).pipe(Effect.withSpan('Auth.enforcePasscodeRotation')), sweepExpired: Effect.fn('Auth.sweepExpired')(function* (limit) { const bounded = Math.max(1, Math.min(limit, 100)); const now = new Date().toISOString(); const cutoff = new Date( new Date(now).getTime() - 24 * 60 * 60 * 1_000 ).toISOString(); - return yield* sql - .withTransaction( - Effect.gen(function* () { - const sessions = yield* sql` - DELETE FROM dashboard_sessions - WHERE token_hash IN ( - SELECT token_hash FROM dashboard_sessions - WHERE expires_at <= ${now} - ORDER BY expires_at - LIMIT ${bounded} - ) - RETURNING token_hash - `; - const codes = yield* sql` - DELETE FROM device_codes - WHERE device_code_hash IN ( - SELECT device_code_hash FROM device_codes - WHERE expires_at <= ${now} - OR (status = 'consumed' AND consumed_at <= ${cutoff}) - ORDER BY expires_at - LIMIT ${bounded} - ) - RETURNING device_code_hash - `; - return sessions.length + codes.length; - }) + const codes = yield* sql` + DELETE FROM device_codes + WHERE device_code_hash IN ( + SELECT device_code_hash FROM device_codes + WHERE expires_at <= ${now} + OR (status = 'consumed' AND consumed_at <= ${cutoff}) + ORDER BY expires_at + LIMIT ${bounded} ) - .pipe(storageError('sweep expired authentication state')); + RETURNING device_code_hash + `.pipe(storageError('sweep expired device codes')); + return codes.length; }) }); }); diff --git a/apps/web/src/lib/server/services/current-org.ts b/apps/web/src/lib/server/services/current-org.ts new file mode 100644 index 0000000..7540a16 --- /dev/null +++ b/apps/web/src/lib/server/services/current-org.ts @@ -0,0 +1,32 @@ +import { Context } from 'effect'; + +// 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. +export class CurrentOrg extends Context.Service< + CurrentOrg, + { readonly id: string } +>()('app/CurrentOrg') {} + +export class CurrentUser extends Context.Service< + CurrentUser, + { readonly id: string } +>()('app/CurrentUser') {} + +const missing = (what: string) => ({ + get id(): string { + throw new Error( + `No ${what} on this program: tenant-scoped work needs a signed-in caller or an explicit org` + ); + } +}); + +// Stand-ins for programs that have no tenant: content routes, the queue +// consumer, cross-org sweeps before they pick an org. They keep every +// service constructible; reading the id is a bug and surfaces as a defect +// instead of silently matching no rows. +export const anonymousOrg: CurrentOrg['Service'] = missing('org'); +export const anonymousUser: CurrentUser['Service'] = missing('user'); + +export const tenantOrgId = (org: CurrentOrg['Service']) => + org === anonymousOrg ? null : org.id; diff --git a/apps/web/src/lib/server/services/files.ts b/apps/web/src/lib/server/services/files.ts index fecefd9..bd0d75d 100644 --- a/apps/web/src/lib/server/services/files.ts +++ b/apps/web/src/lib/server/services/files.ts @@ -3,6 +3,7 @@ import { AppConfig } from '../config'; import { PgSql } from '../pg'; import { Blobs } from './blobs'; import { Tags } from './tags'; +import { CurrentOrg } from './current-org'; import { createInternals } from './files/internals'; import { mutationOps } from './files/mutations'; import { purgeOps } from './files/purge'; @@ -27,7 +28,8 @@ const makeFiles = Effect.gen(function* () { const blobs = yield* Blobs; const config = yield* AppConfig; const tags = yield* Tags; - const internals = createInternals({ sql, blobs, config, tags }); + const org = yield* CurrentOrg; + const internals = createInternals({ sql, blobs, config, tags, org }); return Files.of({ ...uploadOps(internals), diff --git a/apps/web/src/lib/server/services/files/internals.ts b/apps/web/src/lib/server/services/files/internals.ts index e7e2c3d..a6cbbec 100644 --- a/apps/web/src/lib/server/services/files/internals.ts +++ b/apps/web/src/lib/server/services/files/internals.ts @@ -13,10 +13,11 @@ import { } from '../../file-rows'; import { visibilityForFile } from '../../file-policy'; import { refreshSearchDocument } from '../../search-index'; -import { ensureStoredBytesWithin } from '../../storage-quota'; +import { ensureStorageHeadroom, reserveWithinPlan } from '../../storage-quota'; import type { AppConfig } from '../../config'; import type { Blobs } from '../blobs'; import type { Tags } from '../tags'; +import type { CurrentOrg } from '../current-org'; import type { MutationResult } from './types'; export interface CoreDeps { @@ -24,10 +25,11 @@ export interface CoreDeps { readonly blobs: Blobs['Service']; readonly config: AppConfig['Service']; readonly tags: Tags['Service']; + readonly org: CurrentOrg['Service']; } export const createInternals = (deps: CoreDeps) => { - const { sql, blobs, config, tags } = deps; + const { sql, blobs, config, tags, org } = deps; const compensateStoredBlob = ( failure: OriginalError, fileId: string, @@ -64,10 +66,12 @@ export const createInternals = (deps: CoreDeps) => { } ); - // One aggregate query per upload; at personal scale this stays cheap - // and cannot drift the way a maintained counter can. - const checkStorageQuota = (incomingBytes: number) => - ensureStoredBytesWithin(sql, config.maxTotalBytes, incomingBytes); + // Cheap read before a body streams; the reservation inside the commit + // transaction is what actually holds the bytes. + const ensureHeadroom = (incomingBytes: number) => + ensureStorageHeadroom(sql, org.id, incomingBytes); + const reserveBytes = (orgId: string, delta: number) => + reserveWithinPlan(sql, orgId, delta); const findDashboardFile = Effect.fn('Files.findDashboardFile')(function* ( id: string @@ -75,7 +79,7 @@ export const createInternals = (deps: CoreDeps) => { const rows = yield* sql` SELECT ${sql.literal(dashboardFileColumns)} FROM files f - WHERE f.id = ${id} + WHERE f.id = ${id} AND f.org_id = ${org.id} LIMIT 1`.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'find dashboard file', cause }) @@ -111,7 +115,8 @@ export const createInternals = (deps: CoreDeps) => { updated_at = ${updatedAt}, index_state = 'pending', index_cursor = 0, index_attempts = 0, index_error = NULL, index_next_run_at = NULL, index_lease_token = NULL - WHERE id = ${current.id} AND current_version = ${current.version} + WHERE id = ${current.id} AND org_id = ${org.id} + AND current_version = ${current.version} AND deleted_at IS NULL RETURNING id`; if (updated.length !== 1) { @@ -121,14 +126,15 @@ export const createInternals = (deps: CoreDeps) => { }); } yield* sql` - INSERT INTO file_versions ( - file_id, version, r2_key, size_bytes, sha256, content_type, - created_at, text_content - ) VALUES ( - ${current.id}, ${version}, ${r2Key}, ${size}, NULL, ${contentType}, - ${updatedAt}, NULL - )`; - yield* refreshSearchDocument(sql, current.id); + INSERT INTO file_versions ( + file_id, org_id, version, r2_key, size_bytes, sha256, + content_type, created_at, text_content + ) VALUES ( + ${current.id}, ${org.id}, ${version}, ${r2Key}, ${size}, NULL, + ${contentType}, ${updatedAt}, NULL + )`; + yield* refreshSearchDocument(sql, current.id, org.id); + yield* reserveBytes(org.id, size); }) ) .pipe( @@ -161,8 +167,10 @@ export const createInternals = (deps: CoreDeps) => { blobs, config, tags, + org, compensateStoredBlob, - checkStorageQuota, + ensureHeadroom, + reserveBytes, findDashboardFile, commitStoredVersion }; diff --git a/apps/web/src/lib/server/services/files/mutations.ts b/apps/web/src/lib/server/services/files/mutations.ts index 467944b..bb4f697 100644 --- a/apps/web/src/lib/server/services/files/mutations.ts +++ b/apps/web/src/lib/server/services/files/mutations.ts @@ -6,6 +6,7 @@ import { visibilityForFile } from '../../file-policy'; import { refreshSearchDocument } from '../../search-index'; +import { tenantOrgId } from '../current-org'; import type { FileInternals } from './internals'; import type { FilesShape } from './types'; @@ -24,7 +25,7 @@ export const mutationOps = ( | 'scheduleAllPurgesNow' | 'recordDownload' > => { - const { sql } = internals; + const { sql, org } = internals; const { findDashboardFile } = internals; return { setVisibility: Effect.fn('Files.setVisibility')(function* (id, isPublic) { @@ -44,7 +45,7 @@ export const mutationOps = ( yield* sql` UPDATE files SET public = ${visibility.public}, updated_at = ${updatedAt} - WHERE id = ${id} + WHERE id = ${id} AND org_id = ${org.id} `.pipe( Effect.mapError( (cause) => @@ -67,7 +68,7 @@ export const mutationOps = ( UPDATE files SET deleted_at = ${deletedAt}, purge_at = ${purgeAt}, purge_state = 'none', purge_error = NULL, purge_next_run_at = NULL, updated_at = ${deletedAt} - WHERE id = ${id} AND purge_state <> 'pending' + WHERE id = ${id} AND org_id = ${org.id} AND purge_state <> 'pending' RETURNING id `.pipe( Effect.mapError( @@ -90,7 +91,7 @@ export const mutationOps = ( SET deleted_at = NULL, purge_at = NULL, purge_state = 'none', purge_error = NULL, purge_next_run_at = NULL, updated_at = ${updatedAt} - WHERE id = ${id} AND purge_state <> 'pending' + WHERE id = ${id} AND org_id = ${org.id} AND purge_state <> 'pending' RETURNING id `.pipe( Effect.mapError( @@ -114,7 +115,7 @@ export const mutationOps = ( yield* sql` UPDATE files SET expires_at = ${expiresAt}, updated_at = ${updatedAt} - WHERE id = ${id} + WHERE id = ${id} AND org_id = ${org.id} `.pipe( Effect.mapError( (cause) => @@ -152,8 +153,8 @@ export const mutationOps = ( updated_at = ${updatedAt}, index_state = 'pending', index_cursor = 0, index_attempts = 0, index_error = NULL, index_next_run_at = NULL, index_lease_token = NULL - WHERE id = ${id} - `.pipe(Effect.andThen(refreshSearchDocument(sql, id))) + WHERE id = ${id} AND org_id = ${org.id} + `.pipe(Effect.andThen(refreshSearchDocument(sql, id, org.id))) ) .pipe( Effect.mapError( @@ -187,7 +188,7 @@ export const mutationOps = ( UPDATE files SET purge_at = ${EPOCH}, purge_state = 'none', purge_error = NULL, purge_next_run_at = NULL - WHERE id = ${id} AND deleted_at IS NOT NULL + WHERE id = ${id} AND org_id = ${org.id} AND deleted_at IS NOT NULL AND purge_state <> 'pending' RETURNING id `.pipe( @@ -204,13 +205,18 @@ export const mutationOps = ( } return { file: current, forcedPublic: false }; }), - scheduleAllPurgesNow: sql<{ id: string }>` - UPDATE files - SET purge_at = ${EPOCH}, purge_state = 'none', purge_error = NULL, - purge_next_run_at = NULL - WHERE deleted_at IS NOT NULL AND purge_state <> 'pending' - RETURNING id - `.pipe( + // Suspended so the org is read when the effect runs, not when the + // layer is built (content routes build the layer with no tenant). + scheduleAllPurgesNow: Effect.suspend( + () => sql<{ id: string }>` + UPDATE files + SET purge_at = ${EPOCH}, purge_state = 'none', purge_error = NULL, + purge_next_run_at = NULL + WHERE org_id = ${org.id} AND deleted_at IS NOT NULL + AND purge_state <> 'pending' + RETURNING id + ` + ).pipe( Effect.map((rows) => rows.length), Effect.mapError( (cause) => @@ -220,10 +226,12 @@ export const mutationOps = ( ), recordDownload: Effect.fn('Files.recordDownload')(function* (id) { const now = new Date().toISOString(); + // Content routes have no tenant; the file id alone identifies it. + const orgId = tenantOrgId(org); yield* sql` UPDATE files SET download_count = download_count + 1, last_download_at = ${now} - WHERE id = ${id} + WHERE id = ${id} AND (${orgId}::text IS NULL OR org_id = ${orgId}) `.pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/lib/server/services/files/purge.ts b/apps/web/src/lib/server/services/files/purge.ts index 32c9ae7..385fac6 100644 --- a/apps/web/src/lib/server/services/files/purge.ts +++ b/apps/web/src/lib/server/services/files/purge.ts @@ -10,7 +10,7 @@ import type { FilesShape } from './types'; export const purgeOps = ( internals: FileInternals ): Pick => { - const { sql, blobs } = internals; + const { sql, blobs, org } = internals; return { sweepPurges: Effect.fn('Files.sweepPurges')(function* (limit) { const bounded = Math.max(1, Math.min(limit, 10)); @@ -18,7 +18,7 @@ export const purgeOps = ( const due = yield* sql<{ id: string }>` SELECT id FROM files - WHERE ( + WHERE org_id = ${org.id} AND (( ( (expires_at IS NOT NULL AND expires_at <= ${now}) OR (deleted_at IS NOT NULL AND purge_at IS NOT NULL AND purge_at <= ${now}) @@ -28,7 +28,7 @@ export const purgeOps = ( ) OR ( purge_state = 'pending' AND (purge_next_run_at IS NULL OR purge_next_run_at <= ${now}) - ) + )) ORDER BY COALESCE(purge_at, expires_at), id LIMIT ${bounded} `.pipe( @@ -47,7 +47,7 @@ export const purgeOps = ( purge_at = COALESCE(purge_at, ${now}), purge_attempts = purge_attempts + 1, purge_error = NULL, purge_next_run_at = ${leaseUntil} - WHERE id = ${row.id} AND ( + WHERE id = ${row.id} AND org_id = ${org.id} AND ( ( purge_state IN ('none', 'failed') AND ( @@ -124,8 +124,8 @@ export const purgeOps = ( ); if (!deleted) continue; - yield* completePurge(sql, row.id); - forgetTagListCache(); + yield* completePurge(sql, org.id, row.id); + forgetTagListCache(org.id); } return due.length; }) diff --git a/apps/web/src/lib/server/services/files/queries.ts b/apps/web/src/lib/server/services/files/queries.ts index d912311..211df2d 100644 --- a/apps/web/src/lib/server/services/files/queries.ts +++ b/apps/web/src/lib/server/services/files/queries.ts @@ -10,6 +10,7 @@ import { encodeListCursor, type ListCursor } from '../../list-cursor'; +import { tenantOrgId } from '../current-org'; import type { FileInternals } from './internals'; import type { FilesShape } from './types'; import { decodeContentRows, decodeVersionRows, toVersion } from './types'; @@ -17,7 +18,7 @@ import { decodeContentRows, decodeVersionRows, toVersion } from './types'; export const queryOps = ( internals: FileInternals ): Pick => { - const { sql, findDashboardFile } = internals; + const { sql, org, findDashboardFile } = internals; return { list: Effect.fn('Files.list')(function* (trashed, page) { const now = new Date().toISOString(); @@ -36,6 +37,7 @@ export const queryOps = ( SELECT ${sql.literal(dashboardFileColumns)} FROM files f WHERE ${sql.and([ + sql`f.org_id = ${org.id}`, trashed ? sql`f.deleted_at IS NOT NULL AND (f.purge_at IS NULL OR f.purge_at > ${now})` : sql`f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${now})`, @@ -90,6 +92,7 @@ export const queryOps = ( FROM file_versions WHERE ${sql.and([ sql`file_id = ${id}`, + sql`org_id = ${org.id}`, ...(beforeVersion !== null ? [sql`version < ${beforeVersion}`] : []) ])} ORDER BY version DESC @@ -130,6 +133,11 @@ export const queryOps = ( }); } const now = new Date().toISOString(); + // Content routes run without a tenant and resolve the org from the + // file row (ids are globally unique); dashboard callers are pinned + // to their own org so a foreign id is a plain 404. + const orgId = tenantOrgId(org); + const orgFilter = sql`(${orgId}::text IS NULL OR f.org_id = ${orgId})`; const available = sql`( ${includeUnavailable}::boolean OR ( @@ -142,13 +150,14 @@ export const queryOps = ( version === undefined ? yield* sql` SELECT - f.id, f.display_name, v.content_type, v.version, v.size_bytes, - f.public AS is_public, f.is_site, v.r2_key, v.thumbnail_r2_key, - v.created_at + f.id, f.org_id, f.display_name, v.content_type, v.version, + v.size_bytes, f.public AS is_public, f.is_site, v.r2_key, + v.thumbnail_r2_key, v.created_at FROM files f JOIN file_versions v ON v.file_id = f.id AND v.version = f.current_version - WHERE f.id = ${id} AND ${available} AND ${siteFilter} + WHERE f.id = ${id} AND ${orgFilter} AND ${available} + AND ${siteFilter} LIMIT 1 `.pipe( Effect.mapError( @@ -157,12 +166,12 @@ export const queryOps = ( ) : yield* sql` SELECT - f.id, f.display_name, v.content_type, v.version, v.size_bytes, - f.public AS is_public, f.is_site, v.r2_key, v.thumbnail_r2_key, - v.created_at + f.id, f.org_id, f.display_name, v.content_type, v.version, + v.size_bytes, f.public AS is_public, f.is_site, v.r2_key, + v.thumbnail_r2_key, v.created_at FROM files f JOIN file_versions v ON v.file_id = f.id - WHERE f.id = ${id} AND v.version = ${version} + WHERE f.id = ${id} AND v.version = ${version} AND ${orgFilter} AND ${available} AND ${siteFilter} AND (f.is_site = false OR v.version = f.current_version) LIMIT 1 @@ -192,6 +201,7 @@ export const queryOps = ( indexAttempts: 0, indexError: null }, + orgId: row.org_id, r2Key: row.r2_key, thumbnailR2Key: row.thumbnail_r2_key }; diff --git a/apps/web/src/lib/server/services/files/thumbnails.ts b/apps/web/src/lib/server/services/files/thumbnails.ts index e9337e6..27b681c 100644 --- a/apps/web/src/lib/server/services/files/thumbnails.ts +++ b/apps/web/src/lib/server/services/files/thumbnails.ts @@ -13,11 +13,16 @@ export const thumbnailOps = ( internals: FileInternals ): Pick => { const { sql, blobs } = internals; - const { checkStorageQuota, compensateStoredBlob } = internals; + const { reserveBytes, compensateStoredBlob } = internals; return { storeDashboardThumbnail: Effect.fn('Files.storeDashboardThumbnail')( - function* (id, version, body, size, expectedR2Key) { - const state = yield* thumbnailStorageState(sql, id, version).pipe( + function* (orgId, id, version, body, size, expectedR2Key) { + const state = yield* thumbnailStorageState( + sql, + orgId, + id, + version + ).pipe( Effect.mapError( (cause) => new StorageError({ @@ -34,27 +39,42 @@ export const thumbnailOps = ( return { _tag: 'Existing', r2Key: state.thumbnail_r2_key } as const; } - yield* checkStorageQuota( - thumbnailQuotaDelta(state.thumbnail_size_bytes, size) - ); const r2Key = `${dashboardThumbnailPrefix(id, version)}${crypto.randomUUID()}.webp`; const stored = yield* blobs.put(r2Key, body, size, 'image/webp'); - const commit = commitThumbnailStorage( - sql, - id, - version, - r2Key, - stored.size, - expectedR2Key - ).pipe( - Effect.mapError( - (cause) => - new StorageError({ - operation: 'record dashboard thumbnail', - cause - }) + // The counter moves only for the writer that wins the + // compare-and-set, by the growth (or shrink) over the previous + // thumbnail, in the same transaction. + const commit = sql + .withTransaction( + Effect.gen(function* () { + const won = yield* commitThumbnailStorage( + sql, + orgId, + id, + version, + r2Key, + stored.size, + expectedR2Key + ); + if (won) { + yield* reserveBytes( + orgId, + thumbnailQuotaDelta(state.thumbnail_size_bytes, stored.size) + ); + } + return won; + }) ) - ); + .pipe( + Effect.catchTag('SqlError', (cause) => + Effect.fail( + new StorageError({ + operation: 'record dashboard thumbnail', + cause + }) + ) + ) + ); const committed = yield* commit.pipe( Effect.catch((failure) => compensateStoredBlob( @@ -74,7 +94,12 @@ export const thumbnailOps = ( r2Key, 'dashboard thumbnail' ).pipe(Effect.catchTag('NotFound', () => Effect.void)); - const winner = yield* thumbnailStorageState(sql, id, version).pipe( + const winner = yield* thumbnailStorageState( + sql, + orgId, + id, + version + ).pipe( Effect.mapError( (cause) => new StorageError({ diff --git a/apps/web/src/lib/server/services/files/types.ts b/apps/web/src/lib/server/services/files/types.ts index a35898d..0131d3d 100644 --- a/apps/web/src/lib/server/services/files/types.ts +++ b/apps/web/src/lib/server/services/files/types.ts @@ -6,6 +6,7 @@ import { InvalidRequest, NotFound, StorageError } from '../../errors'; const FileContentRow = Schema.Struct({ id: Schema.String, + org_id: Schema.String, display_name: Schema.String, content_type: Schema.String, version: Schema.Int, @@ -48,6 +49,9 @@ export interface VersionUploadInput { export interface FileContent { readonly file: FileSummary; + // The owning org, resolved from the row so content routes (which run + // without a tenant) can bind grants to it. + readonly orgId: string; readonly r2Key: string; readonly thumbnailR2Key: string | null; } @@ -118,6 +122,7 @@ export interface FilesShape { readonly scheduleAllPurgesNow: Effect.Effect; readonly recordDownload: (id: string) => Effect.Effect; readonly storeDashboardThumbnail: ( + orgId: string, id: string, version: number, body: ReadableStream | null, diff --git a/apps/web/src/lib/server/services/files/upload.ts b/apps/web/src/lib/server/services/files/upload.ts index a7a2f2d..4af2cd0 100644 --- a/apps/web/src/lib/server/services/files/upload.ts +++ b/apps/web/src/lib/server/services/files/upload.ts @@ -15,9 +15,10 @@ import { decodeContentRows } from './types'; export const uploadOps = ( internals: FileInternals ): Pick => { - const { blobs, sql, config, tags } = internals; + const { blobs, sql, config, tags, org } = internals; const { - checkStorageQuota, + ensureHeadroom, + reserveBytes, compensateStoredBlob, commitStoredVersion, findDashboardFile @@ -45,7 +46,7 @@ export const uploadOps = ( message: 'File name is invalid' }) }); - yield* checkStorageQuota(size); + yield* ensureHeadroom(size); const contentType = contentTypeForUpload(displayName, input.contentType); const visibility = visibilityForFile( displayName, @@ -61,35 +62,38 @@ export const uploadOps = ( .withTransaction( Effect.gen(function* () { yield* sql` - INSERT INTO files ( - id, display_name, content_type, kind, current_version, size_bytes, - public, is_site, created_at, updated_at, expires_at, index_state - ) VALUES ( - ${id}, ${displayName}, ${contentType}, 'file', 1, ${stored.size}, - ${visibility.public}, false, ${createdAt}, ${createdAt}, - ${input.expiresAt}, 'pending' - )`; + INSERT INTO files ( + id, org_id, display_name, content_type, kind, current_version, + size_bytes, public, is_site, created_at, updated_at, expires_at, + index_state + ) VALUES ( + ${id}, ${org.id}, ${displayName}, ${contentType}, 'file', 1, + ${stored.size}, ${visibility.public}, false, ${createdAt}, + ${createdAt}, ${input.expiresAt}, 'pending' + )`; yield* sql` - INSERT INTO file_versions ( - file_id, version, r2_key, size_bytes, sha256, content_type, - created_at, text_content - ) VALUES ( - ${id}, 1, ${r2Key}, ${stored.size}, NULL, ${contentType}, - ${createdAt}, NULL - )`; + INSERT INTO file_versions ( + file_id, org_id, version, r2_key, size_bytes, sha256, + content_type, created_at, text_content + ) VALUES ( + ${id}, ${org.id}, 1, ${r2Key}, ${stored.size}, NULL, + ${contentType}, ${createdAt}, NULL + )`; if (resolvedTags.length > 0) { yield* sql` INSERT INTO file_tags ${sql.insert( resolvedTags.map((tag) => ({ file_id: id, tag_id: tag.id })) )}`; } - yield* refreshSearchDocument(sql, id); + yield* refreshSearchDocument(sql, id, org.id); + yield* reserveBytes(org.id, stored.size); }) ) .pipe( - Effect.mapError( - (cause) => + Effect.catchTag('SqlError', (cause) => + Effect.fail( new StorageError({ operation: 'commit file metadata', cause }) + ) ) ); yield* commit.pipe( @@ -97,7 +101,7 @@ export const uploadOps = ( compensateStoredBlob(failure, id, 1, r2Key, 'upload') ) ); - forgetTagListCache(); + forgetTagListCache(org.id); return { file: { @@ -141,7 +145,7 @@ export const uploadOps = ( message: 'Upload length is invalid' }) }); - yield* checkStorageQuota(size); + yield* ensureHeadroom(size); const contentType = contentTypeForUpload( current.displayName, input.contentType @@ -188,15 +192,16 @@ export const uploadOps = ( }); } const rows = yield* sql` - SELECT - f.id, f.display_name, v.content_type, v.version, v.size_bytes, - f.public AS is_public, f.is_site, v.r2_key, v.thumbnail_r2_key, v.created_at - FROM files f - JOIN file_versions v ON v.file_id = f.id - WHERE f.id = ${id} AND v.version = ${version} - AND f.deleted_at IS NULL AND f.is_site = false - LIMIT 1 - `.pipe( + SELECT + f.id, f.org_id, f.display_name, v.content_type, v.version, + v.size_bytes, f.public AS is_public, f.is_site, v.r2_key, + v.thumbnail_r2_key, v.created_at + FROM files f + JOIN file_versions v ON v.file_id = f.id + WHERE f.id = ${id} AND f.org_id = ${org.id} AND v.version = ${version} + AND f.deleted_at IS NULL AND f.is_site = false + LIMIT 1 + `.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'find version to restore', cause }) @@ -204,7 +209,7 @@ export const uploadOps = ( ); const source = decodeContentRows(rows)[0]; if (!source) return yield* new NotFound({ id }); - yield* checkStorageQuota(source.size_bytes); + yield* ensureHeadroom(source.size_bytes); const sourceObject = yield* blobs.get(source.r2_key); const r2Key = `v/${current.id}/${crypto.randomUUID()}`; const stored = yield* blobs.put( diff --git a/apps/web/src/lib/server/services/indexing.ts b/apps/web/src/lib/server/services/indexing.ts index e7bf51c..c33dd7f 100644 --- a/apps/web/src/lib/server/services/indexing.ts +++ b/apps/web/src/lib/server/services/indexing.ts @@ -16,18 +16,18 @@ import { safeIndexError } from '../semantic-policy'; import { isSearchableText, searchTextLimit } from '../search-text'; -import { createObjectTtlCache } from '../isolate-cache'; +import { createTtlCache } from '../isolate-cache'; import { PgSql } from '../pg'; import { Blobs } from './blobs'; +import { CurrentOrg } from './current-org'; import { Embedder, VectorIndex } from './semantic'; const INDEX_LEASE_MS = 5 * 60 * 1_000; const EMBEDDING_MODEL = '@cf/baai/bge-small-en-v1.5'; const SEMANTIC_STATUS_CACHE_TTL_MS = 10_000; -// The Postgres client is built per request, so the cache is keyed by a -// module-scope sentinel: there is exactly one database per deployment. -const semanticStatusKey = {}; -const semanticStatusCache = createObjectTtlCache( +// Per isolate, per org; the Postgres client is rebuilt per request so it +// cannot be the key. +const semanticStatusCache = createTtlCache( SEMANTIC_STATUS_CACHE_TTL_MS ); @@ -71,6 +71,7 @@ export class Indexing extends Context.Service()( const makeIndexing = Effect.gen(function* () { const sql = yield* PgSql; + const org = yield* CurrentOrg; const blobs = yield* Blobs; const embedder = yield* Embedder; const vectors = yield* VectorIndex; @@ -87,7 +88,7 @@ const makeIndexing = Effect.gen(function* () { FROM files f JOIN file_versions v ON v.file_id = f.id AND v.version = f.current_version - WHERE f.id = ${fileId} AND f.deleted_at IS NULL + WHERE f.id = ${fileId} AND f.org_id = ${org.id} AND f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${new Date().toISOString()}) LIMIT 1`.pipe(Effect.mapError(storage('find indexing job'))); return decodeRows(IndexJobRow, rows)[0] ?? null; @@ -124,6 +125,7 @@ const makeIndexing = Effect.gen(function* () { const now = new Date(); const leaseUntil = new Date(now.getTime() + INDEX_LEASE_MS).toISOString(); const lease = { + orgId: org.id, fileId, version: initial.current_version, attempt: initial.index_attempts + 1, @@ -246,7 +248,8 @@ const makeIndexing = Effect.gen(function* () { const rows = yield* sql<{ id: string }>` SELECT id FROM files - WHERE deleted_at IS NULL + WHERE org_id = ${org.id} + AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ${now}) AND index_attempts < ${MAX_INDEX_ATTEMPTS} AND ( @@ -267,7 +270,7 @@ const makeIndexing = Effect.gen(function* () { SET index_state = 'pending', index_cursor = 0, index_attempts = 0, index_error = NULL, index_next_run_at = NULL, index_lease_token = NULL - WHERE id = ${fileId} AND deleted_at IS NULL + WHERE id = ${fileId} AND org_id = ${org.id} AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ${new Date().toISOString()})`.pipe( Effect.mapError(storage('enqueue semantic indexing')) ); @@ -275,9 +278,9 @@ const makeIndexing = Effect.gen(function* () { const status = Effect.gen(function* () { const enabled = embedder.enabled && vectors.enabled; - const cached = semanticStatusCache.get(semanticStatusKey); + const cached = semanticStatusCache.get(org.id); if (cached) return { ...cached, enabled }; - const indexedChunks = yield* vectors.count; + const indexedChunks = yield* vectors.count(org.id); const result = { enabled, indexedChunks, @@ -286,7 +289,7 @@ const makeIndexing = Effect.gen(function* () { costNotice: 'Embeddings are stored in Postgres (pgvector) beside the file rows; each indexed chunk costs 384 floats plus its HNSW index entry, and keyword search stays available when semantic search is off.' }; - semanticStatusCache.set(semanticStatusKey, result); + semanticStatusCache.set(org.id, result); return result; }).pipe(Effect.withSpan('Indexing.status')); diff --git a/apps/web/src/lib/server/services/lifecycle.ts b/apps/web/src/lib/server/services/lifecycle.ts index 5e6b326..72a1995 100644 --- a/apps/web/src/lib/server/services/lifecycle.ts +++ b/apps/web/src/lib/server/services/lifecycle.ts @@ -11,8 +11,16 @@ export interface LifecycleSummary { readonly files: number; } +// Per-org caps. Small on purpose: the tick visits a random handful of +// orgs, so a busy tenant only ever gets its share. +export const ORG_SWEEP_LIMIT = 2; + export interface LifecycleShape { - readonly run: Effect.Effect; + // Work that is not tenant-scoped (device codes); runs once per tick. + readonly global: Effect.Effect; + // One org's share of purges, indexing, and site cleanup; runAcrossOrgs + // in edge.ts runs it once per randomly chosen live org. + readonly org: Effect.Effect; } export class Lifecycle extends Context.Service()( @@ -59,38 +67,38 @@ export const runLifecycleTasks = (tasks: LifecycleTasks) => return { authentication, sites, indexing, files }; }); +export const summarize = ( + authentication: number, + perOrg: ReadonlyArray +): LifecycleSummary => + perOrg.reduce( + (total, summary) => ({ + authentication: total.authentication, + sites: total.sites + summary.sites, + indexing: total.indexing + summary.indexing, + files: total.files + summary.files + }), + { authentication, sites: 0, indexing: 0, files: 0 } + ); + const makeLifecycle = Effect.gen(function* () { const auth = yield* Auth; const files = yield* Files; const indexing = yield* Indexing; const sites = yield* Sites; - const run = runLifecycleTasks({ - // Rotation and sweep are independent: a failed rotation check must - // not stop expired-credential cleanup, and vice versa. - authentication: Effect.zip( - auth.enforcePasscodeRotation.pipe( - Effect.map((rotation) => rotation.revoked), - Effect.catchCause((cause) => - Effect.sync(() => { - console.error( - JSON.stringify({ - message: 'passcode rotation enforcement failed', - cause: String(cause) - }) - ); - return 0; - }) - ) - ), - auth.sweepExpired(100) - ).pipe(Effect.map(([revoked, swept]) => revoked + swept)), - sites: sites.sweepLifecycle(10), - indexing: indexing.runDue(5), - files: files.sweepPurges(5) - }).pipe(Effect.withSpan('Lifecycle.run')); + const global = recover('authentication', auth.sweepExpired(100), 0).pipe( + Effect.withSpan('Lifecycle.global') + ); + + const org = runLifecycleTasks({ + authentication: Effect.succeed(0), + sites: sites.sweepLifecycle(ORG_SWEEP_LIMIT), + indexing: indexing.runDue(ORG_SWEEP_LIMIT), + files: files.sweepPurges(ORG_SWEEP_LIMIT) + }).pipe(Effect.withSpan('Lifecycle.org')); - return Lifecycle.of({ run }); + return Lifecycle.of({ global, org }); }); export const LifecycleLive = Layer.effect(Lifecycle, makeLifecycle); diff --git a/apps/web/src/lib/server/services/search.ts b/apps/web/src/lib/server/services/search.ts index 5bddd50..815b947 100644 --- a/apps/web/src/lib/server/services/search.ts +++ b/apps/web/src/lib/server/services/search.ts @@ -22,6 +22,7 @@ import { type RankedRow } from '../search-candidates'; import { PgSql } from '../pg'; +import { CurrentOrg } from './current-org'; import { Embedder, VectorIndex } from './semantic'; export interface SearchInput { @@ -63,6 +64,7 @@ const noCandidates = Effect.succeed>([]); const makeSearch = Effect.gen(function* () { const sql = yield* PgSql; + const org = yield* CurrentOrg; const embedder = yield* Embedder; const vectorIndex = yield* VectorIndex; @@ -82,7 +84,8 @@ const makeSearch = Effect.gen(function* () { const result = yield* sql` SELECT ${sql.literal(dashboardFileColumns)} FROM files f - WHERE ${sql.in('f.id', fileIds)} + WHERE f.org_id = ${org.id} + AND ${sql.in('f.id', fileIds)} AND f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${new Date().toISOString()}) ${selectedTagFilter(sql, tagIds)}`.pipe( @@ -110,7 +113,8 @@ const makeSearch = Effect.gen(function* () { const rows = yield* sql` SELECT ${sql.literal(dashboardFileColumns)} FROM files f - WHERE f.deleted_at IS NULL + WHERE f.org_id = ${org.id} + AND f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${new Date().toISOString()}) ${selectedTagFilter(sql, tagIds)} ORDER BY f.updated_at DESC, f.id @@ -149,7 +153,11 @@ const makeSearch = Effect.gen(function* () { }; } - const filter = { now: new Date().toISOString(), tagIds: selectedTagIds }; + const filter = { + orgId: org.id, + now: new Date().toISOString(), + tagIds: selectedTagIds + }; // The index reads (full text, trigram) and the optional embedding + // vector query are independent; run them concurrently so search // latency is the slowest source, not their sum. Workers AI diff --git a/apps/web/src/lib/server/services/semantic.ts b/apps/web/src/lib/server/services/semantic.ts index cd8c1f4..24fe651 100644 --- a/apps/web/src/lib/server/services/semantic.ts +++ b/apps/web/src/lib/server/services/semantic.ts @@ -36,7 +36,7 @@ export interface VectorIndexShape { vector: ReadonlyArray | null, filter: CandidateFilter ) => Effect.Effect, StorageError>; - readonly count: Effect.Effect; + readonly count: (orgId: string) => Effect.Effect; } export class Embedder extends Context.Service()( @@ -91,7 +91,7 @@ export const VectorIndexNull = Layer.succeed( enabled: false, upsert: () => Effect.void, search: () => Effect.succeed([]), - count: Effect.succeed(0) + count: () => Effect.succeed(0) }) ); @@ -127,7 +127,9 @@ export const makeVectorIndex = ( SELECT c.file_id, c.embedding <=> ${vectorLiteral(vector)}::vector AS distance FROM file_chunks c JOIN files f ON f.id = c.file_id AND f.current_version = c.version - WHERE c.embedding IS NOT NULL + WHERE c.org_id = ${filter.orgId} + AND c.embedding IS NOT NULL + AND f.org_id = ${filter.orgId} AND f.deleted_at IS NULL AND (f.expires_at IS NULL OR f.expires_at > ${filter.now}) ${selectedTagFilter(sql, filter.tagIds)} @@ -147,14 +149,17 @@ export const makeVectorIndex = ( new StorageError({ operation: 'query semantic vectors', cause }) ) ), - count: sql<{ count: number }>` - SELECT COUNT(*)::integer AS count FROM file_chunks WHERE embedding IS NOT NULL`.pipe( - Effect.map((rows) => rows[0]?.count ?? 0), - Effect.mapError( - (cause) => - new StorageError({ operation: 'count semantic vectors', cause }) + count: (orgId) => + sql<{ count: number }>` + SELECT COUNT(*)::integer AS count + FROM file_chunks + WHERE org_id = ${orgId} AND embedding IS NOT NULL`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0), + Effect.mapError( + (cause) => + new StorageError({ operation: 'count semantic vectors', cause }) + ) ) - ) }); export const VectorIndexLive = (enabled: boolean) => diff --git a/apps/web/src/lib/server/services/sites.ts b/apps/web/src/lib/server/services/sites.ts index b8ea582..a1ab7f6 100644 --- a/apps/web/src/lib/server/services/sites.ts +++ b/apps/web/src/lib/server/services/sites.ts @@ -2,6 +2,7 @@ import { Context, Effect, Layer } from 'effect'; import { AppConfig } from '../config'; import { PgSql } from '../pg'; import { Blobs } from './blobs'; +import { CurrentOrg } from './current-org'; import { cleanupOps } from './sites/cleanup'; import { createInternals } from './sites/internals'; import { readOps } from './sites/read'; @@ -21,8 +22,9 @@ const makeSites = Effect.gen(function* () { const sql = yield* PgSql; const blobs = yield* Blobs; const config = yield* AppConfig; + const org = yield* CurrentOrg; - const internals = createInternals({ sql, blobs, config }); + const internals = createInternals({ sql, blobs, config, org }); return Sites.of({ ...sessionOps(internals), diff --git a/apps/web/src/lib/server/services/sites/internals.ts b/apps/web/src/lib/server/services/sites/internals.ts index 15be274..38bfadb 100644 --- a/apps/web/src/lib/server/services/sites/internals.ts +++ b/apps/web/src/lib/server/services/sites/internals.ts @@ -5,6 +5,7 @@ import { AppConfig } from '../../config'; import { NotFound, StorageError } from '../../errors'; import { siteCleanupDisposition } from '../../site-policy'; import { Blobs } from '../blobs'; +import type { CurrentOrg } from '../current-org'; import { PendingDeleteRow, SiteSessionRow, @@ -16,9 +17,10 @@ interface CoreDeps { readonly sql: PgClient.PgClient; readonly blobs: Blobs['Service']; readonly config: AppConfig['Service']; + readonly org: CurrentOrg['Service']; } -export const createInternals = ({ sql, blobs, config }: CoreDeps) => { +export const createInternals = ({ sql, blobs, config, org }: CoreDeps) => { const all = ( statement: Effect.Effect, unknown>, schema: Schema.Codec, @@ -37,7 +39,7 @@ export const createInternals = ({ sql, blobs, config }: CoreDeps) => { sql` SELECT id, file_id, display_name, version, status, created_at, expires_at FROM site_upload_sessions - WHERE id = ${sessionId} + WHERE id = ${sessionId} AND org_id = ${org.id} LIMIT 1`, SiteSessionRow, 'find site upload session' @@ -226,7 +228,7 @@ export const createInternals = ({ sql, blobs, config }: CoreDeps) => { SELECT id, file_id, display_name, version, status, created_at, expires_at FROM site_upload_sessions - WHERE status = 'open' AND expires_at <= ${now} + WHERE org_id = ${org.id} AND status = 'open' AND expires_at <= ${now} ORDER BY expires_at LIMIT ${bounded}`, SiteSessionRow, @@ -251,10 +253,12 @@ export const createInternals = ({ sql, blobs, config }: CoreDeps) => { ) { const bounded = Math.max(1, Math.min(limit, 25)); const rows = yield* sql<{ file_id: string }>` - SELECT file_id - FROM pending_site_asset_deletes - GROUP BY file_id - ORDER BY MIN(queued_at) + SELECT p.file_id + FROM pending_site_asset_deletes p + LEFT JOIN files f ON f.id = p.file_id + WHERE f.id IS NULL OR f.org_id = ${org.id} + GROUP BY p.file_id + ORDER BY MIN(p.queued_at) LIMIT ${bounded}`.pipe( Effect.mapError( (cause) => @@ -277,7 +281,8 @@ export const createInternals = ({ sql, blobs, config }: CoreDeps) => { sweepPendingDeletes, sql, blobs, - config + config, + org }; }; diff --git a/apps/web/src/lib/server/services/sites/read.ts b/apps/web/src/lib/server/services/sites/read.ts index 8739f1d..ad3dc13 100644 --- a/apps/web/src/lib/server/services/sites/read.ts +++ b/apps/web/src/lib/server/services/sites/read.ts @@ -30,7 +30,7 @@ export const readOps = ( const pinVersion = options.version !== undefined; const rows = yield* all( sql` - SELECT a.path, a.r2_key, a.content_type, a.size_bytes + SELECT f.org_id, a.path, a.r2_key, a.content_type, a.size_bytes FROM files f JOIN site_assets a ON a.file_id = f.id AND a.version = f.current_version @@ -54,6 +54,7 @@ export const readOps = ( })[0]; if (!asset) return yield* new NotFound({ id: fileId }); return { + orgId: asset.org_id, path: asset.path, r2Key: asset.r2_key, contentType: asset.content_type, diff --git a/apps/web/src/lib/server/services/sites/sessions.ts b/apps/web/src/lib/server/services/sites/sessions.ts index 157f19c..9ad50bb 100644 --- a/apps/web/src/lib/server/services/sites/sessions.ts +++ b/apps/web/src/lib/server/services/sites/sessions.ts @@ -2,7 +2,7 @@ import { normalizeSitePath } from '@adrive/shared'; import { Effect } from 'effect'; import { InvalidRequest, NotFound, StorageError } from '../../errors'; import { refreshSearchDocument } from '../../search-index'; -import { ensureStoredBytesWithin } from '../../storage-quota'; +import { ensureStorageHeadroom, reserveWithinPlan } from '../../storage-quota'; import { assertOpenSiteSession, prepareSiteManifest, @@ -26,7 +26,8 @@ export const sessionOps = ( sweepExpiredSessions, sql, blobs, - config + config, + org } = internals; return { @@ -59,7 +60,7 @@ export const sessionOps = ( (total, asset) => total + asset.sizeBytes, 0 ); - yield* ensureStoredBytesWithin(sql, config.maxTotalBytes, declaredBytes); + yield* ensureStorageHeadroom(sql, org.id, declaredBytes); let fileId: string = crypto.randomUUID(); let version = 1; @@ -69,7 +70,8 @@ export const sessionOps = ( sql` SELECT id, display_name, current_version FROM files - WHERE id = ${input.fileId} AND is_site = true AND deleted_at IS NULL + WHERE id = ${input.fileId} AND org_id = ${org.id} + AND is_site = true AND deleted_at IS NULL LIMIT 1`, ExistingSiteRow, 'find site to republish' @@ -90,12 +92,13 @@ export const sessionOps = ( .withTransaction( Effect.gen(function* () { yield* sql` - INSERT INTO site_upload_sessions ( - id, file_id, display_name, version, status, created_at, expires_at - ) VALUES ( - ${id}, ${fileId}, ${displayName}, ${version}, 'open', - ${createdAt}, ${expiresAt} - )`; + INSERT INTO site_upload_sessions ( + id, org_id, file_id, display_name, version, status, created_at, + expires_at + ) VALUES ( + ${id}, ${org.id}, ${fileId}, ${displayName}, ${version}, 'open', + ${createdAt}, ${expiresAt} + )`; for (const asset of prepared.assets) { yield* sql` INSERT INTO staged_site_assets ( @@ -193,7 +196,7 @@ export const sessionOps = ( // record behind an abort or an expiry sweep. const open = yield* sql` SELECT id FROM site_upload_sessions - WHERE id = ${session.id} AND status = 'open' + WHERE id = ${session.id} AND org_id = ${org.id} AND status = 'open' AND expires_at > ${uploadedAt} FOR UPDATE`; if (open.length === 0) return []; @@ -255,18 +258,19 @@ export const sessionOps = ( ? sql<{ id: string }>` UPDATE site_upload_sessions SET status = 'committing' - WHERE id = ${session.id} AND status = 'open' + WHERE id = ${session.id} AND org_id = ${org.id} AND status = 'open' AND expires_at > ${publishedAt} AND NOT EXISTS (SELECT 1 FROM files WHERE id = ${session.fileId}) RETURNING id` : sql<{ id: string }>` UPDATE site_upload_sessions SET status = 'committing' - WHERE id = ${session.id} AND status = 'open' + WHERE id = ${session.id} AND org_id = ${org.id} AND status = 'open' AND expires_at > ${publishedAt} AND EXISTS ( SELECT 1 FROM files - WHERE id = ${session.fileId} AND is_site = true + WHERE id = ${session.fileId} AND org_id = ${org.id} + AND is_site = true AND deleted_at IS NULL AND current_version = ${session.version - 1} ) @@ -300,16 +304,25 @@ export const sessionOps = ( cause: 'The site changed while it was publishing' }); } + // The previous version's assets leave R2 after commit, so + // the org is charged only the difference. + const previous = + session.version === 1 + ? [] + : yield* sql<{ size_bytes: number }>` + SELECT size_bytes FROM files + WHERE id = ${session.fileId} AND org_id = ${org.id}`; + const previousBytes = previous[0]?.size_bytes ?? 0; yield* sql` - INSERT INTO files ( - id, display_name, content_type, kind, current_version, size_bytes, - public, is_site, created_at, updated_at, index_state - ) - SELECT file_id, display_name, 'text/html', 'site', 1, ${totalSize}, - true, true, ${publishedAt}, ${publishedAt}, 'pending' - FROM site_upload_sessions - WHERE id = ${session.id} AND status = 'committing' AND version = 1 - ON CONFLICT (id) DO NOTHING`; + INSERT INTO files ( + id, org_id, display_name, content_type, kind, current_version, + size_bytes, public, is_site, created_at, updated_at, index_state + ) + SELECT file_id, org_id, display_name, 'text/html', 'site', 1, + ${totalSize}, true, true, ${publishedAt}, ${publishedAt}, 'pending' + FROM site_upload_sessions + WHERE id = ${session.id} AND status = 'committing' AND version = 1 + ON CONFLICT (id) DO NOTHING`; yield* sql` UPDATE files SET current_version = ${session.version}, size_bytes = ${totalSize}, @@ -317,7 +330,7 @@ export const sessionOps = ( updated_at = ${publishedAt}, index_state = 'pending', index_cursor = 0, index_attempts = 0, index_error = NULL, index_next_run_at = NULL, index_lease_token = NULL - WHERE id = ${session.fileId} + WHERE id = ${session.fileId} AND org_id = ${org.id} AND current_version = ${session.version - 1} AND is_site = true AND EXISTS ( @@ -326,13 +339,13 @@ export const sessionOps = ( AND version > 1 )`; yield* sql` - INSERT INTO file_versions ( - file_id, version, r2_key, size_bytes, sha256, content_type, - created_at, text_content - ) - SELECT s.file_id, s.version, ${versionKey}, ${totalSize}, NULL, - 'text/html', ${publishedAt}, NULL - FROM site_upload_sessions s + INSERT INTO file_versions ( + file_id, org_id, version, r2_key, size_bytes, sha256, + content_type, created_at, text_content + ) + SELECT s.file_id, s.org_id, s.version, ${versionKey}, ${totalSize}, + NULL, 'text/html', ${publishedAt}, NULL + FROM site_upload_sessions s JOIN files f ON f.id = s.file_id WHERE s.id = ${session.id} AND s.status = 'committing' AND f.current_version = s.version`; @@ -366,7 +379,7 @@ export const sessionOps = ( JOIN staged_site_assets a ON a.session_id = s.id WHERE s.id = ${session.id} AND s.status = 'committing' AND a.r2_key IS NOT NULL AND a.stored_size_bytes IS NOT NULL`; - yield* refreshSearchDocument(sql, session.fileId); + yield* refreshSearchDocument(sql, session.fileId, org.id); yield* sql` UPDATE site_upload_sessions SET status = 'complete' WHERE id = ${session.id} AND status = 'committing' @@ -384,6 +397,7 @@ export const sessionOps = ( SELECT 1 FROM site_upload_sessions WHERE id = ${session.id} AND status = 'complete' )`; + yield* reserveWithinPlan(sql, org.id, totalSize - previousBytes); }) ) .pipe( @@ -431,7 +445,7 @@ export const sessionOps = ( SELECT id, display_name, current_version, size_bytes, created_at, expires_at, download_count, last_download_at FROM files - WHERE id = ${session.fileId} AND is_site = true + WHERE id = ${session.fileId} AND org_id = ${org.id} AND is_site = true AND current_version = ${session.version} LIMIT 1`, SiteFileRow, diff --git a/apps/web/src/lib/server/services/sites/types.ts b/apps/web/src/lib/server/services/sites/types.ts index 5e8fa74..7a32d6a 100644 --- a/apps/web/src/lib/server/services/sites/types.ts +++ b/apps/web/src/lib/server/services/sites/types.ts @@ -38,6 +38,7 @@ export const SiteFileRow = Schema.Struct({ }); export const SiteAssetRow = Schema.Struct({ + org_id: Schema.String, path: Schema.String, r2_key: Schema.String, content_type: Schema.String, @@ -77,6 +78,7 @@ export interface SiteCommitResult { } export interface SiteContent { + readonly orgId: string; readonly path: string; readonly r2Key: string; readonly contentType: string; diff --git a/apps/web/src/lib/server/services/tags.ts b/apps/web/src/lib/server/services/tags.ts index 3108641..4da8ae2 100644 --- a/apps/web/src/lib/server/services/tags.ts +++ b/apps/web/src/lib/server/services/tags.ts @@ -7,19 +7,17 @@ import { normalizeTagName, uniqueTagNames } from '../tag-policy'; -import { createObjectTtlCache } from '../isolate-cache'; +import { createTtlCache } from '../isolate-cache'; import { PgSql } from '../pg'; +import { CurrentOrg } from './current-org'; const TAG_LIST_CACHE_TTL_MS = 5_000; -// One database per deployment, so the per-isolate cache needs a single -// stable key; the Postgres client is rebuilt per request and cannot be it. -const tagListCacheKey = {}; -const tagListCache = createObjectTtlCache>( - TAG_LIST_CACHE_TTL_MS -); +// Per isolate, per org: the Postgres client is rebuilt per request so it +// cannot be the key, and one org's writes must never expire another's. +const tagListCache = createTtlCache>(TAG_LIST_CACHE_TTL_MS); -export const forgetTagListCache = () => { - tagListCache.delete(tagListCacheKey); +export const forgetTagListCache = (orgId: string) => { + tagListCache.delete(orgId); }; const TagRow = Schema.Struct({ @@ -80,13 +78,15 @@ export class Tags extends Context.Service()('app/Tags') {} const makeTags = Effect.gen(function* () { const sql = yield* PgSql; + const org = yield* CurrentOrg; const select = sql.literal(tagSelect); const list = Effect.gen(function* () { - const cached = tagListCache.get(tagListCacheKey); + const cached = tagListCache.get(org.id); if (cached) return cached; const rows = yield* sql` ${select} + WHERE t.org_id = ${org.id} GROUP BY t.id ORDER BY t.normalized_name, t.id`.pipe( Effect.mapError( @@ -94,14 +94,14 @@ const makeTags = Effect.gen(function* () { ) ); const tags = decodeTagRows(rows).map(toTag); - tagListCache.set(tagListCacheKey, tags); + tagListCache.set(org.id, tags); return tags; }).pipe(Effect.withSpan('Tags.list')); const find = Effect.fn('Tags.find')(function* (id: string) { const rows = yield* sql` ${select} - WHERE t.id = ${id} + WHERE t.id = ${id} AND t.org_id = ${org.id} GROUP BY t.id LIMIT 1`.pipe( Effect.mapError( @@ -119,7 +119,8 @@ const makeTags = Effect.gen(function* () { if (normalizedNames.length === 0) return []; const rows = yield* sql` ${select} - WHERE ${sql.in('t.normalized_name', normalizedNames)} + WHERE t.org_id = ${org.id} + AND ${sql.in('t.normalized_name', normalizedNames)} GROUP BY t.id ORDER BY t.normalized_name`.pipe( Effect.mapError( @@ -146,18 +147,19 @@ const makeTags = Effect.gen(function* () { INSERT INTO tags ${sql.insert( missing.map((tag) => ({ id: crypto.randomUUID(), + org_id: org.id, name: tag.name, normalized_name: tag.normalizedName, color: null, created_at: createdAt })) )} - ON CONFLICT (normalized_name) DO NOTHING`.pipe( + ON CONFLICT (org_id, normalized_name) DO NOTHING`.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'auto-create tags', cause }) ) ); - forgetTagListCache(); + forgetTagListCache(org.id); } const resolved = yield* findByNormalized(normalized); const byNormalized = new Map( @@ -177,12 +179,12 @@ const makeTags = Effect.gen(function* () { const color = yield* validate(() => normalizeTagColor(input.color)); const createdAt = new Date().toISOString(); yield* sql` - INSERT INTO tags (id, name, normalized_name, color, created_at) + INSERT INTO tags (id, org_id, name, normalized_name, color, created_at) VALUES ( - ${crypto.randomUUID()}, ${tag.name}, ${tag.normalizedName}, + ${crypto.randomUUID()}, ${org.id}, ${tag.name}, ${tag.normalizedName}, ${color}, ${createdAt} ) - ON CONFLICT (normalized_name) DO NOTHING`.pipe( + ON CONFLICT (org_id, normalized_name) DO NOTHING`.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'create tag', cause }) ) @@ -195,7 +197,7 @@ const makeTags = Effect.gen(function* () { cause: 'Tag was not returned after creation' }); } - forgetTagListCache(); + forgetTagListCache(org.id); return result; }), update: Effect.fn('Tags.update')(function* (id, input) { @@ -229,14 +231,16 @@ const makeTags = Effect.gen(function* () { UPDATE tags SET name = ${name.name}, normalized_name = ${name.normalizedName}, color = ${color} - WHERE id = ${id}`.pipe(Effect.andThen(refreshAllIndexedTags(sql))) + WHERE id = ${id} AND org_id = ${org.id}`.pipe( + Effect.andThen(refreshAllIndexedTags(sql, org.id)) + ) ) .pipe( Effect.mapError( (cause) => new StorageError({ operation: 'update tag', cause }) ) ); - forgetTagListCache(); + forgetTagListCache(org.id); return yield* find(id); }), remove: Effect.fn('Tags.remove')(function* (id) { @@ -244,8 +248,8 @@ const makeTags = Effect.gen(function* () { // file_tags cascades from tags, so one delete drops the links. yield* sql .withTransaction( - sql`DELETE FROM tags WHERE id = ${id}`.pipe( - Effect.andThen(refreshAllIndexedTags(sql)) + sql`DELETE FROM tags WHERE id = ${id} AND org_id = ${org.id}`.pipe( + Effect.andThen(refreshAllIndexedTags(sql, org.id)) ) ) .pipe( @@ -253,7 +257,7 @@ const makeTags = Effect.gen(function* () { (cause) => new StorageError({ operation: 'delete tag', cause }) ) ); - forgetTagListCache(); + forgetTagListCache(org.id); }), setFileTags: Effect.fn('Tags.setFileTags')(function* (fileId, names) { yield* sql @@ -262,7 +266,7 @@ const makeTags = Effect.gen(function* () { // Replacements must serialize even when the file has no tags. // Locking only existing file_tags rows leaves that case unprotected. const file = yield* sql` - SELECT id FROM files WHERE id = ${fileId} FOR UPDATE`; + SELECT id FROM files WHERE id = ${fileId} AND org_id = ${org.id} FOR UPDATE`; if (file.length === 0) return yield* new NotFound({ id: fileId }); const resolved = yield* resolveNames(names); yield* sql`DELETE FROM file_tags WHERE file_id = ${fileId}`; @@ -272,7 +276,7 @@ const makeTags = Effect.gen(function* () { resolved.map((tag) => ({ file_id: fileId, tag_id: tag.id })) )}`; } - yield* refreshSearchDocument(sql, fileId); + yield* refreshSearchDocument(sql, fileId, org.id); }) ) .pipe( @@ -280,7 +284,7 @@ const makeTags = Effect.gen(function* () { Effect.fail(new StorageError({ operation: 'set file tags', cause })) ) ); - forgetTagListCache(); + forgetTagListCache(org.id); }) }); }); diff --git a/apps/web/src/lib/server/services/workos.ts b/apps/web/src/lib/server/services/workos.ts new file mode 100644 index 0000000..4647a5a --- /dev/null +++ b/apps/web/src/lib/server/services/workos.ts @@ -0,0 +1,350 @@ +import { WorkOS } from '@workos-inc/node'; +import { Context, Effect, Layer } from 'effect'; +import { AppConfig, type WorkOSConfig } from '../config'; +import { StorageError, Unauthorized } from '../errors'; + +// The slice of WorkOS the app depends on. Routes and tests program against +// this shape; the SDK is confined to workOSLive below and WorkOSFake is +// selected only by explicit development configuration or test injection. + +export interface ExchangedCode { + readonly sealedSession: string; + readonly sessionId: string; + readonly user: { + readonly id: string; + readonly email: string; + readonly emailVerified: boolean; + }; + readonly organizationId: string | null; + readonly role: string | null; +} + +export type LoadedSession = + | { + readonly authenticated: true; + readonly sessionId: string; + readonly userId: string; + readonly orgId: string | null; + readonly role: string | null; + } + | { readonly authenticated: false; readonly refreshable: boolean }; + +export type WebhookEvent = + | { readonly event: 'user.deleted'; readonly userId: string } + | { + readonly event: 'organization_membership.deleted'; + readonly orgId: string; + readonly userId: string; + } + | { readonly event: 'ignored'; readonly name: string }; + +export interface WorkOSClientShape { + readonly authorizationUrl: (state: string, redirectUri: string) => string; + readonly exchangeCode: ( + code: string + ) => Effect.Effect; + readonly loadSession: ( + cookie: string + ) => Effect.Effect; + // Returns the new sealed session, or null when the session cannot be + // refreshed (revoked, expired, or a terminal WorkOS failure). + // Temporary provider failures remain errors so callers retain the cookie. + readonly refresh: ( + cookie: string, + orgId?: string + ) => Effect.Effect; + readonly logoutUrl: ( + sessionId: string, + returnTo: string + ) => Effect.Effect; + readonly createOrganization: ( + name: string + ) => Effect.Effect<{ readonly id: string }, StorageError>; + readonly createOrganizationMembership: (input: { + readonly organizationId: string; + readonly userId: string; + readonly roleSlug: string; + }) => Effect.Effect; + readonly constructEvent: ( + payload: string, + signature: string + ) => Effect.Effect; +} + +export class WorkOSClient extends Context.Service< + WorkOSClient, + WorkOSClientShape +>()('app/WorkOSClient') {} + +const toWebhookEvent = (event: { + readonly event: string; + readonly data: unknown; +}): WebhookEvent => { + const data = + typeof event.data === 'object' && event.data !== null + ? (event.data as Record) + : {}; + const text = (value: unknown) => (typeof value === 'string' ? value : ''); + if (event.event === 'user.deleted' && text(data.id)) { + return { event: 'user.deleted', userId: text(data.id) }; + } + if ( + event.event === 'organization_membership.deleted' && + text(data.organizationId) && + text(data.userId) + ) { + return { + event: 'organization_membership.deleted', + orgId: text(data.organizationId), + userId: text(data.userId) + }; + } + return { event: 'ignored', name: event.event }; +}; + +// One SDK instance per isolate: it caches the JWKS used to verify session +// tokens locally, so per-request construction would refetch it every time. +let cachedSdk: { readonly key: string; readonly sdk: WorkOS } | undefined; +const sdkFor = (config: WorkOSConfig & { readonly apiKey: string }) => { + const key = `${config.apiKey}\n${config.clientId}`; + if (cachedSdk?.key !== key) { + cachedSdk = { + key, + sdk: new WorkOS(config.apiKey, { clientId: config.clientId }) + }; + } + return cachedSdk.sdk; +}; + +const failure = (operation: string) => (cause: unknown) => + new StorageError({ operation, cause }); + +const workOSLive = ( + config: WorkOSConfig & { readonly apiKey: string } +): WorkOSClientShape => { + const workos = sdkFor(config); + const session = (cookie: string) => + workos.userManagement.loadSealedSession({ + sessionData: cookie, + cookiePassword: config.cookiePassword + }); + return { + authorizationUrl: (state, redirectUri) => + workos.userManagement.getAuthorizationUrl({ + provider: 'authkit', + clientId: config.clientId, + redirectUri, + state + }), + exchangeCode: (code) => + Effect.tryPromise({ + try: () => + workos.userManagement.authenticateWithCode({ + clientId: config.clientId, + code, + session: { + sealSession: true, + cookiePassword: config.cookiePassword + } + }), + catch: failure('exchange WorkOS authorization code') + }).pipe( + Effect.flatMap((result) => + Effect.gen(function* () { + if (!result.sealedSession) { + return yield* new Unauthorized({ + message: 'WorkOS did not return a session' + }); + } + const loaded = yield* Effect.tryPromise({ + try: () => session(result.sealedSession ?? '').authenticate(), + catch: failure('read exchanged WorkOS session') + }); + if (!loaded.authenticated) { + return yield* new Unauthorized({ + message: 'WorkOS returned an invalid session' + }); + } + return { + sealedSession: result.sealedSession, + sessionId: loaded.sessionId, + user: { + id: result.user.id, + email: result.user.email, + emailVerified: result.user.emailVerified + }, + organizationId: result.organizationId ?? null, + role: loaded.role ?? null + }; + }) + ) + ), + loadSession: (cookie) => + Effect.tryPromise({ + try: () => session(cookie).authenticate(), + catch: failure('authenticate WorkOS session') + }).pipe( + Effect.map((result): LoadedSession => + result.authenticated + ? { + authenticated: true, + sessionId: result.sessionId, + userId: result.user.id, + orgId: result.organizationId ?? null, + role: result.role ?? null + } + : { + authenticated: false, + refreshable: result.reason === 'invalid_jwt' + } + ) + ), + refresh: (cookie, orgId) => + Effect.tryPromise({ + try: () => + session(cookie).refresh({ + cookiePassword: config.cookiePassword, + organizationId: orgId + }), + catch: failure('refresh WorkOS session') + }).pipe( + Effect.flatMap((result) => { + if (result.authenticated) { + return Effect.succeed(result.sealedSession ?? null); + } + if (result.retryable) { + return Effect.fail( + new StorageError({ + operation: 'refresh WorkOS session', + cause: result.reason + }) + ); + } + return Effect.succeed(null); + }) + ), + logoutUrl: (sessionId, returnTo) => + Effect.sync(() => + workos.userManagement.getLogoutUrl({ sessionId, returnTo }) + ), + createOrganization: (name) => + Effect.tryPromise({ + try: () => workos.organizations.createOrganization({ name }), + catch: failure('create WorkOS organization') + }).pipe(Effect.map((org) => ({ id: org.id }))), + createOrganizationMembership: (input) => + Effect.tryPromise({ + try: () => workos.userManagement.createOrganizationMembership(input), + catch: failure('create WorkOS organization membership') + }).pipe(Effect.asVoid), + constructEvent: (payload, signature) => + Effect.tryPromise({ + try: () => + workos.webhooks.constructEvent({ + payload, + sigHeader: signature, + secret: config.webhookSecret + }), + catch: () => + new Unauthorized({ message: 'Webhook signature is invalid' }) + }).pipe(Effect.map(toWebhookEvent)) + }; +}; + +// Local development and tests. Identities are plain strings so a test can +// sign in as anyone: the authorization code and the session cookie are +// both `fake:[:]`. No secrets are checked, which is why the +// fake requires explicit development opt-in in configFromEnv. +export const FAKE_SESSION_PREFIX = 'fake:'; +export const FAKE_DEV_USER = 'user_local'; + +export const fakeSession = (userId: string, orgId: string | null = null) => + `${FAKE_SESSION_PREFIX}${userId}:${orgId ?? ''}`; + +const parseFake = (value: string) => { + if (!value.startsWith(FAKE_SESSION_PREFIX)) return null; + const [userId = '', orgId = ''] = value + .slice(FAKE_SESSION_PREFIX.length) + .split(':'); + return userId ? { userId, orgId: orgId || null } : null; +}; + +const fakeEmail = (userId: string) => `${userId}@fake.adrive.invalid`; + +export const workOSFake: WorkOSClientShape = { + authorizationUrl: (state, redirectUri) => { + const url = new URL(redirectUri); + url.searchParams.set('code', fakeSession(FAKE_DEV_USER)); + url.searchParams.set('state', state); + return url.href; + }, + exchangeCode: (code) => { + const parsed = parseFake(code); + return parsed + ? Effect.succeed({ + sealedSession: fakeSession(parsed.userId, parsed.orgId), + sessionId: `fake-session:${parsed.userId}`, + user: { + id: parsed.userId, + email: fakeEmail(parsed.userId), + emailVerified: true + }, + organizationId: parsed.orgId, + role: 'owner' + }) + : Effect.fail( + new Unauthorized({ message: 'Authorization code is invalid' }) + ); + }, + loadSession: (cookie) => { + const parsed = parseFake(cookie); + return Effect.succeed( + parsed + ? { + authenticated: true, + sessionId: `fake-session:${parsed.userId}`, + userId: parsed.userId, + orgId: parsed.orgId, + role: 'owner' + } + : { authenticated: false, refreshable: false } + ); + }, + refresh: (cookie, orgId) => { + const parsed = parseFake(cookie); + return Effect.succeed( + parsed ? fakeSession(parsed.userId, orgId ?? parsed.orgId) : null + ); + }, + logoutUrl: (_sessionId, returnTo) => Effect.succeed(returnTo), + createOrganization: () => + Effect.succeed({ id: `org_fake_${crypto.randomUUID().slice(0, 8)}` }), + createOrganizationMembership: () => Effect.void, + constructEvent: (payload) => + Effect.try({ + try: (): unknown => JSON.parse(payload), + catch: () => new Unauthorized({ message: 'Webhook payload is invalid' }) + }).pipe( + Effect.map((body) => + toWebhookEvent( + typeof body === 'object' && body !== null && 'event' in body + ? { + event: String(body.event), + data: 'data' in body ? body.data : undefined + } + : { event: '', data: undefined } + ) + ) + ) +}; + +export const WorkOSFake = Layer.succeed(WorkOSClient, workOSFake); + +export const WorkOSLive = Layer.effect( + WorkOSClient, + Effect.map(AppConfig, (config) => + config.workos.apiKey === null + ? workOSFake + : workOSLive({ ...config.workos, apiKey: config.workos.apiKey }) + ) +); diff --git a/apps/web/src/lib/server/storage-quota.ts b/apps/web/src/lib/server/storage-quota.ts index 44545af..7b948e1 100644 --- a/apps/web/src/lib/server/storage-quota.ts +++ b/apps/web/src/lib/server/storage-quota.ts @@ -1,60 +1,108 @@ import type { PgClient } from '@effect/sql-pg'; import { Effect } from 'effect'; import { InvalidRequest, StorageError } from './errors'; +import { planLimits } from './plans'; -// Bytes actually held in R2: every version of a regular file is stored, -// a site keeps only its current version's assets (old assets are deleted -// at commit), and assets staged into an uncommitted publish session are -// already in R2 so they must count before the commit creates file rows. -// Concurrent uploads can each pass this check before the other commits — -// for a single-user deployment that bounded overshoot (≤ one upload per -// concurrent request) is accepted; the quota is a safety net, not billing. -// Sites and files both call this before writing bytes. -export const ensureStoredBytesWithin = ( +// Stored bytes are metered per org in org_usage.stored_bytes, moved by +// the same transaction that creates or drops the rows they describe: +// file uploads, version restores, site commits, thumbnail replacement, +// and purges. The reservation is a conditional UPDATE, so two concurrent +// uploads cannot both squeeze under the limit. Assets staged into an +// uncommitted site session are not counted; the manifest is bounded by +// the per-upload cap and the session expires on its own. + +const exhausted = () => + new InvalidRequest({ + status: 413, + message: 'The storage quota is exhausted' + }); + +const missingUsage = (operation: string) => + new StorageError({ + operation, + cause: 'The organization has no usage row' + }); + +// The plan's byte ceiling for one org. +export const storageLimit = Effect.fn('StorageQuota.limit')(function* ( + sql: PgClient.PgClient, + orgId: string +) { + const rows = yield* sql<{ plan: string }>` + SELECT plan FROM orgs WHERE id = ${orgId}`; + const row = rows[0]; + if (!row) { + return yield* new StorageError({ + operation: 'read storage plan', + cause: 'Organization not found' + }); + } + return planLimits(row.plan).storedBytes; +}); + +// Moves the counter by delta. A positive delta only lands when it fits +// under the limit (zero rows means over quota); a negative delta always +// lands and floors at zero. Resolves to the new total. +export const reserveStoredBytes = Effect.fn('StorageQuota.reserve')(function* ( sql: PgClient.PgClient, - maxTotalBytes: number, - incomingBytes: number -) => - Effect.gen(function* () { - const rows = yield* sql<{ total: number }>` - SELECT - COALESCE(( - SELECT SUM(v.size_bytes + v.thumbnail_size_bytes) - FROM file_versions v - JOIN files f ON f.id = v.file_id - WHERE f.is_site = false - ), 0) - + - COALESCE(( - SELECT SUM(size_bytes) FROM files WHERE is_site = true - ), 0) - + - COALESCE(( - SELECT SUM(a.stored_size_bytes) - FROM staged_site_assets a - JOIN site_upload_sessions s ON s.id = a.session_id - WHERE a.stored_size_bytes IS NOT NULL - AND s.status IN ('open', 'committing') - ), 0) - AS total`.pipe( + orgId: string, + delta: number, + limit: number +) { + const rows = yield* sql<{ stored_bytes: number }>` + UPDATE org_usage + SET stored_bytes = GREATEST(0, stored_bytes + ${delta}) + WHERE org_id = ${orgId} + AND (${delta}::bigint <= 0 OR stored_bytes + ${delta} <= ${limit}) + RETURNING stored_bytes`; + const row = rows[0]; + if (row) return row.stored_bytes; + if (delta <= 0) return yield* missingUsage('release stored bytes'); + const existing = yield* sql<{ stored_bytes: number }>` + SELECT stored_bytes FROM org_usage WHERE org_id = ${orgId}`; + if (existing.length === 1) return yield* exhausted(); + return yield* missingUsage('reserve stored bytes'); +}); + +export const reserveWithinPlan = Effect.fn('StorageQuota.reserveWithinPlan')( + function* (sql: PgClient.PgClient, orgId: string, delta: number) { + const limit = yield* storageLimit(sql, orgId); + return yield* reserveStoredBytes(sql, orgId, delta, limit); + } +); + +export const releaseStoredBytes = Effect.fn('StorageQuota.release')(function* ( + sql: PgClient.PgClient, + orgId: string, + bytes: number +) { + const rows = yield* sql<{ stored_bytes: number }>` + UPDATE org_usage + SET stored_bytes = GREATEST(0, stored_bytes - ${Math.max(0, bytes)}) + WHERE org_id = ${orgId} + RETURNING stored_bytes`; + if (rows.length !== 1) return yield* missingUsage('release stored bytes'); +}); + +// Read-only check before a body is accepted, so a client is refused up +// front instead of streaming bytes the commit would reject. The commit's +// reservation stays authoritative. +export const ensureStorageHeadroom = Effect.fn('StorageQuota.headroom')( + function* (sql: PgClient.PgClient, orgId: string, incomingBytes: number) { + const rows = yield* sql<{ stored_bytes: number; plan: string }>` + SELECT u.stored_bytes, o.plan + FROM org_usage u + JOIN orgs o ON o.id = u.org_id + WHERE u.org_id = ${orgId}`.pipe( Effect.mapError( (cause) => new StorageError({ operation: 'measure stored bytes', cause }) ) ); - const total = rows[0]?.total; - // Fail closed: an unreadable total must block the upload, not wave - // it through as if the bucket were empty. - if (total === undefined || !Number.isFinite(total)) { - return yield* new StorageError({ - operation: 'measure stored bytes', - cause: 'Unexpected aggregate result' - }); - } - if (total + incomingBytes > maxTotalBytes) { - return yield* new InvalidRequest({ - status: 413, - message: 'The storage quota is exhausted' - }); + const row = rows[0]; + if (!row) return yield* missingUsage('measure stored bytes'); + if (row.stored_bytes + incomingBytes > planLimits(row.plan).storedBytes) { + return yield* exhausted(); } - }).pipe(Effect.withSpan('StorageQuota.ensure')); + } +); diff --git a/apps/web/src/lib/server/tenants.ts b/apps/web/src/lib/server/tenants.ts new file mode 100644 index 0000000..f64a29b --- /dev/null +++ b/apps/web/src/lib/server/tenants.ts @@ -0,0 +1,67 @@ +import type { PgClient } from '@effect/sql-pg'; +import { Effect } from 'effect'; + +export interface TenantRows { + readonly orgId: string; + readonly userId: string; + readonly slug: string; + readonly name: string; + readonly email: string; + readonly emailVerified?: boolean; + readonly role?: string; +} + +// Upserts the four rows a tenant needs before any tenant-scoped row can +// reference it: the org, the user, the membership, and the usage counter. +// Keep them atomic so a failed first sign-in cannot leave a membership +// that makes a retry skip the missing usage counter. +// Idempotent, and never rewinds a row that already exists (a later sign-in +// must not rename an org the owner renamed). +export const ensureTenant = (sql: PgClient.PgClient, tenant: TenantRows) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql` + INSERT INTO orgs (id, slug, name) + VALUES (${tenant.orgId}, ${tenant.slug}, ${tenant.name}) + ON CONFLICT (id) DO NOTHING`; + yield* sql` + INSERT INTO users (id, email, email_verified) + VALUES (${tenant.userId}, ${tenant.email}, ${tenant.emailVerified ?? false}) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, email_verified = EXCLUDED.email_verified`; + yield* sql` + INSERT INTO memberships (org_id, user_id, role) + VALUES (${tenant.orgId}, ${tenant.userId}, ${tenant.role ?? 'owner'}) + ON CONFLICT (org_id, user_id) DO NOTHING`; + yield* sql` + INSERT INTO org_usage (org_id) VALUES (${tenant.orgId}) + ON CONFLICT (org_id) DO NOTHING`; + }) + ); + +// First-login org naming. Stack C owns the real slug rules; until then the +// slug is the email's local part plus a short random suffix so two people +// named `sam` never collide. +export const slugify = (value: string) => + value + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'drive'; + +const randomHex = (bytes: number) => { + const value = new Uint8Array(bytes); + crypto.getRandomValues(value); + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); +}; + +export const personalOrgFor = (email: string) => { + const local = email.split('@')[0] ?? email; + return { + name: `${local}'s drive`, + slug: `${slugify(local)}-${randomHex(2)}` + }; +}; diff --git a/apps/web/src/lib/server/thumbnail-storage.ts b/apps/web/src/lib/server/thumbnail-storage.ts index d72f0c9..3cf8d60 100644 --- a/apps/web/src/lib/server/thumbnail-storage.ts +++ b/apps/web/src/lib/server/thumbnail-storage.ts @@ -10,6 +10,7 @@ export interface ThumbnailStorageState { // a purge that has claimed the file wins over any in-flight render. export const thumbnailStorageState = ( sql: PgClient.PgClient, + orgId: string, fileId: string, version: number ) => @@ -17,7 +18,7 @@ export const thumbnailStorageState = ( SELECT v.thumbnail_r2_key, v.thumbnail_size_bytes FROM file_versions v JOIN files f ON f.id = v.file_id - WHERE v.file_id = ${fileId} AND v.version = ${version} + WHERE v.file_id = ${fileId} AND v.org_id = ${orgId} AND v.version = ${version} AND f.purge_state = 'none' LIMIT 1`.pipe(Effect.map((rows) => rows[0] ?? null)); @@ -26,6 +27,7 @@ export const thumbnailStorageState = ( // Resolves to whether this writer won. export const commitThumbnailStorage = ( sql: PgClient.PgClient, + orgId: string, fileId: string, version: number, r2Key: string, @@ -35,7 +37,7 @@ export const commitThumbnailStorage = ( sql<{ file_id: string }>` UPDATE file_versions SET thumbnail_r2_key = ${r2Key}, thumbnail_size_bytes = ${size} - WHERE file_id = ${fileId} AND version = ${version} + WHERE file_id = ${fileId} AND org_id = ${orgId} AND version = ${version} AND thumbnail_r2_key IS NOT DISTINCT FROM ${expectedR2Key}::text AND EXISTS ( SELECT 1 FROM files f @@ -43,5 +45,6 @@ export const commitThumbnailStorage = ( ) RETURNING file_id`.pipe(Effect.map((rows) => rows.length === 1)); +// Signed: replacing a thumbnail with a smaller one hands bytes back. export const thumbnailQuotaDelta = (storedSize: number, incomingSize: number) => - Math.max(0, incomingSize - storedSize); + incomingSize - storedSize; diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index 5521355..c03c967 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,26 +1,18 @@ import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async ({ fetch, setHeaders, url }) => { +// The handle hook already resolved the session; expose only what the +// shell renders so the sealed cookie and ids never reach the client. +export const load: LayoutServerLoad = ({ locals, setHeaders, url }) => { setHeaders({ 'Cache-Control': 'private, no-store' }); - let response: Response; - try { - response = await fetch('/api/auth/check'); - } catch { - return { - browserSession: false, - authError: 'Could not restore the session', - origin: url.origin - }; - } - if (response.ok) { - return { browserSession: true, authError: '', origin: url.origin }; - } - if (response.status === 401) { - return { browserSession: false, authError: '', origin: url.origin }; - } + const auth = locals.auth; return { - browserSession: false, - authError: `Could not restore the session (${response.status})`, + session: + auth && auth.via === 'session' + ? { + user: { email: auth.email }, + org: { name: auth.orgName, slug: auth.orgSlug } + } + : null, origin: url.origin }; }; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 5c56f29..607c205 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -7,14 +7,13 @@ import { untrack } from 'svelte'; let { children, data }: LayoutProps = $props(); - const session = createDashboardSession(untrack(() => data.browserSession)); + const session = createDashboardSession(untrack(() => data.session !== null)); createToasts(); - const authError = $derived( - session.error || (!session.ready ? data.authError : '') - ); + // A stale page whose cookie expired shows the sign-in state after the + // next check instead of a wall of 401 toasts. $effect(() => { - if (!session.ready) void session.restore(); + if (data.session === null && session.token) void session.restore(); }); @@ -62,31 +61,34 @@ adrive - {#if session.token} + {#if data.session} {/if} - {#if authError} + {#if session.error}

- {authError} + {session.error}

{/if} diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index 9104bf4..f4f25f5 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -60,10 +60,12 @@ const preloadUrls = ( // failure falls back to bare URLs, which still self-heal after generation. const grantedThumbnailPreloads = async ( env: Env | undefined, + auth: App.Locals['auth'], list: FileListResponse ) => { const plain = preloadUrls(list, []); - if (plain.length === 0 || env === undefined) return plain; + if (plain.length === 0 || env === undefined || auth === null) return plain; + const orgId = auth.orgId; try { const minted = await runWorkerProgram( env, @@ -74,13 +76,15 @@ const grantedThumbnailPreloads = async ( const secrets = yield* GrantSecrets; return yield* secrets.mint({ contentOrigin: config.contentOrigin, + orgId, fileId: id, version }); }) ), { concurrency: 'unbounded' } - ) + ), + auth ); return preloadUrls( list, @@ -127,6 +131,7 @@ const tagIds = (url: URL) => { export const load: PageServerLoad = async ({ depends, fetch, + locals, platform, url }) => { @@ -182,6 +187,7 @@ export const load: PageServerLoad = async ({ initialError: '', thumbnailPreloads: await grantedThumbnailPreloads( platform?.env, + locals.auth, initialList ) }; diff --git a/apps/web/src/routes/api/auth/check/+server.ts b/apps/web/src/routes/api/auth/check/+server.ts index 593e3fe..b643f97 100644 --- a/apps/web/src/routes/api/auth/check/+server.ts +++ b/apps/web/src/routes/api/auth/check/+server.ts @@ -1,13 +1,14 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeRequest } from '$lib/server/services/auth'; +import { requireAuth } from '$lib/server/request-auth'; -export const GET: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); return Response.json({ ok: true as const }); }) ); +}; diff --git a/apps/web/src/routes/api/auth/device/approve/+server.ts b/apps/web/src/routes/api/auth/device/approve/+server.ts index 1a4c49f..89f8025 100644 --- a/apps/web/src/routes/api/auth/device/approve/+server.ts +++ b/apps/web/src/routes/api/auth/device/approve/+server.ts @@ -2,14 +2,16 @@ import { DeviceApprovalSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { decodeJson } from '$lib/server/request-json'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { Auth } from '$lib/server/services/auth'; -export const POST: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const POST: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const input = yield* decodeJson( request, DeviceApprovalSchema, @@ -19,12 +21,14 @@ export const POST: RequestHandler = ({ cookies, request, url }) => return Response.json({ ok: true as const }); }) ); +}; -export const DELETE: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const DELETE: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const input = yield* decodeJson( request, DeviceApprovalSchema, @@ -34,3 +38,4 @@ export const DELETE: RequestHandler = ({ cookies, request, url }) => return Response.json({ ok: true as const }); }) ); +}; diff --git a/apps/web/src/routes/api/auth/keys/+server.ts b/apps/web/src/routes/api/auth/keys/+server.ts index 40fae62..dc37bb5 100644 --- a/apps/web/src/routes/api/auth/keys/+server.ts +++ b/apps/web/src/routes/api/auth/keys/+server.ts @@ -2,26 +2,30 @@ import { ApiKeyCreateSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { decodeJson } from '$lib/server/request-json'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { Auth } from '$lib/server/services/auth'; // Key inventory is credential-adjacent: a leaked read-only key should not // be able to enumerate the other credentials, so listing requires write // scope just like creation and revocation. -export const GET: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); return Response.json({ keys: yield* auth.listApiKeys }); }) ); +}; -export const POST: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const POST: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const input = yield* decodeJson( request, ApiKeyCreateSchema, @@ -38,3 +42,4 @@ export const POST: RequestHandler = ({ cookies, request, url }) => ); }) ); +}; diff --git a/apps/web/src/routes/api/auth/keys/[id]/+server.ts b/apps/web/src/routes/api/auth/keys/[id]/+server.ts index 9b780d7..f85a352 100644 --- a/apps/web/src/routes/api/auth/keys/[id]/+server.ts +++ b/apps/web/src/routes/api/auth/keys/[id]/+server.ts @@ -1,14 +1,17 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { requireWrite } from '$lib/server/request-auth'; +import { Auth } from '$lib/server/services/auth'; -export const DELETE: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const DELETE: RequestHandler = (event) => { + const { params, request } = event; + return runEdge( Effect.gen(function* () { const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); yield* auth.revokeApiKey(params.id); return new Response(null, { status: 204 }); }) ); +}; diff --git a/apps/web/src/routes/api/auth/session/+server.ts b/apps/web/src/routes/api/auth/session/+server.ts deleted file mode 100644 index ecb678f..0000000 --- a/apps/web/src/routes/api/auth/session/+server.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { PasscodeLoginSchema } from '@adrive/shared'; -import type { RequestHandler } from './$types'; -import { Effect } from 'effect'; -import { - SESSION_COOKIE, - SESSION_MAX_AGE_SECONDS -} from '$lib/server/auth-policy'; -import { authRateLimitResponse } from '$lib/server/auth-rate-limit-response'; -import { runEdge } from '$lib/server/edge'; -import { InvalidRequest, Unauthorized } from '$lib/server/errors'; -import { decodeJson } from '$lib/server/request-json'; -import { Auth } from '$lib/server/services/auth'; -import { AuthGuard } from '$lib/server/services/auth-guard'; - -export const POST: RequestHandler = ({ - cookies, - request, - url, - getClientAddress -}) => - runEdge( - Effect.gen(function* () { - if (request.headers.get('origin') !== url.origin) { - return yield* new InvalidRequest({ - status: 403, - message: 'The request origin is not allowed' - }); - } - const auth = yield* Auth; - const authGuard = yield* AuthGuard; - const clientId = getClientAddress(); - const lock = yield* authGuard.checkPasscodeLock(clientId); - if (!lock.allowed) return authRateLimitResponse(lock); - const rateLimit = yield* authGuard.consume('passcodeLogin', clientId); - if (!rateLimit.allowed) return authRateLimitResponse(rateLimit); - const input = yield* decodeJson( - request, - PasscodeLoginSchema, - 'A passcode is required' - ); - const outcome = yield* auth.createSession(input.passcode).pipe( - Effect.matchEffect({ - onFailure: (failure) => - failure instanceof Unauthorized - ? authGuard - .recordPasscodeFailure(clientId) - .pipe( - Effect.flatMap((decision) => - decision.allowed - ? Effect.fail(failure) - : Effect.succeed(authRateLimitResponse(decision)) - ) - ) - : Effect.fail(failure), - onSuccess: (token) => - authGuard - .clearPasscodeFailures(clientId) - .pipe(Effect.map(() => token)) - }) - ); - if (outcome instanceof Response) return outcome; - cookies.set(SESSION_COOKIE, outcome, { - path: '/', - httpOnly: true, - secure: true, - sameSite: 'strict', - maxAge: SESSION_MAX_AGE_SECONDS - }); - return Response.json({ ok: true as const }); - }) - ); - -export const DELETE: RequestHandler = ({ cookies, request, url }) => - runEdge( - Effect.gen(function* () { - if (request.headers.get('origin') !== url.origin) { - return yield* new InvalidRequest({ - status: 403, - message: 'The request origin is not allowed' - }); - } - const auth = yield* Auth; - yield* auth.revokeSession(cookies.get(SESSION_COOKIE)); - cookies.delete(SESSION_COOKIE, { path: '/' }); - return new Response(null, { status: 204 }); - }) - ); diff --git a/apps/web/src/routes/api/auth/sessions/+server.ts b/apps/web/src/routes/api/auth/sessions/+server.ts deleted file mode 100644 index e443421..0000000 --- a/apps/web/src/routes/api/auth/sessions/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { RequestHandler } from './$types'; -import { Effect } from 'effect'; -import { SESSION_COOKIE } from '$lib/server/auth-policy'; -import { runEdge } from '$lib/server/edge'; -import { InvalidRequest } from '$lib/server/errors'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; - -// Sign out everywhere: drops every dashboard session and cancels any -// in-flight device authorizations. API keys are left alone — revoke -// those individually so a stolen browser session cannot brick the CLI. -export const DELETE: RequestHandler = ({ cookies, request, url }) => - runEdge( - Effect.gen(function* () { - if (request.headers.get('origin') !== url.origin) { - return yield* new InvalidRequest({ - status: 403, - message: 'The request origin is not allowed' - }); - } - const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); - const revoked = yield* auth.revokeAllSessions; - cookies.delete(SESSION_COOKIE, { path: '/' }); - return Response.json( - { revoked }, - { headers: { 'Cache-Control': 'private, no-store' } } - ); - }) - ); diff --git a/apps/web/src/routes/api/files/+server.ts b/apps/web/src/routes/api/files/+server.ts index 64f6863..bdf4754 100644 --- a/apps/web/src/routes/api/files/+server.ts +++ b/apps/web/src/routes/api/files/+server.ts @@ -1,16 +1,12 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge, runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; +import { requireAuth, requireWrite } from '$lib/server/request-auth'; import { AppConfig } from '$lib/server/config'; import { validateExpiration } from '$lib/server/auth-policy'; import { authRateLimitResponse } from '$lib/server/auth-rate-limit-response'; import { InvalidRequest } from '$lib/server/errors'; import { parsePageSize } from '$lib/server/list-cursor'; -import { - Auth, - authorizeRequest, - authorizeWriteRequest -} from '$lib/server/services/auth'; import { AuthGuard } from '$lib/server/services/auth-guard'; import { Files } from '$lib/server/services/files'; import { Indexing } from '$lib/server/services/indexing'; @@ -67,15 +63,15 @@ const parseTags = (value: string | null) => { } }; -export const GET: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const files = yield* Files; const tags = yield* Tags; const indexing = yield* Indexing; const config = yield* AppConfig; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); const trashed = url.searchParams.get('trashed') === 'true'; const page = { cursor: url.searchParams.get('cursor'), @@ -117,22 +113,17 @@ export const GET: RequestHandler = ({ cookies, request, url }) => }); }) ); +}; export const PUT: RequestHandler = async (event) => { - const { cookies, request, url } = event; + const { request, url } = event; const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const auth = yield* Auth; const authGuard = yield* AuthGuard; const files = yield* Files; const config = yield* AppConfig; - const credential = yield* authorizeWriteRequest( - auth, - request, - url, - cookies - ); + const credential = yield* requireWrite(event); const rateLimit = yield* authGuard.consume( 'upload', credential.credentialId @@ -216,7 +207,8 @@ export const PUT: RequestHandler = async (event) => { Effect.gen(function* () { const indexing = yield* Indexing; yield* indexing.process(uploadedFileId); - }) + }), + event.locals.auth ) ); } @@ -224,13 +216,12 @@ export const PUT: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - const { cookies, request, url } = event; + const { request, url } = event; const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const auth = yield* Auth; const files = yield* Files; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); if (url.searchParams.get('trashed') !== 'true') { return yield* new InvalidRequest({ status: 400, @@ -250,7 +241,8 @@ export const DELETE: RequestHandler = async (event) => { Effect.gen(function* () { const files = yield* Files; yield* files.sweepPurges(10); - }) + }), + event.locals.auth ) ); } diff --git a/apps/web/src/routes/api/files/[id]/+server.ts b/apps/web/src/routes/api/files/[id]/+server.ts index f089d4f..a63a23f 100644 --- a/apps/web/src/routes/api/files/[id]/+server.ts +++ b/apps/web/src/routes/api/files/[id]/+server.ts @@ -2,16 +2,12 @@ import { FileMutationSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge, runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; +import { requireAuth, requireWrite } from '$lib/server/request-auth'; import { AppConfig } from '$lib/server/config'; 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 { - Auth, - authorizeRequest, - authorizeWriteRequest -} from '$lib/server/services/auth'; import { Files } from '$lib/server/services/files'; import { Tags } from '$lib/server/services/tags'; import { Indexing } from '$lib/server/services/indexing'; @@ -19,15 +15,15 @@ import { Indexing } from '$lib/server/services/indexing'; const readMutation = (request: Request) => decodeJson(request, FileMutationSchema, 'File mutation is invalid'); -export const GET: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { params, request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const files = yield* Files; const tags = yield* Tags; const indexing = yield* Indexing; const config = yield* AppConfig; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); // File detail, tag list, and indexing status are independent D1 // reads; run them concurrently like the list and search routes. const [detail, tagList, semantic] = yield* Effect.all( @@ -60,16 +56,16 @@ export const GET: RequestHandler = ({ cookies, params, request, url }) => }); }) ); +}; export const PATCH: RequestHandler = async (event) => { - const { cookies, params, request, url } = event; + const { params, request, url } = event; const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const auth = yield* Auth; const files = yield* Files; const indexing = yield* Indexing; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const mutation = yield* readMutation(request); const result = mutation.action === 'visibility' @@ -122,7 +118,8 @@ export const PATCH: RequestHandler = async (event) => { Effect.gen(function* () { const indexing = yield* Indexing; yield* indexing.process(params.id); - }) + }), + event.locals.auth ) ); } @@ -133,7 +130,8 @@ export const PATCH: RequestHandler = async (event) => { Effect.gen(function* () { const files = yield* Files; yield* files.sweepPurges(1); - }) + }), + event.locals.auth ) ); } diff --git a/apps/web/src/routes/api/files/[id]/content/+server.ts b/apps/web/src/routes/api/files/[id]/content/+server.ts index ebbdcb4..13b3326 100644 --- a/apps/web/src/routes/api/files/[id]/content/+server.ts +++ b/apps/web/src/routes/api/files/[id]/content/+server.ts @@ -5,18 +5,18 @@ import { resolveFileContentLink } from '$lib/server/file-content-link'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeRequest } from '$lib/server/services/auth'; +import { requireAuth } from '$lib/server/request-auth'; const requestedVersion = (url: URL) => { const value = url.searchParams.get('v'); return value === null ? undefined : Number(value); }; -export const GET: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { params, request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); return contentLinkRedirectResponse( yield* resolveFileContentLink( params.id, @@ -26,3 +26,4 @@ export const GET: RequestHandler = ({ cookies, params, request, url }) => ); }) ); +}; diff --git a/apps/web/src/routes/api/files/[id]/link/+server.ts b/apps/web/src/routes/api/files/[id]/link/+server.ts index 5329d82..e6012c4 100644 --- a/apps/web/src/routes/api/files/[id]/link/+server.ts +++ b/apps/web/src/routes/api/files/[id]/link/+server.ts @@ -6,7 +6,7 @@ import { } from '$lib/server/file-content-link'; import { InvalidRequest } from '$lib/server/errors'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeRequest } from '$lib/server/services/auth'; +import { requireAuth } from '$lib/server/request-auth'; const requestedVersion = (url: URL) => { const value = url.searchParams.get('v'); @@ -21,11 +21,11 @@ const requestedVersion = (url: URL) => { return version; }; -export const GET: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { params, request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); return contentLinkJsonResponse( yield* resolveFileContentLink( params.id, @@ -45,3 +45,4 @@ export const GET: RequestHandler = ({ cookies, params, request, url }) => ); }) ); +}; diff --git a/apps/web/src/routes/api/files/[id]/preview/+server.ts b/apps/web/src/routes/api/files/[id]/preview/+server.ts index b8d8057..af9281a 100644 --- a/apps/web/src/routes/api/files/[id]/preview/+server.ts +++ b/apps/web/src/routes/api/files/[id]/preview/+server.ts @@ -2,8 +2,8 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { InvalidRequest } from '$lib/server/errors'; import { runEdge } from '$lib/server/edge'; +import { requireAuth } from '$lib/server/request-auth'; import { maxPreviewBytes, previewKind } from '$lib/server/file-preview'; -import { Auth, authorizeRequest } from '$lib/server/services/auth'; import { Blobs } from '$lib/server/services/blobs'; import { Files } from '$lib/server/services/files'; @@ -20,13 +20,13 @@ const requestedVersion = (url: URL) => { return version; }; -export const GET: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { params, request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const blobs = yield* Blobs; const files = yield* Files; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); const version = yield* Effect.try({ try: () => requestedVersion(url), catch: (cause) => @@ -66,3 +66,4 @@ export const GET: RequestHandler = ({ cookies, params, request, url }) => }); }) ); +}; diff --git a/apps/web/src/routes/api/files/[id]/tags/+server.ts b/apps/web/src/routes/api/files/[id]/tags/+server.ts index 6779057..8cf2e38 100644 --- a/apps/web/src/routes/api/files/[id]/tags/+server.ts +++ b/apps/web/src/routes/api/files/[id]/tags/+server.ts @@ -2,23 +2,24 @@ import { FileTagsUpdateSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { decodeJson } from '$lib/server/request-json'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; import { Files } from '$lib/server/services/files'; import { Tags } from '$lib/server/services/tags'; const readNames = (request: Request) => decodeJson(request, FileTagsUpdateSchema, 'File tags are invalid'); -export const PUT: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const PUT: RequestHandler = (event) => { + const { params, request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const tags = yield* Tags; const files = yield* Files; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const input = yield* readNames(request); yield* tags.setFileTags(params.id, input.names); return Response.json({ file: (yield* files.detail(params.id)).file }); }) ); +}; diff --git a/apps/web/src/routes/api/files/[id]/versions/+server.ts b/apps/web/src/routes/api/files/[id]/versions/+server.ts index c80afd6..d8188dd 100644 --- a/apps/web/src/routes/api/files/[id]/versions/+server.ts +++ b/apps/web/src/routes/api/files/[id]/versions/+server.ts @@ -2,25 +2,19 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { authRateLimitResponse } from '$lib/server/auth-rate-limit-response'; import { runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { requireWrite } from '$lib/server/request-auth'; import { AuthGuard } from '$lib/server/services/auth-guard'; import { Files } from '$lib/server/services/files'; import { Indexing } from '$lib/server/services/indexing'; export const PUT: RequestHandler = async (event) => { - const { cookies, params, request, url } = event; + const { params, request } = event; const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const auth = yield* Auth; const authGuard = yield* AuthGuard; const files = yield* Files; - const credential = yield* authorizeWriteRequest( - auth, - request, - url, - cookies - ); + const credential = yield* requireWrite(event); const rateLimit = yield* authGuard.consume( 'upload', credential.credentialId @@ -55,7 +49,8 @@ export const PUT: RequestHandler = async (event) => { Effect.gen(function* () { const indexing = yield* Indexing; yield* indexing.process(uploadedFileId); - }) + }), + event.locals.auth ) ); } diff --git a/apps/web/src/routes/api/internal/jobs/+server.ts b/apps/web/src/routes/api/internal/jobs/+server.ts index 48569f3..5b2bc64 100644 --- a/apps/web/src/routes/api/internal/jobs/+server.ts +++ b/apps/web/src/routes/api/internal/jobs/+server.ts @@ -39,7 +39,7 @@ export const POST: RequestHandler = ({ request }) => const authorized = yield* Effect.tryPromise({ try: () => verifyJobsRequest( - config.passcode, + config.maintenanceSecret, request.headers.get('x-adrive-jobs-time'), text, request.headers.get('x-adrive-jobs-signature') diff --git a/apps/web/src/routes/api/internal/maintenance/+server.ts b/apps/web/src/routes/api/internal/maintenance/+server.ts index d84a851..ee3b3d7 100644 --- a/apps/web/src/routes/api/internal/maintenance/+server.ts +++ b/apps/web/src/routes/api/internal/maintenance/+server.ts @@ -2,19 +2,23 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { AppConfig } from '$lib/server/config'; import { verifyScheduledRequest } from '$lib/server/cron-auth'; -import { runEdge } from '$lib/server/edge'; +import { runAcrossOrgs, runEdgeWithEvent } from '$lib/server/edge'; import { Unauthorized } from '$lib/server/errors'; -import { Lifecycle } from '$lib/server/services/lifecycle'; +import { Lifecycle, summarize } from '$lib/server/services/lifecycle'; -export const POST: RequestHandler = ({ request }) => - runEdge( +// The cron tick: one global pass (device codes), then one bounded pass +// per randomly chosen live org so no tenant starves another. +export const POST: RequestHandler = async (event) => { + const { request } = event; + const global = await runEdgeWithEvent( + event, Effect.gen(function* () { const config = yield* AppConfig; const lifecycle = yield* Lifecycle; const authorized = yield* Effect.tryPromise({ try: () => verifyScheduledRequest( - config.passcode, + config.maintenanceSecret, request.headers.get('x-adrive-scheduled-time'), request.headers.get('x-adrive-scheduled-cron'), request.headers.get('x-adrive-scheduled-signature') @@ -27,7 +31,23 @@ export const POST: RequestHandler = ({ request }) => message: 'Scheduled request is unauthorized' }); } - yield* lifecycle.run; - return new Response(null, { status: 204 }); + return yield* lifecycle.global; }) ); + if (!event.platform) return new Response(null, { status: 204 }); + const perOrg = await runAcrossOrgs( + event.platform.env, + Effect.flatMap(Lifecycle, (lifecycle) => lifecycle.org) + ); + console.log( + JSON.stringify({ + message: 'maintenance tick', + orgs: perOrg.length, + ...summarize( + global, + perOrg.map((entry) => entry.value) + ) + }) + ); + return new Response(null, { status: 204 }); +}; diff --git a/apps/web/src/routes/api/search/+server.ts b/apps/web/src/routes/api/search/+server.ts index 29b983e..952e8e8 100644 --- a/apps/web/src/routes/api/search/+server.ts +++ b/apps/web/src/routes/api/search/+server.ts @@ -2,20 +2,20 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeRequest } from '$lib/server/services/auth'; +import { requireAuth } from '$lib/server/request-auth'; import { Search } from '$lib/server/services/search'; import { Tags } from '$lib/server/services/tags'; import { Indexing } from '$lib/server/services/indexing'; -export const GET: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const search = yield* Search; const tags = yield* Tags; const indexing = yield* Indexing; const config = yield* AppConfig; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); const omitMeta = url.searchParams.get('omitMeta') === '1'; const searchInput = { query: url.searchParams.get('q') ?? '', @@ -45,3 +45,4 @@ export const GET: RequestHandler = ({ cookies, request, url }) => }); }) ); +}; diff --git a/apps/web/src/routes/api/sites/sessions/+server.ts b/apps/web/src/routes/api/sites/sessions/+server.ts index 4f0bb2f..1927080 100644 --- a/apps/web/src/routes/api/sites/sessions/+server.ts +++ b/apps/web/src/routes/api/sites/sessions/+server.ts @@ -5,9 +5,9 @@ import { import type { RequestHandler } from './$types'; import { Effect, Schema } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { InvalidRequest } from '$lib/server/errors'; import { readBoundedJson } from '$lib/server/request-json'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; import { Sites } from '$lib/server/services/sites'; const MAX_MANIFEST_BYTES = 1024 * 1024; @@ -32,13 +32,14 @@ const readManifest = (request: Request) => ); }); -export const POST: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const POST: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const sites = yield* Sites; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const input: SiteSessionCreate = yield* readManifest(request); return Response.json(yield* sites.createSession(input), { status: 201 }); }) ); +}; diff --git a/apps/web/src/routes/api/sites/sessions/[id]/+server.ts b/apps/web/src/routes/api/sites/sessions/[id]/+server.ts index 53a7dac..83439c4 100644 --- a/apps/web/src/routes/api/sites/sessions/[id]/+server.ts +++ b/apps/web/src/routes/api/sites/sessions/[id]/+server.ts @@ -1,16 +1,17 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { requireWrite } from '$lib/server/request-auth'; import { Sites } from '$lib/server/services/sites'; -export const DELETE: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const DELETE: RequestHandler = (event) => { + const { params, request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const sites = yield* Sites; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); yield* sites.abort(params.id); return new Response(null, { status: 204 }); }) ); +}; diff --git a/apps/web/src/routes/api/sites/sessions/[id]/assets/+server.ts b/apps/web/src/routes/api/sites/sessions/[id]/assets/+server.ts index cd100f5..cc60717 100644 --- a/apps/web/src/routes/api/sites/sessions/[id]/assets/+server.ts +++ b/apps/web/src/routes/api/sites/sessions/[id]/assets/+server.ts @@ -1,16 +1,16 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { InvalidRequest } from '$lib/server/errors'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; import { Sites } from '$lib/server/services/sites'; -export const PUT: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const PUT: RequestHandler = (event) => { + const { params, request, url } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const sites = yield* Sites; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const path = url.searchParams.get('path'); if (path === null) { return yield* new InvalidRequest({ @@ -29,3 +29,4 @@ export const PUT: RequestHandler = ({ cookies, params, request, url }) => ); }) ); +}; 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 6ecc6b4..07f4ca6 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 @@ -2,19 +2,18 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { AppConfig } from '$lib/server/config'; import { runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { requireWrite } from '$lib/server/request-auth'; import { Indexing } from '$lib/server/services/indexing'; import { Sites } from '$lib/server/services/sites'; export const POST: RequestHandler = async (event) => { - const { cookies, params, request, url } = event; + const { params, request, url } = event; const output = await runEdgeWithEvent( event, Effect.gen(function* () { - const auth = yield* Auth; const config = yield* AppConfig; const sites = yield* Sites; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const result = yield* sites.commit(params.id); return { fileId: result.file.id, @@ -35,7 +34,8 @@ export const POST: RequestHandler = async (event) => { Effect.gen(function* () { const indexing = yield* Indexing; yield* indexing.process(output.fileId); - }) + }), + event.locals.auth ) ); } diff --git a/apps/web/src/routes/api/tags/+server.ts b/apps/web/src/routes/api/tags/+server.ts index 088acfc..6831057 100644 --- a/apps/web/src/routes/api/tags/+server.ts +++ b/apps/web/src/routes/api/tags/+server.ts @@ -2,36 +2,34 @@ import { TagCreateSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireAuth, requireWrite } from '$lib/server/request-auth'; import { decodeJson } from '$lib/server/request-json'; -import { - Auth, - authorizeRequest, - authorizeWriteRequest -} from '$lib/server/services/auth'; import { Tags } from '$lib/server/services/tags'; const readCreate = (request: Request) => decodeJson(request, TagCreateSchema, 'Tag input is invalid'); -export const GET: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const GET: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const tags = yield* Tags; - yield* authorizeRequest(auth, request, url, cookies); + yield* requireAuth(event); return Response.json({ tags: yield* tags.list }); }) ); +}; -export const POST: RequestHandler = ({ cookies, request, url }) => - runEdge( +export const POST: RequestHandler = (event) => { + const { request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const tags = yield* Tags; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const tag = yield* readCreate(request).pipe( Effect.flatMap((input) => tags.create(input)) ); return Response.json({ tag }, { status: 201 }); }) ); +}; diff --git a/apps/web/src/routes/api/tags/[id]/+server.ts b/apps/web/src/routes/api/tags/[id]/+server.ts index ce989d0..b1b82fd 100644 --- a/apps/web/src/routes/api/tags/[id]/+server.ts +++ b/apps/web/src/routes/api/tags/[id]/+server.ts @@ -2,33 +2,35 @@ import { TagUpdateSchema } from '@adrive/shared'; import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; +import { requireWrite } from '$lib/server/request-auth'; import { decodeJson } from '$lib/server/request-json'; -import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; import { Tags } from '$lib/server/services/tags'; const readUpdate = (request: Request) => decodeJson(request, TagUpdateSchema, 'Tag update is invalid'); -export const PATCH: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const PATCH: RequestHandler = (event) => { + const { params, request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const tags = yield* Tags; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const tag = yield* readUpdate(request).pipe( Effect.flatMap((input) => tags.update(params.id, input)) ); return Response.json({ tag }); }) ); +}; -export const DELETE: RequestHandler = ({ cookies, params, request, url }) => - runEdge( +export const DELETE: RequestHandler = (event) => { + const { params, request } = event; + return runEdge( Effect.gen(function* () { - const auth = yield* Auth; const tags = yield* Tags; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); yield* tags.remove(params.id); return new Response(null, { status: 204 }); }) ); +}; diff --git a/apps/web/src/routes/api/webhooks/workos/+server.ts b/apps/web/src/routes/api/webhooks/workos/+server.ts new file mode 100644 index 0000000..5232fe4 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/workos/+server.ts @@ -0,0 +1,46 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { Unauthorized } from '$lib/server/errors'; +import { readBoundedText } from '$lib/server/request-json'; +import { Auth } from '$lib/server/services/auth'; +import { WorkOSClient } from '$lib/server/services/workos'; + +const MAX_PAYLOAD_BYTES = 256 * 1024; + +// WorkOS is the source of truth for accounts. Sign-in upserts everything +// it needs at callback time; the webhook only mirrors the two removals a +// callback can never observe. +export const POST: RequestHandler = ({ request }) => + runEdge( + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const auth = yield* Auth; + const payload = yield* readBoundedText(request, { + maxBytes: MAX_PAYLOAD_BYTES, + invalidLengthMessage: 'Webhook payload is too large', + invalidTextMessage: 'Webhook is invalid' + }); + const signature = request.headers.get('workos-signature'); + if (!signature) { + return yield* new Unauthorized({ + message: 'Webhook signature is missing' + }); + } + const event = yield* workos.constructEvent(payload, signature); + switch (event.event) { + case 'user.deleted': + yield* auth.removeUser(event.userId); + break; + case 'organization_membership.deleted': + yield* auth.removeMembership(event.orgId, event.userId); + break; + case 'ignored': + break; + } + return Response.json( + { ok: true as const }, + { headers: { 'Cache-Control': 'private, no-store' } } + ); + }) + ); diff --git a/apps/web/src/routes/auth/callback/+server.ts b/apps/web/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..552e50f --- /dev/null +++ b/apps/web/src/routes/auth/callback/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { deviceApprovalParams } from '$lib/device-approval'; +import { + SESSION_COOKIE, + STATE_COOKIE, + sessionCookieOptions +} from '$lib/server/auth-policy'; +import { runEdge } from '$lib/server/edge'; +import { InvalidRequest } from '$lib/server/errors'; +import { Auth } from '$lib/server/services/auth'; + +const redirectHome = (params: URLSearchParams) => + new Response(null, { + status: 302, + headers: { + 'Cache-Control': 'private, no-store', + Location: `/${params.size ? `?${params}` : ''}` + } + }); + +export const GET: RequestHandler = ({ cookies, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const state = url.searchParams.get('state'); + const pending = new URLSearchParams(cookies.get(STATE_COOKIE) ?? ''); + const expectedState = pending.get('state'); + cookies.delete(STATE_COOKIE, { path: '/' }); + if (!state || !expectedState || state !== expectedState) { + return yield* new InvalidRequest({ + status: 400, + message: 'Sign-in state did not match; start again' + }); + } + const code = url.searchParams.get('code'); + if (!code) { + return yield* new InvalidRequest({ + status: 400, + message: 'Sign-in did not return an authorization code' + }); + } + const { sealedSession } = yield* auth.completeSignIn(code); + cookies.set(SESSION_COOKIE, sealedSession, sessionCookieOptions); + return redirectHome(deviceApprovalParams(pending)); + }) + ); diff --git a/apps/web/src/routes/auth/sign-in/+server.ts b/apps/web/src/routes/auth/sign-in/+server.ts new file mode 100644 index 0000000..01cfd4c --- /dev/null +++ b/apps/web/src/routes/auth/sign-in/+server.ts @@ -0,0 +1,39 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { deviceApprovalParams } from '$lib/device-approval'; +import { STATE_COOKIE, stateCookieOptions } from '$lib/server/auth-policy'; +import { AppConfig } from '$lib/server/config'; +import { runEdge } from '$lib/server/edge'; +import { WorkOSClient } from '$lib/server/services/workos'; + +const randomState = () => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); +}; + +// Starts the AuthKit flow. The state travels in a short-lived cookie so +// the callback can tell this browser's sign-in from a forged redirect. +export const GET: RequestHandler = ({ cookies, url }) => + runEdge( + Effect.gen(function* () { + const config = yield* AppConfig; + const workos = yield* WorkOSClient; + const state = randomState(); + const pending = deviceApprovalParams(url.searchParams); + pending.set('state', state); + cookies.set(STATE_COOKIE, pending.toString(), stateCookieOptions); + return new Response(null, { + status: 302, + headers: { + 'Cache-Control': 'private, no-store', + Location: workos.authorizationUrl( + state, + `${config.dashboardOrigin}/auth/callback` + ) + } + }); + }) + ); diff --git a/apps/web/src/routes/auth/sign-out/+server.ts b/apps/web/src/routes/auth/sign-out/+server.ts new file mode 100644 index 0000000..e817339 --- /dev/null +++ b/apps/web/src/routes/auth/sign-out/+server.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { SESSION_COOKIE } from '$lib/server/auth-policy'; +import { AppConfig } from '$lib/server/config'; +import { runEdge } from '$lib/server/edge'; +import { InvalidRequest } from '$lib/server/errors'; +import { Auth } from '$lib/server/services/auth'; + +// A POST from the dashboard's own form, so a cross-site link cannot sign +// someone out. Clears the cookie, then hands off to WorkOS to end the +// session there and bounce back home. +export const POST: RequestHandler = ({ cookies, request, url }) => + runEdge( + Effect.gen(function* () { + if (request.headers.get('origin') !== url.origin) { + return yield* new InvalidRequest({ + status: 403, + message: 'The request origin is not allowed' + }); + } + const auth = yield* Auth; + const config = yield* AppConfig; + const location = yield* auth.logoutUrl( + cookies.get(SESSION_COOKIE), + `${config.dashboardOrigin}/` + ); + cookies.delete(SESSION_COOKIE, { path: '/' }); + return new Response(null, { + status: 303, + headers: { 'Cache-Control': 'private, no-store', Location: location } + }); + }) + ); diff --git a/apps/web/src/routes/f/[id]/+server.ts b/apps/web/src/routes/f/[id]/+server.ts index 6a41853..d1318bc 100644 --- a/apps/web/src/routes/f/[id]/+server.ts +++ b/apps/web/src/routes/f/[id]/+server.ts @@ -55,6 +55,7 @@ const serveFile: RequestHandler = ({ params, platform, request, url }) => const signature = url.searchParams.get('g') ?? ''; const granted = yield* grantSecrets.verify({ contentOrigin: config.contentOrigin, + orgId: content.orgId, requestOrigin: url.origin, fileId: params.id, version: content.file.version, diff --git a/apps/web/src/routes/s/[id]/[...path]/+server.ts b/apps/web/src/routes/s/[id]/[...path]/+server.ts index 055bf9b..73eba3c 100644 --- a/apps/web/src/routes/s/[id]/[...path]/+server.ts +++ b/apps/web/src/routes/s/[id]/[...path]/+server.ts @@ -62,10 +62,21 @@ const serveSite: RequestHandler = ({ params, request, url }) => const blobs = yield* Blobs; const files = yield* Files; const config = yield* AppConfig; + // The asset row names the owning org, which the grant is bound to, + // so it is resolved before the signature is checked. + const asset = yield* sites.findAsset( + params.id, + grant ? grant.assetPath : (params.path ?? ''), + { + includeUnavailable: hasGrant, + version: grant?.version + } + ); if (grant) { const grantSecrets = yield* GrantSecrets; const granted = yield* grantSecrets.verify({ contentOrigin: config.contentOrigin, + orgId: asset.orgId, requestOrigin: url.origin, fileId: params.id, version: grant.version, @@ -76,6 +87,7 @@ const serveSite: RequestHandler = ({ params, request, url }) => if (thumbnailSource) { const thumbnailGranted = yield* grantSecrets.verify({ contentOrigin: config.contentOrigin, + orgId: asset.orgId, requestOrigin: url.origin, fileId: params.id, version: grant.version, @@ -86,14 +98,6 @@ const serveSite: RequestHandler = ({ params, request, url }) => if (!thumbnailGranted) return yield* new NotFound({ id: params.id }); } } - const asset = yield* sites.findAsset( - params.id, - grant ? grant.assetPath : (params.path ?? ''), - { - includeUnavailable: hasGrant, - version: grant?.version - } - ); const cacheControl = siteCacheControl(hasGrant, asset.contentType); const ifNoneMatch = request.headers.get('if-none-match'); const headersFor = (etag: string, size: number) => ({ diff --git a/apps/web/src/routes/settings/+page.svelte b/apps/web/src/routes/settings/+page.svelte index 6c13fe0..ba080da 100644 --- a/apps/web/src/routes/settings/+page.svelte +++ b/apps/web/src/routes/settings/+page.svelte @@ -1,39 +1,17 @@ @@ -119,32 +97,5 @@
- -
-

Sessions

-

- Sign out every browser and cancel pending device approvals. API keys - keep working — revoke them above if one may have leaked. Changing the - PASSCODE secret also signs out every browser on the next maintenance - run. -

- -
{/if} - - 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 82a53e5..1a12e4f 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 @@ -109,6 +109,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => if (!content.file.public || hasGrant) { const granted = yield* grantSecrets.verify({ contentOrigin: config.contentOrigin, + orgId: content.orgId, requestOrigin: url.origin, fileId: params.id, version: content.file.version, @@ -167,6 +168,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => const sourceGrant = yield* grantSecrets.mint({ contentOrigin: config.contentOrigin, + orgId: content.orgId, fileId: params.id, version: content.file.version, purpose: 'thumbnail-source' @@ -175,6 +177,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => content.file.kind === 'site' ? yield* grantSecrets.mint({ contentOrigin: config.contentOrigin, + orgId: content.orgId, fileId: params.id, version: content.file.version }) @@ -244,6 +247,7 @@ export const GET: RequestHandler = ({ params, platform, request, url }) => }); const body = new Response(bytes).body; const result = yield* files.storeDashboardThumbnail( + content.orgId, params.id, content.file.version, body, diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index a2b6197..6e4dbe0 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: 6c913b5c836dfb6c0be90009a078b25d) +// Generated by Wrangler by running `wrangler types` (hash: a2a3fe7c1a00316d379b37e6a139d74d) // Runtime types generated with workerd@1.20260722.1 2026-07-27 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { AUTH_GUARD: KVNamespace; @@ -10,12 +10,15 @@ interface __BaseEnv_Env { AI?: Ai; ASSETS: Fetcher; MAX_UPLOAD_BYTES: "99614720"; - MAX_TOTAL_BYTES: "107374182400"; SEMANTIC_SEARCH: "required" | "auto"; EMBEDDING_MODEL: "@cf/baai/bge-small-en-v1.5"; EMBEDDING_POOLING: "cls"; EMBEDDING_DIMENSIONS: "384"; - PASSCODE: string; + MAINTENANCE_SECRET: string; + WORKOS_API_KEY: string; + WORKOS_CLIENT_ID: string; + WORKOS_COOKIE_PASSWORD: string; + WORKOS_WEBHOOK_SECRET: string; CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: string; DASHBOARD_ORIGIN: string; CONTENT_ORIGIN: string; @@ -30,12 +33,15 @@ declare namespace Cloudflare { AI: Ai; ASSETS: Fetcher; MAX_UPLOAD_BYTES: "99614720"; - MAX_TOTAL_BYTES: "107374182400"; SEMANTIC_SEARCH: "required"; EMBEDDING_MODEL: "@cf/baai/bge-small-en-v1.5"; EMBEDDING_POOLING: "cls"; EMBEDDING_DIMENSIONS: "384"; - PASSCODE: string; + MAINTENANCE_SECRET: string; + WORKOS_API_KEY: string; + WORKOS_CLIENT_ID: string; + WORKOS_COOKIE_PASSWORD: string; + WORKOS_WEBHOOK_SECRET: string; CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: string; DASHBOARD_ORIGIN: string; CONTENT_ORIGIN: string; @@ -47,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 d4c9827..91e1414 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -14,7 +14,6 @@ "DASHBOARD_ORIGIN": "http://localhost:5173", "CONTENT_ORIGIN": "http://localhost:5174", "MAX_UPLOAD_BYTES": "99614720", - "MAX_TOTAL_BYTES": "107374182400", "SEMANTIC_SEARCH": "auto", "EMBEDDING_MODEL": "@cf/baai/bge-small-en-v1.5", "EMBEDDING_POOLING": "cls", @@ -77,7 +76,9 @@ // 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). - // 5. `wrangler secret put PASSCODE --env production` + // 5. `wrangler secret put` MAINTENANCE_SECRET, WORKOS_API_KEY, + // WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_WEBHOOK_SECRET + // (all `--env production`) // 6. `DATABASE_URL=postgres://... bun db:pg:migrate` (see docs/release.md) "env": { "production": { @@ -93,7 +94,6 @@ "DASHBOARD_ORIGIN": "https://drive.davis7.space", "CONTENT_ORIGIN": "https://files.davis7.space", "MAX_UPLOAD_BYTES": "99614720", - "MAX_TOTAL_BYTES": "107374182400", // required (not auto) so a missing AI binding fails the deploy // loudly instead of silently degrading to keyword search. "SEMANTIC_SEARCH": "required", diff --git a/bun.lock b/bun.lock index 86eed9d..d3527f1 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "@types/node": "^26.1.1", "@types/pg": "^8.23.1", "@typescript/native": "npm:typescript@^7.0.2", + "@workos-inc/node": "10.13.0", "concurrently": "^10.0.4", "svelte": "5.56.8", "svelte-check": "4.7.4", @@ -479,6 +480,8 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@workos-inc/node": ["@workos-inc/node@10.13.0", "", {}, "sha512-xPtcziTjN1irPiZ4niTaulYvRMs6D+Fz5E54GGHVOxNi0PV2/Ws1g/cx3q9oMvMhnINkva5iZ2T5aS6yLzdCWA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 3e32956..040794e 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -35,7 +35,7 @@ Failures and suspicious shrinkage post to `ALERT_WEBHOOK_URL` from bucket — it cannot delete or overwrite anything upstream. - The Postgres dump uses a read-only database role (`DATABASE_URL` in `backup.env`). -- Neither the PASSCODE, session secrets, nor deploy-capable tokens exist +- Neither the Worker secrets (WorkOS, maintenance), session cookies, nor deploy-capable tokens exist on the backup host. `backup.env` is `chmod 600` and gitignored. ## Restore procedures @@ -93,7 +93,8 @@ a complete restore. `env.production`. 2. Restore Postgres from the latest dump (above). 3. Restore R2: `rclone sync ~/Backups/a-drive/r2-mirror adrive-r2-rw:` -4. Set the secret: `wrangler secret put PASSCODE --env production`. +4. Set the secrets: `wrangler secret put --env production` for + `MAINTENANCE_SECRET` and the four `WORKOS_*` values. 5. Deploy: `bun release` (or `wrangler deploy --env production`). 6. DNS: point `drive.davis7.space` and `files.davis7.space` at the new Worker (custom domains attach from the routes in wrangler.jsonc). diff --git a/docs/plans/hosted-product.md b/docs/plans/hosted-product.md index ffbf64d..cc31b6e 100644 --- a/docs/plans/hosted-product.md +++ b/docs/plans/hosted-product.md @@ -9,6 +9,7 @@ Decisions already made: - All search moves into Postgres: `tsvector` + `pg_trgm` for keyword, `pgvector` for semantic. Vectorize is removed. Workers AI still produces embeddings. - Public content is served from `.` on a throwaway domain, one wildcard route. - Postgres port lands first while still single-tenant on passcode auth, so parity can be checked on the real drive before tenancy. +- Hosted cutover uses a separate empty Postgres target. The tenancy layer does not upgrade a populated single-tenant database in place; retain that source until a verified migration with explicit owner/org mapping is available. The shipped importer supports D1 sources. - Stacked PRs via GitHub native `gh stack`. - Indexing and other background work moves to Cloudflare Queues. Cron shrinks to reconciliation sweeps. - Abuse at launch: hash lists + Cloudflare URL Scanner + trust levels + rate limit bindings + kill switch. ClamAV in Containers is a later phase. @@ -251,7 +252,7 @@ CREATE TABLE org_usage ( - Add `org_id text NOT NULL REFERENCES orgs(id)` to `files`, `tags`, `api_keys`, `device_codes`, `site_upload_sessions`. Child tables reach the org through their parent, but add `org_id` to `file_versions` and `file_chunks` too so the hot queries do not need a join. - Unique constraints gain the org: `tags (org_id, normalized_name)`, `api_keys (org_id, prefix)` if you want prefixes per org, though a global prefix is fine. -- Backfill: create one org for the existing drive, set `org_id` on every row, then add `NOT NULL`. +- Hosted bootstrap: apply tenancy to a separate empty database, create the owner's WorkOS identity, then import D1 metadata into that org. Populated single-tenant Postgres databases are guarded against this migration; an in-place backfill or Postgres-source import is separate work. Preserve the source database for a verified cutover. - Row level security as a safety net, policy keyed on a transaction-local setting. PlanetScale supports it and also advises against relying on it alone. Use it as the second layer, never the first: ```sql diff --git a/docs/release.md b/docs/release.md index 0ee0510..6904365 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,22 +1,56 @@ # Releases, deployment, and rollback +## Hosted bootstrap and existing data + +The hosted product starts with a **separate, empty Postgres database**. +Migration `0004_tenancy.sql` rejects any populated single-tenant database +before changing its tables. Keep the existing drive and its backups; +do not use `--reset` to get past that guard. + +The supported import below copies D1 metadata into the new hosted target +after the owner signs up with WorkOS. An existing single-tenant Postgres +drive needs a separate migration with explicit owner/org mapping; this +release does not provide that importer or an in-place backfill. Keep using +that drive until its migration is prepared and verified. A Postgres dump +alone restores the old schema, so it cannot replace the tenancy import. + +Use a separate Worker and temporary dashboard/content origins to verify +the hosted target before moving production routes. Keep source writes +paused from the final export through verification and cutover. Imports +retain R2 keys: for an isolated rehearsal copy the referenced objects to +the target bucket; using the source bucket allows hosted cleanup to affect +the old drive. Before cutover, verify imported counts, owner access, file +downloads, search, and cleanup ownership. Retain the source deployment and +database for rollback; a hosted Worker rollback does not migrate data back +into the old schema. + ## First-time setup (once) App Worker commands run from `apps/web`. Landing-site commands run from `apps/site` (no `--env`). `bun release` and the backup installer run from the repository root. -1. Create a PlanetScale Postgres 17+ database (region close to most users) - with `vector` 0.8 or newer and the `pg_trgm` extension available, then a Hyperdrive - config pointing at its direct port 5432 with caching disabled: +1. Create a new PlanetScale Postgres 17+ database (region close to most users) + with `vector` 0.8 or newer and the `pg_trgm` extension available. Set + `DATABASE_URL` to its migration-admin connection string and apply the + schema from `apps/web`: + + ``` + bun scripts/pg-migrate.mjs --url "$DATABASE_URL" + ``` + + Provision and verify the restricted runtime login under **Database roles** + below. Set `ADRIVE_RUNTIME_DATABASE_URL` to that login's connection string + for the same database, then create Hyperdrive on the direct port 5432 + with caching disabled: ``` - wrangler hyperdrive create adrive-production --connection-string="postgres://..." --caching-disabled + wrangler hyperdrive create adrive-production --connection-string="$ADRIVE_RUNTIME_DATABASE_URL" --caching-disabled ``` Paste the id into `wrangler.jsonc` `env.production.hyperdrive[0].id`. - Export the same connection string as `DATABASE_URL` when releasing; - `bun release` runs `apps/web/scripts/pg-migrate.mjs` against it. + Keep `DATABASE_URL` on the separate migration-admin credentials when + releasing; `bun release` applies migrations with that role. 2. From `apps/web`: `wrangler r2 bucket create adrive-production` 3. From `apps/web`: `wrangler kv namespace create AUTH_GUARD --env production` @@ -39,8 +73,12 @@ the repository root. the zone that owns `CONTENT_ORIGIN` (`davis7.space` for `files.davis7.space`), and enable transformations. Dashboard thumbnails require this zone-level setting. -7. From `apps/web`: `wrangler secret put PASSCODE --env production` - (12+ characters). +7. From `apps/web`, set each secret with `wrangler secret put --env production`: + `MAINTENANCE_SECRET` (12+ characters), `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`, + `WORKOS_COOKIE_PASSWORD` (32+ characters), `WORKOS_WEBHOOK_SECRET`. Point the + WorkOS redirect URI at `/auth/callback` and the webhook at + `/api/webhooks/workos` (events `user.deleted`, + `organization_membership.deleted`). 8. The `davis7.space` zone must be active in Cloudflare. Remove existing CNAME records for `drive.davis7.space`, `files.davis7.space`, and `adrive.davis7.space` before deployment; the custom-domain routes in @@ -62,6 +100,44 @@ Semantic search notes for the first deploy: with the database. Files whose embeddings are missing after a partial restore regenerate on reindex. +## Database roles + +Migrations run with the schema-owning administrator. The Worker connects +through Hyperdrive with a separate, non-owner login that is neither a +superuser nor allowed to bypass row-level security. Migration 0004 creates +the `adrive_app` permission role without login and grants it application +table access. If the provider prevents role creation and the migration +prints an insufficient-privilege notice, have the database administrator +create `adrive_app` and apply the grants at the end of that migration before +continuing. + +As the database administrator, provision a login using the provider's role +tools or equivalent SQL: + +```sql +CREATE ROLE adrive_runtime LOGIN INHERIT + NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; +GRANT adrive_app TO adrive_runtime; +``` + +Set its password through the provider or `\password adrive_runtime` in +`psql`. Connect using `ADRIVE_RUNTIME_DATABASE_URL` and verify: + +```sql +SELECT current_user, rolsuper, rolbypassrls, + pg_has_role(current_user, 'adrive_app', 'USAGE') AS inherits_app +FROM pg_roles WHERE rolname = current_user; + +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tableowner = current_user; +``` + +Require `rolsuper = false`, `rolbypassrls = false`, `inherits_app = true`, +and no owned application tables. Do not grant the migration-admin role to +this login. Tenant-pinned transactions enforce RLS; ordinary statements +still rely on their explicit tenant predicates. Role setup alone is not a +live tenant-isolation test. + ## Queues The `JOBS` binding provides one Cloudflare Queue per environment for @@ -104,12 +180,17 @@ storage, authentication, upload, routing, or migration change. ## Migration compatibility rule -Migrations apply before the new Worker deploys, and rollback re-runs the -previous Worker against the migrated schema. Therefore every migration +For later releases of an existing hosted instance, migrations apply before +the new Worker deploys, and rollback re-runs the previous hosted Worker +against the migrated schema. Therefore every subsequent migration must be backwards-compatible for at least one release: additive tables and columns (with defaults) only; never drop, rename, or repurpose a column until the release _after_ the last code that used it is gone. +The initial tenancy bootstrap replaces the old authentication schema and +adds mandatory tenant columns. It therefore runs only on the fresh target +described above. Do not apply it to the database serving an older Worker. + ## Rollback ### Worker @@ -148,9 +229,9 @@ radius: ### Secrets -`wrangler secret put PASSCODE --env production` any time. The scheduled -maintenance job detects the change and revokes all browser sessions and -pending device codes automatically (API keys stay). +Rotate `MAINTENANCE_SECRET` with `wrangler secret put` any time; only the +Worker's own cron and queue self-requests use it. Browser sessions live in +WorkOS: revoke them from the WorkOS dashboard (API keys stay). ## Deployment records @@ -160,15 +241,16 @@ visible via `wrangler deployments list --env production`. ## One-off move from D1 -The single existing instance moves its metadata from D1 to Postgres once. -R2 does not move. Sessions, device codes, and the passcode hash are not +The existing D1 instance copies its metadata into the separate hosted +Postgres target once. R2 keys are preserved; configure the verified target +bucket as described above. Sessions, device codes, and the passcode hash are not carried over; sign in again afterwards. Semantic vectors are not carried over either; every file is left `pending` and re-embeds through the indexing sweep. `wrangler d1 export` refuses databases that contain FTS5 virtual tables, so export one table at a time from a checkout that still has the D1 -binding (the commit before this one): +binding (a checkout from before the Postgres port): ``` cd apps/web @@ -180,19 +262,30 @@ for t in files file_versions tags file_tags site_assets api_keys \ done ``` -Then from this checkout: +First finish hosted bootstrap on the separate target and sign in once +with the real owner's WorkOS account. Read that account's user and personal +org IDs from WorkOS or the target's `users`, `memberships`, and `orgs` +tables. Confirm the mapping before copying the old owner's files and API +keys. The target should contain only those identity rows, with no uploaded +content or unrelated tenants. + +Then from this checkout, with `DATABASE_URL` pointing at the target using +migration-admin credentials: ``` cd apps/web -export DATABASE_URL=postgres://... bun scripts/pg-migrate.mjs --url "$DATABASE_URL" -bun scripts/d1-to-postgres.mjs --dump /tmp/adrive-d1 --url "$DATABASE_URL" +bun scripts/d1-to-postgres.mjs --dump /tmp/adrive-d1 --url "$DATABASE_URL" \ + --org org_WORKOS_OWNER_ORG --user user_WORKOS_OWNER \ + --email owner@example.com --slug owner-slug ``` The script prints per-table counts and the Postgres totals at the end. -Compare them with the row counts in the exports before flipping DNS. Run -it with `--wipe` to truncate and retry. Tested against the local D1 state -on 2026-09-09. +Compare them with the row counts in the exports before cutover. If a +rehearsal needs to restart, recreate only its disposable target and repeat +bootstrap/import. Never use `--wipe` against the source drive or a hosted +database containing other users' data. Local importer checks do not prove +the live WorkOS identity mapping or production cutover. All listed tables must be exported, including empty ones. Unfinished site uploads are not resumed: their stored assets enter the cleanup queue unless diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 17768d2..2699df2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -304,14 +304,6 @@ export const AuthCheckResponseSchema = Schema.Struct({ ok: Schema.Literal(true) }); -export const SessionsRevokedResponseSchema = Schema.Struct({ - revoked: Schema.Int -}); - -export const PasscodeLoginSchema = Schema.Struct({ - passcode: Schema.String -}); - export const ApiKeyScopeSchema = Schema.Literals(['read-only', 'read-write']); export type ApiKeyScope = typeof ApiKeyScopeSchema.Type; diff --git a/scripts/create-local-key.mjs b/scripts/create-local-key.mjs index fec421a..3f472cf 100644 --- a/scripts/create-local-key.mjs +++ b/scripts/create-local-key.mjs @@ -12,6 +12,10 @@ const Pg = require('pg'); const LOCAL_DATABASE_URL = 'postgres://adrive:adrive@127.0.0.1:5432/adrive'; +// Reuse the fake browser user's drive, or bootstrap one when the CLI runs first. +const ORG_ID = 'org_local'; +const USER_ID = 'user_local'; + const prefix = randomBytes(4).toString('hex'); const secret = randomBytes(32).toString('base64url'); const token = `adr_${prefix}_${secret}`; @@ -23,14 +27,58 @@ const client = new Pg.Client({ try { await client.connect(); + await client.query('BEGIN'); + // Match browser signup's lock, then read from a fresh statement snapshot. + await client.query('SELECT pg_advisory_xact_lock(1936287598, hashtext($1))', [ + USER_ID + ]); + const membership = await client.query( + `SELECT m.org_id FROM memberships m + JOIN orgs o ON o.id = m.org_id + WHERE m.user_id = $1 + ORDER BY o.created_at, o.id LIMIT 1`, + [USER_ID] + ); + const orgId = membership.rows[0]?.org_id ?? ORG_ID; + if (membership.rows.length === 0) { + await client.query( + `INSERT INTO orgs (id, slug, name) VALUES ($1, 'local', 'Local drive') + ON CONFLICT (id) DO NOTHING`, + [ORG_ID] + ); + await client.query( + `INSERT INTO users (id, email, email_verified) + VALUES ($1, 'local@adrive.invalid', true) ON CONFLICT (id) DO NOTHING`, + [USER_ID] + ); + await client.query( + `INSERT INTO memberships (org_id, user_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [ORG_ID, USER_ID] + ); + } + await client.query( + `INSERT INTO org_usage (org_id) VALUES ($1) ON CONFLICT DO NOTHING`, + [orgId] + ); await client.query( - `INSERT INTO api_keys (id, name, prefix, secret_hash, created_at) - VALUES ($1, $2, $3, $4, $5)`, - [randomUUID(), 'local development', prefix, hash, new Date().toISOString()] + `INSERT INTO api_keys (id, org_id, user_id, name, prefix, secret_hash, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + randomUUID(), + orgId, + USER_ID, + 'local development', + prefix, + hash, + new Date().toISOString() + ] ); + await client.query('COMMIT'); console.log('\nLocal API key (shown once):'); console.log(token); } catch (cause) { + await client.query('ROLLBACK').catch(() => {}); console.error(cause instanceof Error ? cause.message : String(cause)); process.exitCode = 1; } finally {