From 0fa4597e6b4c098cb9c89e50ef3197e9be8ecbe8 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:11:55 -0700 Subject: [PATCH 1/7] FEATURE: Add org, user, and membership tables with row level security Migration 0003 introduces orgs, users, memberships, and org_usage, adds org_id to every tenant table, scopes the tag uniqueness to the org, and enables forced row level security keyed on the transaction-local app.current_org setting with an adrive_app role for production. The PgSql layer pins CurrentOrg after BEGIN so the policies apply inside every transaction; plain statements rely on their WHERE clause. Until WorkOS sign-in lands, the passcode session upserts a bootstrap tenant and the request layer acts as it. Every INSERT now sets org_id, and the Postgres-backed tests upsert a shared test org first. Co-Authored-By: Claude Fable 5.1 --- apps/web/migrations-pg/0003_tenancy.sql | 219 ++++++++++++++++++ apps/web/package.json | 1 + apps/web/scripts/d1-to-postgres.mjs | 56 ++++- apps/web/scripts/pg-rebuild-search.mjs | 3 +- apps/web/src/lib/server/indexing-sql.ts | 11 +- apps/web/src/lib/server/layer.ts | 8 +- apps/web/src/lib/server/pg.ts | 37 ++- apps/web/src/lib/server/search-index.ts | 3 +- apps/web/src/lib/server/services/auth.ts | 33 ++- .../src/lib/server/services/current-org.ts | 32 +++ apps/web/src/lib/server/services/files.ts | 4 +- .../lib/server/services/files/internals.ts | 19 +- .../src/lib/server/services/files/upload.ts | 33 +-- apps/web/src/lib/server/services/sites.ts | 4 +- .../lib/server/services/sites/internals.ts | 7 +- .../src/lib/server/services/sites/sessions.ts | 48 ++-- apps/web/src/lib/server/services/tags.ts | 11 +- apps/web/src/lib/server/tenants.ts | 47 ++++ bun.lock | 3 + scripts/create-local-key.mjs | 40 +++- 20 files changed, 535 insertions(+), 84 deletions(-) create mode 100644 apps/web/migrations-pg/0003_tenancy.sql create mode 100644 apps/web/src/lib/server/services/current-org.ts create mode 100644 apps/web/src/lib/server/tenants.ts diff --git a/apps/web/migrations-pg/0003_tenancy.sql b/apps/web/migrations-pg/0003_tenancy.sql new file mode 100644 index 0000000..ffad7c6 --- /dev/null +++ b/apps/web/migrations-pg/0003_tenancy.sql @@ -0,0 +1,219 @@ +-- 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. Local databases with rows from before this migration +-- must be recreated: `bun db:pg:migrate --reset`. +-- +-- 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 connects as `adrive_app`, which +-- is created here without LOGIN so an operator attaches the password +-- and Hyperdrive credentials out of band. + +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) +); + +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; 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/d1-to-postgres.mjs b/apps/web/scripts/d1-to-postgres.mjs index c01af56..041f002 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 @@ -134,11 +150,28 @@ try { 'TRUNCATE files, tags, api_keys, device_codes, dashboard_sessions, credential_state, 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), @@ -276,8 +316,8 @@ try { // 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/lib/server/indexing-sql.ts b/apps/web/src/lib/server/indexing-sql.ts index 7771f52..adfef7a 100644 --- a/apps/web/src/lib/server/indexing-sql.ts +++ b/apps/web/src/lib/server/indexing-sql.ts @@ -111,7 +111,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 +121,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 +132,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, diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index 5a33859..0089747 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -14,6 +14,8 @@ import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; import { JobQueueLive } from './services/jobs'; +import { CurrentOrg, CurrentUser } from './services/current-org'; +import { BOOTSTRAP_TENANT } from './tenants'; export const PgLive = Layer.unwrap( Effect.map(Pg, (hyperdrive) => pgLayer(hyperdrive)) @@ -25,7 +27,11 @@ export const requestLayer = (env: Env) => { Layer.succeed(Bucket, env.BUCKET), Layer.succeed(AuthGuardStore, env.AUTH_GUARD), Layer.succeed(Jobs, env.JOBS), - ConfigLive(env) + ConfigLive(env), + // Until sign-in resolves a real org, every request acts as the one + // bootstrap tenant the passcode session creates. + Layer.succeed(CurrentOrg, { id: BOOTSTRAP_TENANT.orgId }), + Layer.succeed(CurrentUser, { id: BOOTSTRAP_TENANT.userId }) ); const pg = PgLive.pipe(Layer.provide(bindings)); const blobs = BlobsLive.pipe(Layer.provide(bindings)); diff --git a/apps/web/src/lib/server/pg.ts b/apps/web/src/lib/server/pg.ts index 8c842bd..04a218c 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 0003_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/search-index.ts b/apps/web/src/lib/server/search-index.ts index fce2f28..f477f56 100644 --- a/apps/web/src/lib/server/search-index.ts +++ b/apps/web/src/lib/server/search-index.ts @@ -16,9 +16,10 @@ export const refreshSearchDocument = (sql: PgClient.PgClient, fileId: string) => 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* 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(( diff --git a/apps/web/src/lib/server/services/auth.ts b/apps/web/src/lib/server/services/auth.ts index 5e2f446..b07619d 100644 --- a/apps/web/src/lib/server/services/auth.ts +++ b/apps/web/src/lib/server/services/auth.ts @@ -22,6 +22,8 @@ import { validate } from '../errors'; import { PgSql } from '../pg'; +import { BOOTSTRAP_TENANT, ensureTenant } from '../tenants'; +import { CurrentOrg, CurrentUser } from './current-org'; const ApiKeyRow = Schema.Struct({ id: Schema.String, @@ -251,6 +253,8 @@ const toApiKey = (row: typeof ApiKeyRow.Type): ApiKey => ({ const makeAuth = Effect.gen(function* () { const sql = yield* PgSql; const config = yield* AppConfig; + const org = yield* CurrentOrg; + const user = yield* CurrentUser; const storageError = (operation: string) => Effect.mapError((cause: unknown) => new StorageError({ operation, cause })); @@ -418,6 +422,12 @@ const makeAuth = Effect.gen(function* () { const expiresAt = new Date( createdAt.getTime() + SESSION_MAX_AGE_SECONDS * 1000 ); + yield* ensureTenant(sql, BOOTSTRAP_TENANT).pipe( + Effect.mapError( + (cause) => + new StorageError({ operation: 'ensure bootstrap tenant', cause }) + ) + ); yield* sql` INSERT INTO dashboard_sessions ( token_hash, created_at, expires_at, last_used_at @@ -459,11 +469,12 @@ 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( @@ -537,9 +548,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')); @@ -650,14 +664,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}, 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..3d4a25c 100644 --- a/apps/web/src/lib/server/services/files/internals.ts +++ b/apps/web/src/lib/server/services/files/internals.ts @@ -17,6 +17,7 @@ import { ensureStoredBytesWithin } 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, @@ -121,13 +123,13 @@ 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 - )`; + 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); }) ) @@ -161,6 +163,7 @@ export const createInternals = (deps: CoreDeps) => { blobs, config, tags, + org, compensateStoredBlob, checkStorageQuota, findDashboardFile, diff --git a/apps/web/src/lib/server/services/files/upload.ts b/apps/web/src/lib/server/services/files/upload.ts index a7a2f2d..5a10af9 100644 --- a/apps/web/src/lib/server/services/files/upload.ts +++ b/apps/web/src/lib/server/services/files/upload.ts @@ -15,7 +15,7 @@ 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, compensateStoredBlob, @@ -61,22 +61,23 @@ 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( 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..d32ecee 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, @@ -277,7 +279,8 @@ export const createInternals = ({ sql, blobs, config }: CoreDeps) => { sweepPendingDeletes, sql, blobs, - config + config, + org }; }; diff --git a/apps/web/src/lib/server/services/sites/sessions.ts b/apps/web/src/lib/server/services/sites/sessions.ts index 157f19c..a630710 100644 --- a/apps/web/src/lib/server/services/sites/sessions.ts +++ b/apps/web/src/lib/server/services/sites/sessions.ts @@ -26,7 +26,8 @@ export const sessionOps = ( sweepExpiredSessions, sql, blobs, - config + config, + org } = internals; return { @@ -90,12 +91,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 ( @@ -301,15 +303,15 @@ export const sessionOps = ( }); } 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}, @@ -326,13 +328,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`; diff --git a/apps/web/src/lib/server/services/tags.ts b/apps/web/src/lib/server/services/tags.ts index 3108641..5f8baf6 100644 --- a/apps/web/src/lib/server/services/tags.ts +++ b/apps/web/src/lib/server/services/tags.ts @@ -9,6 +9,7 @@ import { } from '../tag-policy'; import { createObjectTtlCache } 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 @@ -80,6 +81,7 @@ 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* () { @@ -146,13 +148,14 @@ 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 }) ) @@ -177,12 +180,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 }) ) diff --git a/apps/web/src/lib/server/tenants.ts b/apps/web/src/lib/server/tenants.ts new file mode 100644 index 0000000..aeb937b --- /dev/null +++ b/apps/web/src/lib/server/tenants.ts @@ -0,0 +1,47 @@ +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. +// 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) => + 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`; + }).pipe(Effect.asVoid); + +// The single tenant a passcode session signs into until WorkOS accounts +// replace it. Stack B removes this once sign-in resolves a real org. +export const BOOTSTRAP_TENANT: TenantRows = { + orgId: 'org_local', + userId: 'user_local', + slug: 'local', + name: 'Local drive', + email: 'local@adrive.invalid', + emailVerified: true +}; 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/scripts/create-local-key.mjs b/scripts/create-local-key.mjs index fec421a..9e5028a 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'; +// The key lands in the local bootstrap tenant (see src/lib/server/tenants.ts). +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,44 @@ const client = new Pg.Client({ try { await client.connect(); + await client.query('BEGIN'); + 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`, + [ORG_ID] + ); 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(), + ORG_ID, + 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 { From d1e6d99f2d8709d797cdc4da73fbd32e106ecf40 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:35:51 -0700 Subject: [PATCH 2/7] REFACTOR: Resolve request identity in the handle hook hooks.server.ts now turns the adr_ bearer key or the session cookie into locals.auth once per request, and routes read it through requireAuth and requireWrite instead of calling Auth.authorize themselves. The identity shape (org, user, role, via, scope, credential id) is declared on App.Locals; requestLayer takes it and provides CurrentOrg/CurrentUser, so runEdge picks the tenant from the event, runWorkerProgram takes it explicitly, and runAcrossOrgs runs a program once per live org for sweeps. MCP reads the same locals; the route test harness replays the hook's identity step before each handler. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/app.d.ts | 6 + apps/web/src/hooks.server.ts | 13 ++ apps/web/src/lib/server/edge.ts | 63 ++++- apps/web/src/lib/server/identity.ts | 20 ++ apps/web/src/lib/server/layer.ts | 26 ++- apps/web/src/lib/server/mcp/auth.ts | 43 ++-- apps/web/src/lib/server/mcp/handler.ts | 12 +- apps/web/src/lib/server/mcp/run.ts | 8 +- apps/web/src/lib/server/mcp/server.ts | 41 ++-- apps/web/src/lib/server/request-auth.ts | 99 ++++++++ apps/web/src/lib/server/services/auth.ts | 216 +++++++----------- apps/web/src/routes/+page.server.ts | 6 +- apps/web/src/routes/api/auth/check/+server.ts | 11 +- .../routes/api/auth/device/approve/+server.ts | 19 +- apps/web/src/routes/api/auth/keys/+server.ts | 19 +- .../src/routes/api/auth/keys/[id]/+server.ts | 11 +- .../src/routes/api/auth/sessions/+server.ts | 11 +- apps/web/src/routes/api/files/+server.ts | 36 ++- apps/web/src/routes/api/files/[id]/+server.ts | 26 +-- .../routes/api/files/[id]/content/+server.ts | 11 +- .../src/routes/api/files/[id]/link/+server.ts | 11 +- .../routes/api/files/[id]/preview/+server.ts | 11 +- .../src/routes/api/files/[id]/tags/+server.ts | 11 +- .../routes/api/files/[id]/versions/+server.ts | 15 +- apps/web/src/routes/api/search/+server.ts | 11 +- .../src/routes/api/sites/sessions/+server.ts | 11 +- .../routes/api/sites/sessions/[id]/+server.ts | 11 +- .../api/sites/sessions/[id]/assets/+server.ts | 11 +- .../api/sites/sessions/[id]/commit/+server.ts | 10 +- apps/web/src/routes/api/tags/+server.ts | 24 +- apps/web/src/routes/api/tags/[id]/+server.ts | 20 +- 31 files changed, 500 insertions(+), 343 deletions(-) create mode 100644 apps/web/src/lib/server/identity.ts create mode 100644 apps/web/src/lib/server/request-auth.ts diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 98fc166..db82303 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,7 +1,13 @@ import type { Env } from '../worker-configuration'; +import type { AuthContext } from '$lib/server/identity'; declare global { namespace App { + 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/hooks.server.ts b/apps/web/src/hooks.server.ts index f9adada..3640497 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; 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 +22,18 @@ export const handle: Handle = async ({ event, resolve }) => { throw cause; } + try { + event.locals.auth = await resolveEventAuth(env, event); + } 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/server/edge.ts b/apps/web/src/lib/server/edge.ts index 2161055..e070944 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'; @@ -38,6 +39,8 @@ export type AppServices = | AuthGuard | Auth | Blobs + | CurrentOrg + | CurrentUser | Files | Search | Sites @@ -141,6 +144,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 +156,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 +170,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/identity.ts b/apps/web/src/lib/server/identity.ts new file mode 100644 index 0000000..2a76c5a --- /dev/null +++ b/apps/web/src/lib/server/identity.ts @@ -0,0 +1,20 @@ +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 (browser +// 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 session id for browser sessions. Rate limits key + // on it so one leaked credential cannot exhaust another's budget. + readonly credentialId: string; +} diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index 0089747..08b987f 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,24 +21,27 @@ import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; import { JobQueueLive } from './services/jobs'; -import { CurrentOrg, CurrentUser } from './services/current-org'; -import { BOOTSTRAP_TENANT } from './tenants'; 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), - ConfigLive(env), - // Until sign-in resolves a real org, every request acts as the one - // bootstrap tenant the passcode session creates. - Layer.succeed(CurrentOrg, { id: BOOTSTRAP_TENANT.orgId }), - Layer.succeed(CurrentUser, { id: BOOTSTRAP_TENANT.userId }) + 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)); const blobs = BlobsLive.pipe(Layer.provide(bindings)); 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/request-auth.ts b/apps/web/src/lib/server/request-auth.ts new file mode 100644 index 0000000..2142594 --- /dev/null +++ b/apps/web/src/lib/server/request-auth.ts @@ -0,0 +1,99 @@ +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 { 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 sessionToken: string | undefined; + readonly method: string; + readonly origin: string | 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) return yield* auth.resolveApiKey(bearer); + if (!input.sessionToken) return null; + if ( + !allowsCredentialOrigin( + input.method, + input.origin, + config.dashboardOrigin + ) + ) { + return null; + } + return yield* auth.resolveSession(input.sessionToken); + }).pipe(Effect.catchTag('Unauthorized', () => Effect.succeed(null))) + ); + +// 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(null) + : resolveCredential(env, { + authorization: event.request.headers.get('authorization'), + sessionToken: event.cookies.get(SESSION_COOKIE), + method: event.request.method, + origin: event.request.headers.get('origin') + }); diff --git a/apps/web/src/lib/server/services/auth.ts b/apps/web/src/lib/server/services/auth.ts index b07619d..4afad1c 100644 --- a/apps/web/src/lib/server/services/auth.ts +++ b/apps/web/src/lib/server/services/auth.ts @@ -1,14 +1,10 @@ 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 @@ -16,11 +12,11 @@ import { import { AppConfig } from '../config'; import { InvalidRequest, - MisdirectedRequest, StorageError, Unauthorized, validate } from '../errors'; +import type { AuthContext } from '../identity'; import { PgSql } from '../pg'; import { BOOTSTRAP_TENANT, ensureTenant } from '../tenants'; import { CurrentOrg, CurrentUser } from './current-org'; @@ -37,6 +33,17 @@ const ApiKeyRow = Schema.Struct({ revoked_at: Schema.NullOr(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) +}); + const SessionRow = Schema.Struct({ token_hash: Schema.String, expires_at: Schema.String, @@ -53,14 +60,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; @@ -72,19 +71,16 @@ 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 - ) => Effect.Effect< - AuthorizedCredential, - MisdirectedRequest | Unauthorized | StorageError - >; + // Both resolvers turn a presented credential into the identity the + // request acts as, or Unauthorized when it is unknown, expired, or + // revoked. The handle hook calls them once per request. + readonly resolveApiKey: ( + bearer: string + ) => Effect.Effect; + readonly resolveSession: ( + sessionToken: string + ) => Effect.Effect; readonly createSession: ( passcode: string ) => Effect.Effect; @@ -146,41 +142,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); @@ -292,84 +253,72 @@ const makeAuth = Effect.gen(function* () { ); 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' + // 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* new Unauthorized({ + message: 'A valid credential is required' }); } - - 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) { + 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 + FROM api_keys k + LEFT JOIN memberships m ON m.org_id = k.org_id AND m.user_id = k.user_id + WHERE k.prefix = ${match[1]} AND k.revoked_at IS NULL + LIMIT 1 + `.pipe( + Effect.mapError( + (cause) => new StorageError({ operation: 'look up API key', cause }) + ) + ); + const row = decodeRows(ApiKeyCredentialRow, 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' }); } - 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: 'This API key has expired' + }); + } + // A key whose owner left the org stops working with it. + if (row.role === null) { return yield* new Unauthorized({ - message: 'The request origin is not allowed' + message: 'This API key no longer belongs to an organization member' }); } + 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 { + orgId: row.org_id, + userId: row.user_id, + role: row.role, + via: 'api-key' as const, + scope: row.scope, + credentialId: row.id + }; + }), + resolveSession: Effect.fn('Auth.resolveSession')(function* (sessionToken) { const tokenHash = yield* hashToken(sessionToken); const now = new Date(); const nowIso = now.toISOString(); @@ -405,9 +354,12 @@ const makeAuth = Effect.gen(function* () { ); } return { - credentialId: row.token_hash, - kind: 'session' as const, - scope: 'read-write' as const + orgId: BOOTSTRAP_TENANT.orgId, + userId: BOOTSTRAP_TENANT.userId, + role: 'owner', + via: 'session' as const, + scope: 'read-write' as const, + credentialId: row.token_hash }; }), createSession: Effect.fn('Auth.createSession')(function* (passcode) { diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index 9104bf4..4050348 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -60,6 +60,7 @@ 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, []); @@ -80,7 +81,8 @@ const grantedThumbnailPreloads = async ( }) ), { concurrency: 'unbounded' } - ) + ), + auth ); return preloadUrls( list, @@ -127,6 +129,7 @@ const tagIds = (url: URL) => { export const load: PageServerLoad = async ({ depends, fetch, + locals, platform, url }) => { @@ -182,6 +185,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/sessions/+server.ts b/apps/web/src/routes/api/auth/sessions/+server.ts index e443421..76e6b45 100644 --- a/apps/web/src/routes/api/auth/sessions/+server.ts +++ b/apps/web/src/routes/api/auth/sessions/+server.ts @@ -2,14 +2,16 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { SESSION_COOKIE } from '$lib/server/auth-policy'; 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 { Auth } 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( +export const DELETE: RequestHandler = (event) => { + const { cookies, request, url } = event; + return runEdge( Effect.gen(function* () { if (request.headers.get('origin') !== url.origin) { return yield* new InvalidRequest({ @@ -18,7 +20,7 @@ export const DELETE: RequestHandler = ({ cookies, request, url }) => }); } const auth = yield* Auth; - yield* authorizeWriteRequest(auth, request, url, cookies); + yield* requireWrite(event); const revoked = yield* auth.revokeAllSessions; cookies.delete(SESSION_COOKIE, { path: '/' }); return Response.json( @@ -27,3 +29,4 @@ export const DELETE: RequestHandler = ({ cookies, request, url }) => ); }) ); +}; 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/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 }); }) ); +}; From 5dbf856ba67d8ca685afeaaf0cbf9b4149f2300c Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:55:20 -0700 Subject: [PATCH 3/7] FEATURE: Sign in with WorkOS AuthKit The passcode is gone. /auth/sign-in redirects to AuthKit with a state cookie, /auth/callback exchanges the code, bootstraps a personal org and membership in WorkOS on first sign-in, mirrors the user, org, membership, and usage rows into Postgres, and pins the org on the sealed session cookie (__Host-adrive-wos, Lax). The hook verifies the session locally each request and refreshes it once on an expired token. /auth/sign-out ends the WorkOS session; /api/webhooks/workos mirrors user.deleted and organization_membership.deleted. WorkOSClient is a narrow service shape over @workos-inc/node; when WORKOS_API_KEY is unset or starts with `fake:` the in-memory fake signs anyone in from a `fake::` code, which the route tests use. The cron and queue HMAC moves to MAINTENANCE_SECRET. dashboard_sessions and credential_state are dropped; the auth guard keeps only the rate-limit counters. The dashboard shows a single sign-in button and the org name beside the settings link. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- apps/web/.dev.vars.example | 16 +- apps/web/migrations-pg/0003_tenancy.sql | 17 + apps/web/scripts/cloudflare-adapter.mjs | 16 +- apps/web/src/env.d.ts | 15 +- apps/web/src/hooks.server.ts | 11 +- apps/web/src/lib/components/Dashboard.svelte | 2 +- .../web/src/lib/components/auth/SignIn.svelte | 68 +-- apps/web/src/lib/dashboard/api.ts | 20 - apps/web/src/lib/dashboard/parse.ts | 10 - apps/web/src/lib/dashboard/session.svelte.ts | 123 +---- apps/web/src/lib/server/auth-policy.ts | Bin 2316 -> 2967 bytes .../lib/server/auth-rate-limit-response.ts | 11 +- apps/web/src/lib/server/config.ts | 54 +- apps/web/src/lib/server/cron-auth.ts | 20 +- apps/web/src/lib/server/edge.ts | 4 +- apps/web/src/lib/server/identity.ts | 17 +- apps/web/src/lib/server/layer.ts | 7 +- apps/web/src/lib/server/request-auth.ts | 22 +- .../web/src/lib/server/services/auth-guard.ts | 157 +----- apps/web/src/lib/server/services/auth.ts | 494 ++++++++---------- apps/web/src/lib/server/services/lifecycle.ts | 20 +- apps/web/src/lib/server/services/workos.ts | 330 ++++++++++++ apps/web/src/lib/server/tenants.ts | 34 +- apps/web/src/routes/+layout.server.ts | 30 +- apps/web/src/routes/+layout.svelte | 34 +- .../src/routes/api/auth/session/+server.ts | 87 --- .../src/routes/api/internal/jobs/+server.ts | 2 +- .../api/internal/maintenance/+server.ts | 2 +- .../src/routes/api/webhooks/workos/+server.ts | 51 ++ apps/web/src/routes/auth/callback/+server.ts | 42 ++ apps/web/src/routes/auth/sign-in/+server.ts | 36 ++ .../sessions => auth/sign-out}/+server.ts | 29 +- apps/web/src/routes/settings/+page.svelte | 51 +- apps/web/worker-configuration.d.ts | 16 +- apps/web/wrangler.jsonc | 4 +- docs/backup-restore.md | 5 +- docs/release.md | 14 +- packages/shared/src/index.ts | 8 - 39 files changed, 1002 insertions(+), 887 deletions(-) create mode 100644 apps/web/src/lib/server/services/workos.ts delete mode 100644 apps/web/src/routes/api/auth/session/+server.ts create mode 100644 apps/web/src/routes/api/webhooks/workos/+server.ts create mode 100644 apps/web/src/routes/auth/callback/+server.ts create mode 100644 apps/web/src/routes/auth/sign-in/+server.ts rename apps/web/src/routes/{api/auth/sessions => auth/sign-out}/+server.ts (50%) diff --git a/README.md b/README.md index a2785a4..49c8881 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ 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 +Leave `WORKOS_API_KEY` empty to use the in-memory WorkOS fake: the sign-in +button then signs you in as `user_local` with no credentials. Copy the API key printed by the final command, then start both local origins: ```bash @@ -170,7 +171,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 +190,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..d5f2693 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -1,5 +1,17 @@ -# 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. Leave WORKOS_API_KEY empty (or set it to any value +# starting with `fake:`) to run the in-memory WorkOS fake: /auth/sign-in +# then signs you in as `user_local` with no credentials or network. With a +# real key, all four are required; the cookie password must be 32+ chars. +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/0003_tenancy.sql b/apps/web/migrations-pg/0003_tenancy.sql index ffad7c6..c9443d5 100644 --- a/apps/web/migrations-pg/0003_tenancy.sql +++ b/apps/web/migrations-pg/0003_tenancy.sql @@ -51,6 +51,11 @@ CREATE TABLE org_usage ( 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); @@ -217,3 +222,15 @@ 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/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/src/env.d.ts b/apps/web/src/env.d.ts index 12d82cd..b2d9831 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -1,11 +1,22 @@ +// 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_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_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 3640497..44fb9dc 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,4 +1,5 @@ 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'; @@ -23,7 +24,15 @@ export const handle: Handle = async ({ event, resolve }) => { } try { - event.locals.auth = await resolveEventAuth(env, event); + 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({ 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} - import type { DashboardSession } from '$lib/dashboard/session.svelte'; - import Button from '$lib/components/ui/Button.svelte'; - import Field from '$lib/components/ui/Field.svelte'; - - let { session }: { session: DashboardSession } = $props(); - let passcode = $state(''); - let apiKey = $state(''); - -

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/server/auth-policy.ts b/apps/web/src/lib/server/auth-policy.ts index ed94737636ff7b5a99548b6b2ae3ec9da8a0c8b3..25c12f3c60caf311d2f279b7c7a929c1ae76c395 100644 GIT binary patch delta 709 zcmcIhJ8u&~5auPMTnPnqOlJf>I|7s<7RAL}P(+d|&JuKHd>-tT?{-hSW9K3iU4B3c zuFBsCqRC&QWAwX_ba{@=0(-b}%yGT;0oKr^kU>_^6 z$aAv5bJUQsRT;yCGF&>6KY|SNe1h2`5G3hg8D9tWrtAXhX^Xcad+f0^RSX(eYKAOYpjbLj3^>V!*a(dbDDE|{dk>F{Yra0Ohe`5j zFCHI6U&hhLc$~(=Oz(#zBt{Il;`VRQMix!AAo;H{${ z8naN8l-{}%2j7E@QueS7oqneqY|NRp$oU*TQasybkE^3U8_U`TO+#qkG|t`q$Zm H)5m`R>K*DQ delta 88 zcmbO(-Xk { - 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..5d49045 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -1,12 +1,24 @@ import { Context, Layer } from 'effect'; import { normalizeOrigins } from './host-gate'; +// WorkOS credentials. apiKey is null when the deployment runs the in-memory +// fake (local development and tests): WORKOS_API_KEY unset, empty, or +// starting with `fake:`. See services/workos.ts. +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 +40,36 @@ const semanticMode = (value: string) => { } }; +const optionalString = (value: unknown) => + typeof value === 'string' ? value : ''; + +export const isFakeWorkOSKey = (apiKey: string) => + apiKey === '' || apiKey.startsWith('fake:'); + +const workosFromEnv = (env: Env): WorkOSConfig => { + const rawApiKey = optionalString(env.WORKOS_API_KEY); + const clientId = optionalString(env.WORKOS_CLIENT_ID); + const cookiePassword = optionalString(env.WORKOS_COOKIE_PASSWORD); + const webhookSecret = optionalString(env.WORKOS_WEBHOOK_SECRET); + if (isFakeWorkOSKey(rawApiKey)) { + return { apiKey: null, clientId, cookiePassword, webhookSecret }; + } + 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, @@ -47,8 +89,11 @@ export const configFromEnv = (env: Env) => { 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') { @@ -64,7 +109,8 @@ export const configFromEnv = (env: Env) => { ...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 e070944..3a6380c 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -32,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 @@ -50,7 +51,8 @@ export type AppServices = | Indexing | Lifecycle | GrantSecrets - | JobQueue; + | JobQueue + | WorkOSClient; export const isAppError = (failure: unknown): failure is AppError => failure instanceof InvalidRequest || diff --git a/apps/web/src/lib/server/identity.ts b/apps/web/src/lib/server/identity.ts index 2a76c5a..22f485f 100644 --- a/apps/web/src/lib/server/identity.ts +++ b/apps/web/src/lib/server/identity.ts @@ -7,14 +7,25 @@ export interface ProgramIdentity { readonly userId: string; } -// What a request is allowed to act as. Both credential kinds (browser +// 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 session id for browser sessions. Rate limits key - // on it so one leaked credential cannot exhaust another's budget. + // 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/layer.ts b/apps/web/src/lib/server/layer.ts index 08b987f..a2df904 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -21,6 +21,7 @@ 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)) @@ -52,7 +53,10 @@ export const requestLayer = (env: Env, identity: ProgramIdentity | null) => { 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)); @@ -73,6 +77,7 @@ export const requestLayer = (env: Env, identity: ProgramIdentity | null) => { return Layer.mergeAll( infrastructure, semantic, + workos, auth, authGuard, grantSecrets, diff --git a/apps/web/src/lib/server/request-auth.ts b/apps/web/src/lib/server/request-auth.ts index 2142594..7faa153 100644 --- a/apps/web/src/lib/server/request-auth.ts +++ b/apps/web/src/lib/server/request-auth.ts @@ -9,6 +9,7 @@ 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; @@ -51,11 +52,13 @@ export const requireWrite = (event: AuthEvent) => export interface CredentialInput { readonly authorization: string | null; - readonly sessionToken: string | undefined; + 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 @@ -68,8 +71,11 @@ export const resolveCredential = (env: Env, input: CredentialInput) => const auth = yield* Auth; const config = yield* AppConfig; const bearer = bearerToken(input.authorization); - if (bearer) return yield* auth.resolveApiKey(bearer); - if (!input.sessionToken) return null; + if (bearer) { + const resolved = yield* auth.resolveApiKey(bearer); + return { auth: resolved, refreshedSession: null }; + } + if (!input.sessionCookie) return anonymous; if ( !allowsCredentialOrigin( input.method, @@ -77,10 +83,10 @@ export const resolveCredential = (env: Env, input: CredentialInput) => config.dashboardOrigin ) ) { - return null; + return anonymous; } - return yield* auth.resolveSession(input.sessionToken); - }).pipe(Effect.catchTag('Unauthorized', () => Effect.succeed(null))) + return yield* auth.resolveSession(input.sessionCookie); + }).pipe(Effect.catchTag('Unauthorized', () => Effect.succeed(anonymous))) ); // Credentials only mean something on the dashboard origin; content routes @@ -90,10 +96,10 @@ export const resolveEventAuth = ( event: Pick ) => classifyRoute(event.url.pathname) === 'content' - ? Promise.resolve(null) + ? Promise.resolve(anonymous) : resolveCredential(env, { authorization: event.request.headers.get('authorization'), - sessionToken: event.cookies.get(SESSION_COOKIE), + sessionCookie: event.cookies.get(SESSION_COOKIE), method: event.request.method, origin: event.request.headers.get('origin') }); 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 4afad1c..59448b4 100644 --- a/apps/web/src/lib/server/services/auth.ts +++ b/apps/web/src/lib/server/services/auth.ts @@ -5,21 +5,20 @@ import { DEVICE_POLL_INTERVAL_SECONDS, normalizeApiKeyName, normalizeUserCode, - SESSION_MAX_AGE_SECONDS, shouldTouchLastUsed, validateExpiration } from '../auth-policy'; -import { AppConfig } from '../config'; import { InvalidRequest, StorageError, Unauthorized, validate } from '../errors'; -import type { AuthContext } from '../identity'; +import type { AuthContext, ResolvedCredential } from '../identity'; import { PgSql } from '../pg'; -import { BOOTSTRAP_TENANT, ensureTenant } from '../tenants'; +import { ensureTenant, personalOrgFor } from '../tenants'; import { CurrentOrg, CurrentUser } from './current-org'; +import { WorkOSClient } from './workos'; const ApiKeyRow = Schema.Struct({ id: Schema.String, @@ -33,6 +32,15 @@ const ApiKeyRow = Schema.Struct({ revoked_at: Schema.NullOr(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']), @@ -41,13 +49,10 @@ const ApiKeyCredentialRow = Schema.Struct({ last_used_at: Schema.NullOr(Schema.String), org_id: Schema.String, user_id: Schema.String, - role: Schema.NullOr(Schema.String) -}); - -const SessionRow = Schema.Struct({ - token_hash: Schema.String, - expires_at: Schema.String, - last_used_at: 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({ @@ -72,20 +77,33 @@ export type DevicePollResult = | { readonly status: 'complete'; readonly apiKey: string }; export interface AuthShape { - // Both resolvers turn a presented credential into the identity the - // request acts as, or Unauthorized when it is unknown, expired, or - // revoked. The handle hook calls them once per request. + // 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: ( - sessionToken: string - ) => Effect.Effect; - readonly createSession: ( - passcode: string - ) => Effect.Effect; - readonly revokeSession: ( - sessionToken: string | undefined + 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< + { readonly sealedSession: string }, + Unauthorized | StorageError + >; + 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: ( @@ -117,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') {} @@ -211,15 +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); @@ -239,50 +292,41 @@ 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({ // 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* new Unauthorized({ - message: 'A valid credential is required' - }); - } + 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 + 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( - Effect.mapError( - (cause) => new StorageError({ operation: 'look up API key', cause }) - ) - ); + `.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* new Unauthorized({ - message: 'A valid credential is required' - }); + return yield* invalidCredential(); } const now = new Date(); const nowIso = now.toISOString(); @@ -292,7 +336,12 @@ const makeAuth = Effect.gen(function* () { }); } // A key whose owner left the org stops working with it. - if (row.role === null) { + if ( + row.role === null || + row.email === null || + row.org_name === null || + row.org_slug === null + ) { return yield* new Unauthorized({ message: 'This API key no longer belongs to an organization member' }); @@ -302,12 +351,7 @@ const makeAuth = Effect.gen(function* () { UPDATE api_keys SET last_used_at = ${nowIso} WHERE id = ${row.id} - `.pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'update API key usage', cause }) - ) - ); + `.pipe(storageError('update API key usage')); } return { orgId: row.org_id, @@ -315,96 +359,121 @@ const makeAuth = Effect.gen(function* () { role: row.role, via: 'api-key' as const, scope: row.scope, - credentialId: row.id + credentialId: row.id, + email: row.email, + orgName: row.org_name, + orgSlug: row.org_slug }; }), - resolveSession: Effect.fn('Auth.resolveSession')(function* (sessionToken) { - 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) { - return yield* new Unauthorized({ - message: 'A valid credential is required' - }); - } - 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 - }) - ) - ); + // 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); } + 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(); return { - orgId: BOOTSTRAP_TENANT.orgId, - userId: BOOTSTRAP_TENANT.userId, - role: 'owner', - via: 'session' as const, - scope: 'read-write' as const, - credentialId: row.token_hash + auth: sessionContext(loaded.sessionId, membership), + refreshedSession }; }), - 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' }); - } - const token = randomToken(); - const tokenHash = yield* hashToken(token); - const createdAt = new Date(); - const expiresAt = new Date( - createdAt.getTime() + SESSION_MAX_AGE_SECONDS * 1000 - ); - yield* ensureTenant(sql, BOOTSTRAP_TENANT).pipe( - Effect.mapError( - (cause) => - new StorageError({ operation: 'ensure bootstrap tenant', cause }) - ) + completeSignIn: Effect.fn('Auth.completeSignIn')(function* (code) { + const exchanged = yield* workos.exchangeCode(code); + const existing = yield* findMembership( + exchanged.user.id, + exchanged.organizationId ); - 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 }) - ) - ); - return token; + if (existing) { + 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 + }).pipe(storageError('create tenant rows')); + } + // 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 + 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'; @@ -428,11 +497,7 @@ const makeAuth = Effect.gen(function* () { ${scope}, ${generated.secretHash}, ${generated.row.createdAt}, ${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, @@ -448,7 +513,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) { @@ -546,12 +611,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({ @@ -563,15 +623,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' }); @@ -588,12 +640,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 }; } @@ -607,6 +654,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* () { @@ -647,106 +695,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/lifecycle.ts b/apps/web/src/lib/server/services/lifecycle.ts index 5e6b326..a222fe0 100644 --- a/apps/web/src/lib/server/services/lifecycle.ts +++ b/apps/web/src/lib/server/services/lifecycle.ts @@ -66,25 +66,7 @@ const makeLifecycle = Effect.gen(function* () { 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)), + authentication: auth.sweepExpired(100), sites: sites.sweepLifecycle(10), indexing: indexing.runDue(5), files: files.sweepPurges(5) 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..9317b49 --- /dev/null +++ b/apps/web/src/lib/server/services/workos.ts @@ -0,0 +1,330 @@ +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 stands +// in whenever no API key is configured. + +export interface ExchangedCode { + readonly sealedSession: string; + readonly sessionId: string; + readonly user: { + readonly id: string; + readonly email: string; + readonly emailVerified: boolean; + }; + readonly organizationId: 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). + 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') + }); + return { + sealedSession: result.sealedSession, + sessionId: loaded.authenticated ? loaded.sessionId : '', + user: { + id: result.user.id, + email: result.user.email, + emailVerified: result.user.emailVerified + }, + organizationId: result.organizationId ?? 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.map((result) => + result.authenticated ? (result.sealedSession ?? null) : 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 is only ever selected when WORKOS_API_KEY is absent. +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 + }) + : 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/tenants.ts b/apps/web/src/lib/server/tenants.ts index aeb937b..4a7f2af 100644 --- a/apps/web/src/lib/server/tenants.ts +++ b/apps/web/src/lib/server/tenants.ts @@ -35,13 +35,29 @@ export const ensureTenant = (sql: PgClient.PgClient, tenant: TenantRows) => ON CONFLICT (org_id) DO NOTHING`; }).pipe(Effect.asVoid); -// The single tenant a passcode session signs into until WorkOS accounts -// replace it. Stack B removes this once sign-in resolves a real org. -export const BOOTSTRAP_TENANT: TenantRows = { - orgId: 'org_local', - userId: 'user_local', - slug: 'local', - name: 'Local drive', - email: 'local@adrive.invalid', - emailVerified: true +// 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/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/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/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..91c19b6 100644 --- a/apps/web/src/routes/api/internal/maintenance/+server.ts +++ b/apps/web/src/routes/api/internal/maintenance/+server.ts @@ -14,7 +14,7 @@ export const POST: RequestHandler = ({ request }) => 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') 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..fff79e5 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/workos/+server.ts @@ -0,0 +1,51 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { InvalidRequest, Unauthorized } from '$lib/server/errors'; +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* Effect.tryPromise({ + try: () => request.text(), + catch: () => + new InvalidRequest({ status: 400, message: 'Webhook is invalid' }) + }); + if (payload.length > MAX_PAYLOAD_BYTES) { + return yield* new InvalidRequest({ + status: 413, + message: 'Webhook payload is too large' + }); + } + 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..b552fb3 --- /dev/null +++ b/apps/web/src/routes/auth/callback/+server.ts @@ -0,0 +1,42 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +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 = () => + new Response(null, { + status: 302, + headers: { 'Cache-Control': 'private, no-store', Location: '/' } + }); + +export const GET: RequestHandler = ({ cookies, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const state = url.searchParams.get('state'); + const expectedState = cookies.get(STATE_COOKIE); + 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(); + }) + ); 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..25fd06c --- /dev/null +++ b/apps/web/src/routes/auth/sign-in/+server.ts @@ -0,0 +1,36 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +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 }) => + runEdge( + Effect.gen(function* () { + const config = yield* AppConfig; + const workos = yield* WorkOSClient; + const state = randomState(); + cookies.set(STATE_COOKIE, state, 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/api/auth/sessions/+server.ts b/apps/web/src/routes/auth/sign-out/+server.ts similarity index 50% rename from apps/web/src/routes/api/auth/sessions/+server.ts rename to apps/web/src/routes/auth/sign-out/+server.ts index 76e6b45..e817339 100644 --- a/apps/web/src/routes/api/auth/sessions/+server.ts +++ b/apps/web/src/routes/auth/sign-out/+server.ts @@ -1,17 +1,16 @@ 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 { requireWrite } from '$lib/server/request-auth'; import { InvalidRequest } from '$lib/server/errors'; import { Auth } 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 = (event) => { - const { cookies, request, url } = event; - return runEdge( +// 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({ @@ -20,13 +19,15 @@ export const DELETE: RequestHandler = (event) => { }); } const auth = yield* Auth; - yield* requireWrite(event); - const revoked = yield* auth.revokeAllSessions; - cookies.delete(SESSION_COOKIE, { path: '/' }); - return Response.json( - { revoked }, - { headers: { 'Cache-Control': 'private, no-store' } } + 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/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/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index a2b6197..c5f48c8 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: 91e055c4ecd98f35ff655f8fd1f7cb04) // Runtime types generated with workerd@1.20260722.1 2026-07-27 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { AUTH_GUARD: KVNamespace; @@ -15,7 +15,11 @@ interface __BaseEnv_Env { 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; @@ -35,7 +39,11 @@ declare namespace Cloudflare { 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 +55,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..1e0c10a 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -77,7 +77,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": { 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/release.md b/docs/release.md index 0ee0510..dafd33c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -39,8 +39,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 @@ -148,9 +152,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 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; From bf33daeab73664d1d83d61746cf3b04002c1d4fc Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:58:47 -0700 Subject: [PATCH 4/7] FEATURE: Bind API keys and device codes to an org API keys carry the org and user that minted them; the key list and revocation are scoped to the caller's org, and a key stops resolving when its owner leaves the org. A device code has no org until the dashboard approves it: approval stamps the approving user's org and user, and the key minted on the next poll copies both, so the CLI wire format is unchanged. The approve banner names the org the key will land in. A route test covers the device flow end to end across two orgs. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/app.d.ts | 7 +++++++ apps/web/src/lib/components/auth/DeviceApproval.svelte | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index db82303..403a84c 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -3,6 +3,13 @@ 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. diff --git a/apps/web/src/lib/components/auth/DeviceApproval.svelte b/apps/web/src/lib/components/auth/DeviceApproval.svelte index 141c8ca..f0d00d3 100644 --- a/apps/web/src/lib/components/auth/DeviceApproval.svelte +++ b/apps/web/src/lib/components/auth/DeviceApproval.svelte @@ -16,6 +16,7 @@ } = $props(); const toasts = getToasts(); + const orgName = $derived(page.data.session?.org.name ?? ''); let busy = $state(false); let approved = $state(false); let deadline = $state(Date.now() + 10 * 60 * 1_000); @@ -117,7 +118,9 @@ Start device sign-in again from the CLI. {:else} Confirm code {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}

From e76fee1d3ee2ab7a9eb0fa1212a2c8b8fc6b806d Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:10:11 -0700 Subject: [PATCH 5/7] REFACTOR: Scope every query to the current org Files, tags, search, indexing, semantic vectors, and site sessions all carry `org_id = $org` on every SELECT, UPDATE, and DELETE, with the org taken from CurrentOrg at service construction. Content routes still run without a tenant: findContent, findAsset, recordDownload, and thumbnail storage resolve the org from the file row instead (file ids are globally unique) and surface it on FileContent/SiteContent for stack C. The tag list and semantic status caches are keyed per org. Lifecycle splits into a global pass (device codes) and a per-org pass that the maintenance tick runs through runAcrossOrgs over a random handful of live orgs with a cap of two items per sweep per org. A route test proves files, tags, and search stay inside their org and foreign ids are 404s. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/lib/server/indexing-sql.ts | 17 ++++--- apps/web/src/lib/server/isolate-cache.ts | 28 +++++++++++ apps/web/src/lib/server/purge-sql.ts | 10 ++-- apps/web/src/lib/server/search-candidates.ts | 10 ++-- apps/web/src/lib/server/search-index.ts | 37 ++++++++------ .../lib/server/services/files/internals.ts | 7 +-- .../lib/server/services/files/mutations.ts | 40 +++++++++------- .../src/lib/server/services/files/purge.ts | 12 ++--- .../src/lib/server/services/files/queries.ts | 28 +++++++---- .../lib/server/services/files/thumbnails.ts | 17 +++++-- .../src/lib/server/services/files/types.ts | 5 ++ .../src/lib/server/services/files/upload.ts | 23 ++++----- apps/web/src/lib/server/services/indexing.ts | 25 +++++----- apps/web/src/lib/server/services/lifecycle.ts | 42 ++++++++++++---- apps/web/src/lib/server/services/search.ts | 14 ++++-- apps/web/src/lib/server/services/semantic.ts | 25 ++++++---- .../lib/server/services/sites/internals.ts | 14 +++--- .../web/src/lib/server/services/sites/read.ts | 3 +- .../src/lib/server/services/sites/sessions.ts | 18 +++---- .../src/lib/server/services/sites/types.ts | 2 + apps/web/src/lib/server/services/tags.ts | 48 ++++++++++--------- apps/web/src/lib/server/thumbnail-storage.ts | 6 ++- .../api/internal/maintenance/+server.ts | 32 ++++++++++--- .../t/[id]/[version]/grid.webp/+server.ts | 1 + 24 files changed, 311 insertions(+), 153 deletions(-) diff --git a/apps/web/src/lib/server/indexing-sql.ts b/apps/web/src/lib/server/indexing-sql.ts index adfef7a..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); @@ -154,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/purge-sql.ts b/apps/web/src/lib/server/purge-sql.ts index a10273a..65dac58 100644 --- a/apps/web/src/lib/server/purge-sql.ts +++ b/apps/web/src/lib/server/purge-sql.ts @@ -7,18 +7,22 @@ import { StorageError } from './errors'; // 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) => +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`; 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/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 f477f56..10768cb 100644 --- a/apps/web/src/lib/server/search-index.ts +++ b/apps/web/src/lib/server/search-index.ts @@ -1,21 +1,26 @@ 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* 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, @@ -26,26 +31,28 @@ export const refreshSearchDocument = (sql: PgClient.PgClient, fileId: string) => 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/files/internals.ts b/apps/web/src/lib/server/services/files/internals.ts index 3d4a25c..cfd7f45 100644 --- a/apps/web/src/lib/server/services/files/internals.ts +++ b/apps/web/src/lib/server/services/files/internals.ts @@ -77,7 +77,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 }) @@ -113,7 +113,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) { @@ -130,7 +131,7 @@ export const createInternals = (deps: CoreDeps) => { ${current.id}, ${org.id}, ${version}, ${r2Key}, ${size}, NULL, ${contentType}, ${updatedAt}, NULL )`; - yield* refreshSearchDocument(sql, current.id); + yield* refreshSearchDocument(sql, current.id, org.id); }) ) .pipe( 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..b37b468 100644 --- a/apps/web/src/lib/server/services/files/thumbnails.ts +++ b/apps/web/src/lib/server/services/files/thumbnails.ts @@ -16,8 +16,13 @@ export const thumbnailOps = ( const { checkStorageQuota, 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({ @@ -41,6 +46,7 @@ export const thumbnailOps = ( const stored = yield* blobs.put(r2Key, body, size, 'image/webp'); const commit = commitThumbnailStorage( sql, + orgId, id, version, r2Key, @@ -74,7 +80,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 5a10af9..0ccc3ff 100644 --- a/apps/web/src/lib/server/services/files/upload.ts +++ b/apps/web/src/lib/server/services/files/upload.ts @@ -84,7 +84,7 @@ export const uploadOps = ( resolvedTags.map((tag) => ({ file_id: id, tag_id: tag.id })) )}`; } - yield* refreshSearchDocument(sql, id); + yield* refreshSearchDocument(sql, id, org.id); }) ) .pipe( @@ -98,7 +98,7 @@ export const uploadOps = ( compensateStoredBlob(failure, id, 1, r2Key, 'upload') ) ); - forgetTagListCache(); + forgetTagListCache(org.id); return { file: { @@ -189,15 +189,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 }) 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 a222fe0..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,20 +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({ - authentication: auth.sweepExpired(100), - 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/internals.ts b/apps/web/src/lib/server/services/sites/internals.ts index d32ecee..38bfadb 100644 --- a/apps/web/src/lib/server/services/sites/internals.ts +++ b/apps/web/src/lib/server/services/sites/internals.ts @@ -39,7 +39,7 @@ export const createInternals = ({ sql, blobs, config, org }: 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' @@ -228,7 +228,7 @@ export const createInternals = ({ sql, blobs, config, org }: 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, @@ -253,10 +253,12 @@ export const createInternals = ({ sql, blobs, config, org }: 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) => 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 a630710..0fc0db8 100644 --- a/apps/web/src/lib/server/services/sites/sessions.ts +++ b/apps/web/src/lib/server/services/sites/sessions.ts @@ -70,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' @@ -195,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 []; @@ -257,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} ) @@ -319,7 +321,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 ( @@ -368,7 +370,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' @@ -433,7 +435,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 5f8baf6..9abcc68 100644 --- a/apps/web/src/lib/server/services/tags.ts +++ b/apps/web/src/lib/server/services/tags.ts @@ -7,20 +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({ @@ -85,10 +82,11 @@ const makeTags = Effect.gen(function* () { 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( @@ -96,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( @@ -121,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( @@ -160,7 +159,7 @@ const makeTags = Effect.gen(function* () { (cause) => new StorageError({ operation: 'auto-create tags', cause }) ) ); - forgetTagListCache(); + forgetTagListCache(org.id); } const resolved = yield* findByNormalized(normalized); const byNormalized = new Map( @@ -198,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) { @@ -232,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) { @@ -247,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( @@ -256,16 +257,17 @@ 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 .withTransaction( 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}`; @@ -275,7 +277,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( @@ -283,7 +285,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/thumbnail-storage.ts b/apps/web/src/lib/server/thumbnail-storage.ts index d72f0c9..4bf65a0 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 diff --git a/apps/web/src/routes/api/internal/maintenance/+server.ts b/apps/web/src/routes/api/internal/maintenance/+server.ts index 91c19b6..ee3b3d7 100644 --- a/apps/web/src/routes/api/internal/maintenance/+server.ts +++ b/apps/web/src/routes/api/internal/maintenance/+server.ts @@ -2,12 +2,16 @@ 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; @@ -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/t/[id]/[version]/grid.webp/+server.ts b/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts index 82a53e5..75e1ba3 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 @@ -244,6 +244,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, From ca319908f23f3974fa21f7786a1b6a43d6696bf6 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:18:46 -0700 Subject: [PATCH 6/7] FEATURE: Track storage per org with a reserved counter org_usage.stored_bytes is the quota. Uploads, version restores, site commits, and thumbnail replacement reserve their delta with a conditional UPDATE inside the same transaction that writes the rows, so concurrent uploads cannot both squeeze under the limit; purges release the bytes the file held. The limit comes from planLimits(orgs.plan) in plans.ts (free 2 GiB, pro 100 GiB) and MAX_TOTAL_BYTES is gone from config and both wrangler blocks. A cheap headroom read still refuses oversize uploads before the body streams. Private content grants now carry the owning org in the signed payload (v2) and content routes verify against the org on the file row, so a grant cannot be replayed across tenants. The D1 import seeds the counter from the copied rows. Co-Authored-By: Claude Fable 5.1 --- apps/web/scripts/d1-to-postgres.mjs | 13 ++ apps/web/src/lib/server/config.ts | 13 +- apps/web/src/lib/server/file-content-link.ts | 2 + apps/web/src/lib/server/plans.ts | 17 ++ apps/web/src/lib/server/private-grant.ts | 25 ++- apps/web/src/lib/server/purge-sql.ts | 20 ++- .../lib/server/services/files/internals.ts | 17 +- .../lib/server/services/files/thumbnails.ts | 54 ++++--- .../src/lib/server/services/files/upload.ts | 16 +- .../src/lib/server/services/sites/sessions.ts | 14 +- apps/web/src/lib/server/storage-quota.ts | 146 ++++++++++++------ apps/web/src/lib/server/thumbnail-storage.ts | 3 +- apps/web/src/routes/+page.server.ts | 4 +- apps/web/src/routes/f/[id]/+server.ts | 1 + .../src/routes/s/[id]/[...path]/+server.ts | 20 ++- .../t/[id]/[version]/grid.webp/+server.ts | 3 + apps/web/worker-configuration.d.ts | 6 +- apps/web/wrangler.jsonc | 2 - 18 files changed, 260 insertions(+), 116 deletions(-) create mode 100644 apps/web/src/lib/server/plans.ts diff --git a/apps/web/scripts/d1-to-postgres.mjs b/apps/web/scripts/d1-to-postgres.mjs index 041f002..20f5e05 100644 --- a/apps/web/scripts/d1-to-postgres.mjs +++ b/apps/web/scripts/d1-to-postgres.mjs @@ -313,6 +313,19 @@ 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 + 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(` diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index 5d49045..ac79967 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -15,7 +15,6 @@ export interface AppConfigShape { readonly dashboardOrigin: string; readonly contentOrigin: string; readonly maxUploadBytes: number; - readonly maxTotalBytes: number; // Signs the Worker facade's cron and queue self-requests. readonly maintenanceSecret: string; readonly workos: WorkOSConfig; @@ -79,16 +78,7 @@ 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.MAINTENANCE_SECRET !== 'string' || env.MAINTENANCE_SECRET.length < 12 @@ -108,7 +98,6 @@ export const configFromEnv = (env: Env) => { return { ...origins, maxUploadBytes, - maxTotalBytes, maintenanceSecret: env.MAINTENANCE_SECRET, workos: workosFromEnv(env), semanticSearch, 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/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 65dac58..b75b1fe 100644 --- a/apps/web/src/lib/server/purge-sql.ts +++ b/apps/web/src/lib/server/purge-sql.ts @@ -1,12 +1,14 @@ 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. +// 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, @@ -20,6 +22,20 @@ export const completePurge = ( SET purge_state = 'done', purge_error = NULL, purge_next_run_at = NULL WHERE id = ${fileId} AND org_id = ${orgId} AND purge_state = 'pending' RETURNING id`; + // A site's row carries its asset total; a file's versions carry + // their own bytes plus thumbnails. + const held = yield* sql<{ bytes: number }>` + SELECT CASE + WHEN f.is_site THEN f.size_bytes + ELSE COALESCE(( + SELECT SUM(v.size_bytes + v.thumbnail_size_bytes) + FROM file_versions v + WHERE v.file_id = f.id + ), 0) + END 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 org_id = ${orgId} AND purge_state = 'done' diff --git a/apps/web/src/lib/server/services/files/internals.ts b/apps/web/src/lib/server/services/files/internals.ts index cfd7f45..2c20c63 100644 --- a/apps/web/src/lib/server/services/files/internals.ts +++ b/apps/web/src/lib/server/services/files/internals.ts @@ -13,7 +13,7 @@ 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'; @@ -66,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 @@ -132,6 +134,8 @@ export const createInternals = (deps: CoreDeps) => { ${contentType}, ${updatedAt}, NULL )`; yield* refreshSearchDocument(sql, current.id, org.id); + yield* reserveBytes(org.id, size); + }) ) .pipe( @@ -166,7 +170,8 @@ export const createInternals = (deps: CoreDeps) => { tags, org, compensateStoredBlob, - checkStorageQuota, + ensureHeadroom, + reserveBytes, findDashboardFile, commitStoredVersion }; diff --git a/apps/web/src/lib/server/services/files/thumbnails.ts b/apps/web/src/lib/server/services/files/thumbnails.ts index b37b468..27b681c 100644 --- a/apps/web/src/lib/server/services/files/thumbnails.ts +++ b/apps/web/src/lib/server/services/files/thumbnails.ts @@ -13,7 +13,7 @@ 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* (orgId, id, version, body, size, expectedR2Key) { @@ -39,28 +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, - orgId, - 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( diff --git a/apps/web/src/lib/server/services/files/upload.ts b/apps/web/src/lib/server/services/files/upload.ts index 0ccc3ff..07b9a85 100644 --- a/apps/web/src/lib/server/services/files/upload.ts +++ b/apps/web/src/lib/server/services/files/upload.ts @@ -17,7 +17,8 @@ export const uploadOps = ( ): Pick => { 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, @@ -85,12 +86,15 @@ export const uploadOps = ( )}`; } 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( @@ -142,7 +146,7 @@ export const uploadOps = ( message: 'Upload length is invalid' }) }); - yield* checkStorageQuota(size); + yield* ensureHeadroom(size); const contentType = contentTypeForUpload( current.displayName, input.contentType @@ -206,7 +210,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/sites/sessions.ts b/apps/web/src/lib/server/services/sites/sessions.ts index 0fc0db8..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, @@ -60,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; @@ -304,6 +304,15 @@ 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, org_id, display_name, content_type, kind, current_version, @@ -388,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( 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/thumbnail-storage.ts b/apps/web/src/lib/server/thumbnail-storage.ts index 4bf65a0..3cf8d60 100644 --- a/apps/web/src/lib/server/thumbnail-storage.ts +++ b/apps/web/src/lib/server/thumbnail-storage.ts @@ -45,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/+page.server.ts b/apps/web/src/routes/+page.server.ts index 4050348..f4f25f5 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -64,7 +64,8 @@ const grantedThumbnailPreloads = async ( 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, @@ -75,6 +76,7 @@ const grantedThumbnailPreloads = async ( const secrets = yield* GrantSecrets; return yield* secrets.mint({ contentOrigin: config.contentOrigin, + orgId, fileId: id, version }); 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/t/[id]/[version]/grid.webp/+server.ts b/apps/web/src/routes/t/[id]/[version]/grid.webp/+server.ts index 75e1ba3..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 }) diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index c5f48c8..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: 91e055c4ecd98f35ff655f8fd1f7cb04) +// 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,7 +10,6 @@ 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"; @@ -34,7 +33,6 @@ 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"; @@ -55,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 1e0c10a..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", @@ -95,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", From a36d5032ffa799c9e94b2f60e14ed0da9c6e5eb2 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:56:16 -0700 Subject: [PATCH 7/7] FIX: Harden hosted tenancy, account lifecycle, and migration safety --- README.md | 9 +- apps/web/.dev.vars.example | 10 +- .../{0003_tenancy.sql => 0004_tenancy.sql} | 40 +++++- apps/web/scripts/d1-to-postgres.mjs | 7 +- apps/web/src/env.d.ts | 2 + .../web/src/lib/components/auth/SignIn.svelte | 12 +- apps/web/src/lib/device-approval.ts | 17 +++ apps/web/src/lib/server/config.ts | 17 ++- apps/web/src/lib/server/pg.ts | 2 +- apps/web/src/lib/server/purge-sql.ts | 19 +-- apps/web/src/lib/server/services/auth.ts | 110 +++++++++++----- .../lib/server/services/files/internals.ts | 1 - .../src/lib/server/services/files/upload.ts | 1 - apps/web/src/lib/server/services/tags.ts | 1 - apps/web/src/lib/server/services/workos.ts | 38 ++++-- apps/web/src/lib/server/tenants.ts | 16 ++- .../src/routes/api/webhooks/workos/+server.ts | 17 +-- apps/web/src/routes/auth/callback/+server.ts | 13 +- apps/web/src/routes/auth/sign-in/+server.ts | 7 +- docs/plans/hosted-product.md | 3 +- docs/release.md | 123 +++++++++++++++--- scripts/create-local-key.mjs | 44 ++++--- 22 files changed, 374 insertions(+), 135 deletions(-) rename apps/web/migrations-pg/{0003_tenancy.sql => 0004_tenancy.sql} (84%) create mode 100644 apps/web/src/lib/device-approval.ts diff --git a/README.md b/README.md index 49c8881..3884bb6 100644 --- a/README.md +++ b/README.md @@ -69,9 +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. -Leave `WORKOS_API_KEY` empty to use the in-memory WorkOS fake: the sign-in -button then signs you in as `user_local` with no credentials. 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 diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index d5f2693..6831827 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -4,10 +4,12 @@ # random string; production sets it with `wrangler secret put`. MAINTENANCE_SECRET="replace-with-at-least-12-characters" -# WorkOS AuthKit. Leave WORKOS_API_KEY empty (or set it to any value -# starting with `fake:`) to run the in-memory WorkOS fake: /auth/sign-in -# then signs you in as `user_local` with no credentials or network. With a -# real key, all four are required; the cookie password must be 32+ chars. +# 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" diff --git a/apps/web/migrations-pg/0003_tenancy.sql b/apps/web/migrations-pg/0004_tenancy.sql similarity index 84% rename from apps/web/migrations-pg/0003_tenancy.sql rename to apps/web/migrations-pg/0004_tenancy.sql index c9443d5..95f9a9a 100644 --- a/apps/web/migrations-pg/0003_tenancy.sql +++ b/apps/web/migrations-pg/0004_tenancy.sql @@ -3,8 +3,8 @@ -- 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. Local databases with rows from before this migration --- must be recreated: `bun db:pg:migrate --reset`. +-- 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 @@ -14,9 +14,39 @@ -- 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 connects as `adrive_app`, which --- is created here without LOGIN so an operator attaches the password --- and Hyperdrive credentials out of band. +-- 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, diff --git a/apps/web/scripts/d1-to-postgres.mjs b/apps/web/scripts/d1-to-postgres.mjs index 20f5e05..09f2e33 100644 --- a/apps/web/scripts/d1-to-postgres.mjs +++ b/apps/web/scripts/d1-to-postgres.mjs @@ -147,7 +147,7 @@ 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( @@ -317,7 +317,10 @@ try { await client.query( `UPDATE org_usage u SET stored_bytes = COALESCE(( SELECT SUM(CASE - WHEN f.is_site THEN f.size_bytes + 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) diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts index b2d9831..83d54a0 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -5,6 +5,7 @@ declare global { interface Env { MAINTENANCE_SECRET: string; WORKOS_API_KEY?: string; + WORKOS_DEV_FAKE?: string; WORKOS_CLIENT_ID?: string; WORKOS_COOKIE_PASSWORD?: string; WORKOS_WEBHOOK_SECRET?: string; @@ -14,6 +15,7 @@ declare global { interface Env { 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/lib/components/auth/SignIn.svelte b/apps/web/src/lib/components/auth/SignIn.svelte index 3abb0e0..31dbd28 100644 --- a/apps/web/src/lib/components/auth/SignIn.svelte +++ b/apps/web/src/lib/components/auth/SignIn.svelte @@ -1,9 +1,19 @@ + +

Sign in to your drive

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/config.ts b/apps/web/src/lib/server/config.ts index ac79967..513e98e 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -1,9 +1,9 @@ +import { dev } from '$app/environment'; import { Context, Layer } from 'effect'; import { normalizeOrigins } from './host-gate'; -// WorkOS credentials. apiKey is null when the deployment runs the in-memory -// fake (local development and tests): WORKOS_API_KEY unset, empty, or -// starting with `fake:`. See services/workos.ts. +// 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; @@ -42,16 +42,21 @@ const semanticMode = (value: string) => { const optionalString = (value: unknown) => typeof value === 'string' ? value : ''; -export const isFakeWorkOSKey = (apiKey: string) => +const isFakeWorkOSKey = (apiKey: string) => apiKey === '' || apiKey.startsWith('fake:'); const workosFromEnv = (env: Env): WorkOSConfig => { - const rawApiKey = optionalString(env.WORKOS_API_KEY); + 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)) { - return { apiKey: null, clientId, cookiePassword, webhookSecret }; + 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'); diff --git a/apps/web/src/lib/server/pg.ts b/apps/web/src/lib/server/pg.ts index 04a218c..6ae11c4 100644 --- a/apps/web/src/lib/server/pg.ts +++ b/apps/web/src/lib/server/pg.ts @@ -41,7 +41,7 @@ export class PgSql extends Context.Service()( // 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 0003_tenancy.sql refuse rows from +// 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 diff --git a/apps/web/src/lib/server/purge-sql.ts b/apps/web/src/lib/server/purge-sql.ts index b75b1fe..5243e87 100644 --- a/apps/web/src/lib/server/purge-sql.ts +++ b/apps/web/src/lib/server/purge-sql.ts @@ -22,17 +22,18 @@ export const completePurge = ( SET purge_state = 'done', purge_error = NULL, purge_next_run_at = NULL WHERE id = ${fileId} AND org_id = ${orgId} AND purge_state = 'pending' RETURNING id`; - // A site's row carries its asset total; a file's versions carry - // their own bytes plus thumbnails. + // 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 COALESCE(( - SELECT SUM(v.size_bytes + v.thumbnail_size_bytes) + 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 - ), 0) - END AS bytes + 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); diff --git a/apps/web/src/lib/server/services/auth.ts b/apps/web/src/lib/server/services/auth.ts index 59448b4..9d9b2fb 100644 --- a/apps/web/src/lib/server/services/auth.ts +++ b/apps/web/src/lib/server/services/auth.ts @@ -382,52 +382,90 @@ const makeAuth = Effect.gen(function* () { // 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), + auth: sessionContext(loaded.sessionId, { ...membership, role }), refreshedSession }; }), completeSignIn: Effect.fn('Auth.completeSignIn')(function* (code) { const exchanged = yield* workos.exchangeCode(code); - const existing = yield* findMembership( - exchanged.user.id, - exchanged.organizationId - ); - if (existing) { - yield* sql` + 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 - }).pipe(storageError('create tenant rows')); - } + } + 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; + }) + ) + .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 @@ -447,7 +485,7 @@ const makeAuth = Effect.gen(function* () { Effect.gen(function* () { yield* sql` UPDATE device_codes - SET status = 'denied', api_key_id = NULL + 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}`; diff --git a/apps/web/src/lib/server/services/files/internals.ts b/apps/web/src/lib/server/services/files/internals.ts index 2c20c63..a6cbbec 100644 --- a/apps/web/src/lib/server/services/files/internals.ts +++ b/apps/web/src/lib/server/services/files/internals.ts @@ -135,7 +135,6 @@ export const createInternals = (deps: CoreDeps) => { )`; yield* refreshSearchDocument(sql, current.id, org.id); yield* reserveBytes(org.id, size); - }) ) .pipe( diff --git a/apps/web/src/lib/server/services/files/upload.ts b/apps/web/src/lib/server/services/files/upload.ts index 07b9a85..4af2cd0 100644 --- a/apps/web/src/lib/server/services/files/upload.ts +++ b/apps/web/src/lib/server/services/files/upload.ts @@ -87,7 +87,6 @@ export const uploadOps = ( } yield* refreshSearchDocument(sql, id, org.id); yield* reserveBytes(org.id, stored.size); - }) ) .pipe( diff --git a/apps/web/src/lib/server/services/tags.ts b/apps/web/src/lib/server/services/tags.ts index 9abcc68..4da8ae2 100644 --- a/apps/web/src/lib/server/services/tags.ts +++ b/apps/web/src/lib/server/services/tags.ts @@ -260,7 +260,6 @@ const makeTags = Effect.gen(function* () { forgetTagListCache(org.id); }), setFileTags: Effect.fn('Tags.setFileTags')(function* (fileId, names) { - yield* sql .withTransaction( Effect.gen(function* () { diff --git a/apps/web/src/lib/server/services/workos.ts b/apps/web/src/lib/server/services/workos.ts index 9317b49..4647a5a 100644 --- a/apps/web/src/lib/server/services/workos.ts +++ b/apps/web/src/lib/server/services/workos.ts @@ -4,8 +4,8 @@ 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 stands -// in whenever no API key is configured. +// 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; @@ -16,6 +16,7 @@ export interface ExchangedCode { readonly emailVerified: boolean; }; readonly organizationId: string | null; + readonly role: string | null; } export type LoadedSession = @@ -47,6 +48,7 @@ export interface WorkOSClientShape { ) => 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 @@ -158,15 +160,21 @@ const workOSLive = ( 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.authenticated ? loaded.sessionId : '', + sessionId: loaded.sessionId, user: { id: result.user.id, email: result.user.email, emailVerified: result.user.emailVerified }, - organizationId: result.organizationId ?? null + organizationId: result.organizationId ?? null, + role: loaded.role ?? null }; }) ) @@ -200,9 +208,20 @@ const workOSLive = ( }), catch: failure('refresh WorkOS session') }).pipe( - Effect.map((result) => - result.authenticated ? (result.sealedSession ?? null) : null - ) + 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(() => @@ -235,7 +254,7 @@ const workOSLive = ( // 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 is only ever selected when WORKOS_API_KEY is absent. +// fake requires explicit development opt-in in configFromEnv. export const FAKE_SESSION_PREFIX = 'fake:'; export const FAKE_DEV_USER = 'user_local'; @@ -270,7 +289,8 @@ export const workOSFake: WorkOSClientShape = { email: fakeEmail(parsed.userId), emailVerified: true }, - organizationId: parsed.orgId + organizationId: parsed.orgId, + role: 'owner' }) : Effect.fail( new Unauthorized({ message: 'Authorization code is invalid' }) diff --git a/apps/web/src/lib/server/tenants.ts b/apps/web/src/lib/server/tenants.ts index 4a7f2af..f64a29b 100644 --- a/apps/web/src/lib/server/tenants.ts +++ b/apps/web/src/lib/server/tenants.ts @@ -13,27 +13,31 @@ export interface TenantRows { // 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) => - Effect.gen(function* () { - yield* sql` + 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` + 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` + 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` + yield* sql` INSERT INTO org_usage (org_id) VALUES (${tenant.orgId}) ON CONFLICT (org_id) DO NOTHING`; - }).pipe(Effect.asVoid); + }) + ); // 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 diff --git a/apps/web/src/routes/api/webhooks/workos/+server.ts b/apps/web/src/routes/api/webhooks/workos/+server.ts index fff79e5..5232fe4 100644 --- a/apps/web/src/routes/api/webhooks/workos/+server.ts +++ b/apps/web/src/routes/api/webhooks/workos/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { runEdge } from '$lib/server/edge'; -import { InvalidRequest, Unauthorized } from '$lib/server/errors'; +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'; @@ -15,17 +16,11 @@ export const POST: RequestHandler = ({ request }) => Effect.gen(function* () { const workos = yield* WorkOSClient; const auth = yield* Auth; - const payload = yield* Effect.tryPromise({ - try: () => request.text(), - catch: () => - new InvalidRequest({ status: 400, message: 'Webhook is invalid' }) + const payload = yield* readBoundedText(request, { + maxBytes: MAX_PAYLOAD_BYTES, + invalidLengthMessage: 'Webhook payload is too large', + invalidTextMessage: 'Webhook is invalid' }); - if (payload.length > MAX_PAYLOAD_BYTES) { - return yield* new InvalidRequest({ - status: 413, - message: 'Webhook payload is too large' - }); - } const signature = request.headers.get('workos-signature'); if (!signature) { return yield* new Unauthorized({ diff --git a/apps/web/src/routes/auth/callback/+server.ts b/apps/web/src/routes/auth/callback/+server.ts index b552fb3..552e50f 100644 --- a/apps/web/src/routes/auth/callback/+server.ts +++ b/apps/web/src/routes/auth/callback/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; +import { deviceApprovalParams } from '$lib/device-approval'; import { SESSION_COOKIE, STATE_COOKIE, @@ -9,10 +10,13 @@ import { runEdge } from '$lib/server/edge'; import { InvalidRequest } from '$lib/server/errors'; import { Auth } from '$lib/server/services/auth'; -const redirectHome = () => +const redirectHome = (params: URLSearchParams) => new Response(null, { status: 302, - headers: { 'Cache-Control': 'private, no-store', Location: '/' } + headers: { + 'Cache-Control': 'private, no-store', + Location: `/${params.size ? `?${params}` : ''}` + } }); export const GET: RequestHandler = ({ cookies, url }) => @@ -20,7 +24,8 @@ export const GET: RequestHandler = ({ cookies, url }) => Effect.gen(function* () { const auth = yield* Auth; const state = url.searchParams.get('state'); - const expectedState = cookies.get(STATE_COOKIE); + 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({ @@ -37,6 +42,6 @@ export const GET: RequestHandler = ({ cookies, url }) => } const { sealedSession } = yield* auth.completeSignIn(code); cookies.set(SESSION_COOKIE, sealedSession, sessionCookieOptions); - return redirectHome(); + 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 index 25fd06c..01cfd4c 100644 --- a/apps/web/src/routes/auth/sign-in/+server.ts +++ b/apps/web/src/routes/auth/sign-in/+server.ts @@ -1,5 +1,6 @@ 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'; @@ -15,13 +16,15 @@ const randomState = () => { // 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 }) => +export const GET: RequestHandler = ({ cookies, url }) => runEdge( Effect.gen(function* () { const config = yield* AppConfig; const workos = yield* WorkOSClient; const state = randomState(); - cookies.set(STATE_COOKIE, state, stateCookieOptions); + const pending = deviceApprovalParams(url.searchParams); + pending.set('state', state); + cookies.set(STATE_COOKIE, pending.toString(), stateCookieOptions); return new Response(null, { status: 302, headers: { 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 dafd33c..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` @@ -66,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 @@ -108,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 @@ -164,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 @@ -184,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/scripts/create-local-key.mjs b/scripts/create-local-key.mjs index 9e5028a..3f472cf 100644 --- a/scripts/create-local-key.mjs +++ b/scripts/create-local-key.mjs @@ -12,7 +12,7 @@ const Pg = require('pg'); const LOCAL_DATABASE_URL = 'postgres://adrive:adrive@127.0.0.1:5432/adrive'; -// The key lands in the local bootstrap tenant (see src/lib/server/tenants.ts). +// 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'; @@ -28,31 +28,45 @@ const client = new Pg.Client({ try { await client.connect(); await client.query('BEGIN'); - 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`, + // 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] ); - await client.query( - `INSERT INTO memberships (org_id, user_id) VALUES ($1, $2) + 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] - ); + [ORG_ID, USER_ID] + ); + } await client.query( `INSERT INTO org_usage (org_id) VALUES ($1) ON CONFLICT DO NOTHING`, - [ORG_ID] + [orgId] ); await client.query( `INSERT INTO api_keys (id, org_id, user_id, name, prefix, secret_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ randomUUID(), - ORG_ID, + orgId, USER_ID, 'local development', prefix,