diff --git a/.changeset/sdk-media-type-apikey.md b/.changeset/sdk-media-type-apikey.md new file mode 100644 index 00000000..ae30270a --- /dev/null +++ b/.changeset/sdk-media-type-apikey.md @@ -0,0 +1,5 @@ +--- +"medialit": patch +--- + +The `Media` type no longer has an `apikey` field. The API never returned it, and keys are now stored only as hashes. diff --git a/.env.example b/.env.example index 33ee2b06..2dc34ba1 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ NODE_ENV=development # The first user, who signs in with a code sent to this address. EMAIL=admin@example.com +# Optional. An API key created for that user's default app at startup, for +# services that use MediaLit before anyone opens the dashboard. Leave empty to +# create keys in the dashboard. Generate with: +# echo ml_live_$(openssl rand -hex 32) +MEDIALIT_BOOTSTRAP_API_KEY= # Production: generate with `openssl rand -base64 48`. BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min diff --git a/README.md b/README.md index 89fa140f..652a5852 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ bun --filter @medialit/web dev # Dashboard on http://localhost:3000 bun run dev:docs # Docs on http://localhost:3008 ``` -On its first start, the API creates a user for `EMAIL` and a default app, and logs the app's API key (`"msg":"Admin user created"`). Keep it private: anyone with it can upload to your instance. Sign in to the dashboard with `EMAIL`; the code arrives in Mailpit at . The MinIO console is at (`medialit` / `medialit-secret`). +On its first start, the API creates a user for `EMAIL` and a default app. Sign in to the dashboard with `EMAIL`; the code arrives in Mailpit at . Create an API key in the app's settings, or set `MEDIALIT_BOOTSTRAP_API_KEY` in `apps/api/.env` to have the API create one at startup. The MinIO console is at (`medialit` / `medialit-secret`). If you run the dashboard on another port, set `WEB_ORIGIN` in `apps/api/.env` to match, or sign-in redirects fail. diff --git a/apps/api/.env.example b/apps/api/.env.example index 7ba07e55..a26475c3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -10,6 +10,11 @@ WEB_ORIGIN=http://localhost:3000 # AUTH_COOKIE_DOMAIN= BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min EMAIL=admin@example.com +# Optional. An API key the API creates for EMAIL's default app at startup, so +# another service can use MediaLit without anyone opening the dashboard. At +# least 43 characters from [A-Za-z0-9_-]. Generate with: +# echo ml_live_$(openssl rand -hex 32) +# MEDIALIT_BOOTSTRAP_API_KEY= TEMP_FILE_DIR_FOR_UPLOADS=/tmp/medialit # MinIO. The S3 clients use path-style requests against CLOUD_ENDPOINT. diff --git a/apps/api/__tests__/mcp/media-tool.test.ts b/apps/api/__tests__/mcp/media-tool.test.ts index 6d69c1a2..fedf017d 100644 --- a/apps/api/__tests__/mcp/media-tool.test.ts +++ b/apps/api/__tests__/mcp/media-tool.test.ts @@ -43,7 +43,7 @@ describe("MCP get_total_storage", () => { authKind: "apikey", userId: user._id, user, - apikey: "test-api-key", + appId: "test-app", }, }, }, diff --git a/apps/api/__tests__/mcp/upload-tool.test.ts b/apps/api/__tests__/mcp/upload-tool.test.ts index 86b8f43e..325b0fa4 100644 --- a/apps/api/__tests__/mcp/upload-tool.test.ts +++ b/apps/api/__tests__/mcp/upload-tool.test.ts @@ -19,7 +19,7 @@ function authInfo(user: any) { authKind: "apikey", userId: user.id, user, - apikey: "test-api-key", + appId: "test-app", }, }, }; diff --git a/apps/api/__tests__/mcp/whoami-tool.test.ts b/apps/api/__tests__/mcp/whoami-tool.test.ts index 22f1a05d..266629ed 100644 --- a/apps/api/__tests__/mcp/whoami-tool.test.ts +++ b/apps/api/__tests__/mcp/whoami-tool.test.ts @@ -31,7 +31,7 @@ const user = { function extra(authKind: "oauth" | "apikey") { return { authInfo: { - extra: { authKind, userId: user._id, user, apikey: "app-secret" }, + extra: { authKind, userId: user._id, user, appId: "internal-app" }, }, }; } @@ -43,24 +43,25 @@ describe("MCP whoami", () => { mockPaidPlan(); const queried: unknown[] = []; const response = await handleWhoamiTool({}, extra("oauth"), { - getApiKey: async (key: string) => { - queried.push(key); + getApp: async (id: string) => { + queried.push(id); return { - key, - keyId: "app-1", + id, + appId: "app-1", name: "My Store", default: true, + deleted: false, userId: user._id, } as any; }, - getMediaCount: async ({ userId, apikey }: any) => { - queried.push([userId, apikey]); + getMediaCount: async ({ userId, appId }: any) => { + queried.push([userId, appId]); return 3; }, getTotalSpace: async () => 2048, }); - assert.deepEqual(queried, ["app-secret", ["user-1", "app-secret"]]); + assert.deepEqual(queried, ["internal-app", ["user-1", "internal-app"]]); assert.deepEqual((response as any).structuredContent, { auth: "oauth", email: "hi@example.com", @@ -69,7 +70,8 @@ describe("MCP whoami", () => { storage: 2048, maxStorage: maxStorageAllowedSubscribed, }); - assert.doesNotMatch(response.content[0].text, /app-secret/); + // The app is named by its public ID, never the internal one. + assert.doesNotMatch(response.content[0].text, /internal-app/); }); test("rejects calls without auth", async () => { diff --git a/apps/api/__tests__/media/utils/get-public-urls.test.ts b/apps/api/__tests__/media/utils/get-public-urls.test.ts index dfdb4164..e03ff1e2 100644 --- a/apps/api/__tests__/media/utils/get-public-urls.test.ts +++ b/apps/api/__tests__/media/utils/get-public-urls.test.ts @@ -14,7 +14,7 @@ const clearModuleCache = () => { const createMockMedia = (overrides: Partial = {}): Media => ({ fileName: "main.jpg", mediaId: "test-media-id-123", - apikey: "test-apikey", + appId: "test-app", originalFileName: "original.jpg", mimeType: "image/jpeg", size: 1024, diff --git a/apps/api/__tests__/tus/tus-server.test.ts b/apps/api/__tests__/tus/tus-server.test.ts index 3c4323f2..005c40c6 100644 --- a/apps/api/__tests__/tus/tus-server.test.ts +++ b/apps/api/__tests__/tus/tus-server.test.ts @@ -15,7 +15,7 @@ import { function req(user: any) { return { user, - apikey: "test-api-key", + appId: "test-app", group: "default", }; } diff --git a/apps/api/drizzle/0008_api_keys.sql b/apps/api/drizzle/0008_api_keys.sql new file mode 100644 index 00000000..84bc23dd --- /dev/null +++ b/apps/api/drizzle/0008_api_keys.sql @@ -0,0 +1,191 @@ +-- Hashed, revocable API keys (docs/prds/apikey.md §5). +-- +-- The old `api_keys` table was both the app (tenant) and its single plaintext +-- key, and every tenant table stored that plaintext key. This migration: +-- 1. renames `api_keys` to `apps` (the tenant), keeping its IDs; +-- 2. creates a new `api_keys` table holding only SHA-256 hashes; +-- 3. turns each app's plaintext key into a hashed `legacy` key, so existing +-- integrations keep working; +-- 4. re-keys media, media settings, signatures and TUS uploads to `apps.id`; +-- 5. drops the plaintext key and the never-enforced restriction columns. +-- The migrator runs it inside one transaction, so any failure leaves the +-- database untouched. + +ALTER TABLE "api_keys" RENAME TO "apps"; +--> statement-breakpoint +ALTER TABLE "apps" RENAME COLUMN "key_id" TO "app_id"; +--> statement-breakpoint +ALTER TABLE "oauth_app_selections" RENAME COLUMN "api_key_id" TO "app_id"; +--> statement-breakpoint +-- Renaming a table keeps its constraint and index names. 0001 created them +-- implicitly, so look them up by what they constrain and give them the names +-- Drizzle expects; otherwise the new `api_keys` table would collide with them. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT c.conname, c.contype, a.attname + FROM pg_constraint c + LEFT JOIN pg_attribute a + ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1] + WHERE c.conrelid = 'public.apps'::regclass + LOOP + IF r.contype = 'p' AND r.conname <> 'apps_pkey' THEN + EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_pkey'); + ELSIF r.contype = 'u' AND r.attname = 'app_id' AND r.conname <> 'apps_app_id_unique' THEN + EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_app_id_unique'); + ELSIF r.contype = 'u' AND r.attname = 'key' THEN + EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_key_unique'); + ELSIF r.contype = 'f' AND r.attname = 'user_id' AND r.conname <> 'apps_user_id_user_id_fk' THEN + EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_user_id_user_id_fk'); + -- PostgreSQL 18+ records NOT NULL as named constraints too. + ELSIF r.contype = 'n' AND r.conname <> 'apps_' || r.attname || '_not_null' THEN + EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_' || r.attname || '_not_null'); + END IF; + END LOOP; + + FOR r IN + SELECT c.conname + FROM pg_constraint c + WHERE c.conrelid = 'public.oauth_app_selections'::regclass + AND c.contype = 'f' + AND c.confrelid = 'public.apps'::regclass + AND c.conname <> 'oauth_app_selections_app_id_apps_id_fk' + LOOP + EXECUTE format('ALTER TABLE "oauth_app_selections" RENAME CONSTRAINT %I TO %I', r.conname, 'oauth_app_selections_app_id_apps_id_fk'); + END LOOP; +END $$; +--> statement-breakpoint +ALTER INDEX IF EXISTS "api_keys_user_name_uidx" RENAME TO "apps_user_name_uidx"; +--> statement-breakpoint +ALTER INDEX IF EXISTS "api_keys_one_default_uidx" RENAME TO "apps_one_default_uidx"; +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "api_key_id" text NOT NULL, + "app_id" text NOT NULL, + "name" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text NOT NULL, + "expires_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_by_type" text NOT NULL, + "created_by_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_keys_api_key_id_unique" UNIQUE("api_key_id"), + CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash"), + CONSTRAINT "api_keys_created_by_type_check" CHECK ("api_keys"."created_by_type" IN ('user', 'bootstrap', 'legacy')) +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "api_keys_app_id_idx" ON "api_keys" USING btree ("app_id"); +--> statement-breakpoint +-- Every existing plaintext key becomes a hashed `legacy` key on its app. Lookup +-- hashes whatever the client sends, so these keep authenticating unchanged. +-- Keys of deleted apps arrive already revoked. +INSERT INTO "api_keys" ( + "id", "api_key_id", "app_id", "name", "key_hash", "key_prefix", + "revoked_at", "created_by_type", "created_at" +) +SELECT + gen_random_uuid()::text, + 'ak_' || replace(gen_random_uuid()::text, '-', ''), + "id", + 'Default key', + encode(sha256(convert_to("key", 'UTF8')), 'hex'), + left("key", 4), + CASE WHEN "deleted" THEN now() ELSE NULL END, + 'legacy', + "created_at" +FROM "apps"; +--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "app_id" text; +--> statement-breakpoint +UPDATE "media" AS m SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = m."apikey"; +--> statement-breakpoint +ALTER TABLE "media_settings" ADD COLUMN "app_id" text; +--> statement-breakpoint +UPDATE "media_settings" AS s SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = s."apikey"; +--> statement-breakpoint +-- Never drop media or settings silently: a row whose key matches no app needs +-- a person to look at it. +DO $$ +DECLARE + orphans bigint; +BEGIN + SELECT count(*) INTO orphans FROM "media" WHERE "app_id" IS NULL; + IF orphans > 0 THEN + RAISE EXCEPTION '% media rows reference an API key that matches no app (media.apikey not found in api_keys.key). Fix or delete them, then upgrade again.', orphans; + END IF; + SELECT count(*) INTO orphans FROM "media_settings" WHERE "app_id" IS NULL; + IF orphans > 0 THEN + RAISE EXCEPTION '% media_settings rows reference an API key that matches no app (media_settings.apikey not found in api_keys.key). Fix or delete them, then upgrade again.', orphans; + END IF; +END $$; +--> statement-breakpoint +-- Signatures last a day and TUS upload records a few days. An orphaned one +-- could never be used, so delete it rather than block the upgrade. +ALTER TABLE "signatures" ADD COLUMN "app_id" text; +--> statement-breakpoint +ALTER TABLE "signatures" ADD COLUMN "api_key_id" text; +--> statement-breakpoint +UPDATE "signatures" AS s SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = s."apikey"; +--> statement-breakpoint +DELETE FROM "signatures" WHERE "app_id" IS NULL; +--> statement-breakpoint +-- The old schema can't tell key-minted signatures from dashboard ones, so tie +-- them all to the app's legacy key: revoking it then revokes them too. +UPDATE "signatures" AS s SET "api_key_id" = k."id" FROM "api_keys" AS k WHERE k."app_id" = s."app_id" AND k."created_by_type" = 'legacy'; +--> statement-breakpoint +ALTER TABLE "tus_uploads" ADD COLUMN "app_id" text; +--> statement-breakpoint +UPDATE "tus_uploads" AS t SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = t."apikey"; +--> statement-breakpoint +DELETE FROM "tus_uploads" WHERE "app_id" IS NULL; +--> statement-breakpoint +DROP INDEX IF EXISTS "media_owner_idx"; +--> statement-breakpoint +ALTER TABLE "media" DROP COLUMN "apikey"; +--> statement-breakpoint +ALTER TABLE "media_settings" DROP COLUMN "apikey"; +--> statement-breakpoint +ALTER TABLE "signatures" DROP COLUMN "apikey"; +--> statement-breakpoint +ALTER TABLE "tus_uploads" DROP COLUMN "apikey"; +--> statement-breakpoint +ALTER TABLE "media" ALTER COLUMN "app_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "media_settings" ALTER COLUMN "app_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "signatures" ALTER COLUMN "app_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "tus_uploads" ALTER COLUMN "app_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "media" ADD CONSTRAINT "media_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "media_settings" ADD CONSTRAINT "media_settings_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "media_settings" ADD CONSTRAINT "media_settings_app_id_unique" UNIQUE("app_id"); +--> statement-breakpoint +ALTER TABLE "signatures" ADD CONSTRAINT "signatures_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "signatures" ADD CONSTRAINT "signatures_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "tus_uploads" ADD CONSTRAINT "tus_uploads_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "media_owner_idx" ON "media" USING btree ("user_id", "app_id", "temp"); +--> statement-breakpoint +CREATE INDEX "signatures_api_key_id_idx" ON "signatures" USING btree ("api_key_id"); +--> statement-breakpoint +ALTER TABLE "apps" DROP COLUMN "key"; +--> statement-breakpoint +ALTER TABLE "apps" DROP COLUMN "restriction"; +--> statement-breakpoint +ALTER TABLE "apps" DROP COLUMN "http_referrers"; +--> statement-breakpoint +ALTER TABLE "apps" DROP COLUMN "ip_addresses"; diff --git a/apps/api/drizzle/meta/0008_snapshot.json b/apps/api/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..98ce0951 --- /dev/null +++ b/apps/api/drizzle/meta/0008_snapshot.json @@ -0,0 +1,4048 @@ +{ + "id": "8de63584-4558-43d3-ad7d-7c37393a7a70", + "prevId": "a614a624-8226-4283-b992-caa4c14bc437", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "identifier" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": [ + "identifier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_app_id_idx": { + "name": "api_keys_app_id_idx", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_app_id_apps_id_fk": { + "name": "api_keys_app_id_apps_id_fk", + "tableFrom": "api_keys", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_created_by_id_user_id_fk": { + "name": "api_keys_created_by_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_api_key_id_unique": { + "name": "api_keys_api_key_id_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key_id" + ] + }, + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "api_keys_created_by_type_check": { + "name": "api_keys_created_by_type_check", + "value": "\"api_keys\".\"created_by_type\" IN ('user', 'bootstrap', 'legacy')" + } + }, + "isRLSEnabled": false + }, + "public.apps": { + "name": "apps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "apps_user_name_uidx": { + "name": "apps_user_name_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"apps\".\"deleted\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "apps_one_default_uidx": { + "name": "apps_one_default_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"apps\".\"is_default\" = true AND \"apps\".\"deleted\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apps_user_id_user_id_fk": { + "name": "apps_user_id_user_id_fk", + "tableFrom": "apps", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "apps_app_id_unique": { + "name": "apps_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "thumbnail_generated": { + "name": "thumbnail_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "access_control": { + "name": "access_control", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp": { + "name": "temp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "media_owner_idx": { + "name": "media_owner_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "temp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_user_id_user_id_fk": { + "name": "media_user_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_app_id_apps_id_fk": { + "name": "media_app_id_apps_id_fk", + "tableFrom": "media", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": [ + "media_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_settings": { + "name": "media_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_web_p": { + "name": "use_web_p", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "webp_output_quality": { + "name": "webp_output_quality", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "thumbnail_width": { + "name": "thumbnail_width", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "thumbnail_height": { + "name": "thumbnail_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_settings_user_id_user_id_fk": { + "name": "media_settings_user_id_user_id_fk", + "tableFrom": "media_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_settings_app_id_apps_id_fk": { + "name": "media_settings_app_id_apps_id_fk", + "tableFrom": "media_settings", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_settings_app_id_unique": { + "name": "media_settings_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_app_selections": { + "name": "oauth_app_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_app_selections_session_id_session_id_fk": { + "name": "oauth_app_selections_session_id_session_id_fk", + "tableFrom": "oauth_app_selections", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_app_selections_app_id_apps_id_fk": { + "name": "oauth_app_selections_app_id_apps_id_fk", + "tableFrom": "oauth_app_selections", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_user_id": { + "name": "public_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_public_user_id_unique": { + "name": "profiles_public_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "public_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signatures": { + "name": "signatures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_till": { + "name": "valid_till", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "signatures_api_key_id_idx": { + "name": "signatures_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signatures_app_id_apps_id_fk": { + "name": "signatures_app_id_apps_id_fk", + "tableFrom": "signatures", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signatures_api_key_id_api_keys_id_fk": { + "name": "signatures_api_key_id_api_keys_id_fk", + "tableFrom": "signatures", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "signatures_signature_unique": { + "name": "signatures_signature_unique", + "nullsNotDistinct": false, + "columns": [ + "signature" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tus_uploads": { + "name": "tus_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_length": { + "name": "upload_length", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "upload_offset": { + "name": "upload_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "temp_file_path": { + "name": "temp_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tus_uploads_app_id_apps_id_fk": { + "name": "tus_uploads_app_id_apps_id_fk", + "tableFrom": "tus_uploads", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tus_uploads_upload_id_unique": { + "name": "tus_uploads_upload_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upload_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "catalog_revision_id": { + "name": "catalog_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_catalog_revision_items_revision_key_uidx": { + "name": "billing_catalog_revision_items_revision_key_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_catalog_revision_items_revision_price_uidx": { + "name": "billing_catalog_revision_items_revision_price_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_price_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { + "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_catalog_revisions", + "columnsFrom": [ + "catalog_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revisions": { + "name": "billing_catalog_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checkout_provider": { + "name": "checkout_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_verification'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_catalog_revisions_active_provider_uidx": { + "name": "billing_catalog_revisions_active_provider_uidx", + "columns": [ + { + "expression": "checkout_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_catalog_revisions\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_catalog_revisions_revision_unique": { + "name": "billing_catalog_revisions_revision_unique", + "nullsNotDistinct": false, + "columns": [ + "revision" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_catalog_revisions_status_check": { + "name": "billing_catalog_revisions_status_check", + "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" + }, + "billing_catalog_revisions_revision_check": { + "name": "billing_catalog_revisions_revision_check", + "value": "\"billing_catalog_revisions\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_attempts": { + "name": "billing_checkout_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_plan": { + "name": "requested_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_interval": { + "name": "requested_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quoted_amount_minor": { + "name": "quoted_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quoted_currency": { + "name": "quoted_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url_encrypted": { + "name": "checkout_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_checkout_attempts_provider_session_uidx": { + "name": "billing_checkout_attempts_provider_session_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_idempotency_uidx": { + "name": "billing_checkout_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_entity_nonterminal_uidx": { + "name": "billing_checkout_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_attempts_billable_entity_id_user_id_fk": { + "name": "billing_checkout_attempts_billable_entity_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_payer_id_user_id_fk": { + "name": "billing_checkout_attempts_payer_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_checkout_attempts_attempt_id_unique": { + "name": "billing_checkout_attempts_attempt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "attempt_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_checkout_attempts_status_check": { + "name": "billing_checkout_attempts_status_check", + "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" + }, + "billing_checkout_attempts_amount_check": { + "name": "billing_checkout_attempts_amount_check", + "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + }, + "billing_checkout_attempts_plan_check": { + "name": "billing_checkout_attempts_plan_check", + "value": "\"billing_checkout_attempts\".\"requested_plan\" IN ('pro')" + }, + "billing_checkout_attempts_interval_check": { + "name": "billing_checkout_attempts_interval_check", + "value": "\"billing_checkout_attempts\".\"requested_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_change_attempts": { + "name": "billing_plan_change_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "change_id": { + "name": "change_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_catalog_revision": { + "name": "current_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_billing_price_entry_id": { + "name": "current_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_plan": { + "name": "current_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_interval": { + "name": "current_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_catalog_revision": { + "name": "target_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_billing_price_entry_id": { + "name": "target_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_plan": { + "name": "target_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_interval": { + "name": "target_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_offer_key": { + "name": "target_offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proration_mode": { + "name": "proration_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_url_encrypted": { + "name": "payment_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_plan_change_attempts_idempotency_uidx": { + "name": "billing_plan_change_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_plan_change_attempts_entity_nonterminal_uidx": { + "name": "billing_plan_change_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_plan_change_attempts_billable_entity_id_user_id_fk": { + "name": "billing_plan_change_attempts_billable_entity_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_payer_id_user_id_fk": { + "name": "billing_plan_change_attempts_payer_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "current_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "target_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_change_attempts_change_id_unique": { + "name": "billing_plan_change_attempts_change_id_unique", + "nullsNotDistinct": false, + "columns": [ + "change_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_plan_change_attempts_status_check": { + "name": "billing_plan_change_attempts_status_check", + "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" + }, + "billing_plan_change_attempts_effective_at_check": { + "name": "billing_plan_change_attempts_effective_at_check", + "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" + }, + "billing_plan_change_attempts_proration_mode_check": { + "name": "billing_plan_change_attempts_proration_mode_check", + "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" + }, + "billing_plan_change_attempts_current_plan_check": { + "name": "billing_plan_change_attempts_current_plan_check", + "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('pro')" + }, + "billing_plan_change_attempts_target_plan_check": { + "name": "billing_plan_change_attempts_target_plan_check", + "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('pro')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_states": { + "name": "billing_plan_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_plan_states_billable_entity_id_user_id_fk": { + "name": "billing_plan_states_billable_entity_id_user_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "active_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_states_billable_entity_id_unique": { + "name": "billing_plan_states_billable_entity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "billable_entity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_price_entries": { + "name": "billing_price_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_trial_days": { + "name": "provider_trial_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_price_entries_provider_product_uidx": { + "name": "billing_price_entries_provider_product_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_entries_offer_key_idx": { + "name": "billing_price_entries_offer_key_idx", + "columns": [ + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_price_entries_amount_check": { + "name": "billing_price_entries_amount_check", + "value": "\"billing_price_entries\".\"amount_minor\" > 0" + }, + "billing_price_entries_trial_days_check": { + "name": "billing_price_entries_trial_days_check", + "value": "\"billing_price_entries\".\"provider_trial_days\" >= 0" + }, + "billing_price_entries_currency_check": { + "name": "billing_price_entries_currency_check", + "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + }, + "billing_price_entries_plan_check": { + "name": "billing_price_entries_plan_check", + "value": "\"billing_price_entries\".\"plan\" IN ('pro')" + }, + "billing_price_entries_interval_check": { + "name": "billing_price_entries_interval_check", + "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_provider_customers": { + "name": "billing_provider_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_provider_customers_provider_payer_uidx": { + "name": "billing_provider_customers_provider_payer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_provider_customer_uidx": { + "name": "billing_provider_customers_provider_customer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_idempotency_uidx": { + "name": "billing_provider_customers_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_provider_customers_payer_id_user_id_fk": { + "name": "billing_provider_customers_payer_id_user_id_fk", + "tableFrom": "billing_provider_customers", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_provider_customers_status_check": { + "name": "billing_provider_customers_status_check", + "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" + } + }, + "isRLSEnabled": false + }, + "public.billing_reconciliation_jobs": { + "name": "billing_reconciliation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan_change_attempt_id": { + "name": "plan_change_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reconcile'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_reconciliation_jobs_live_checkout_uidx": { + "name": "billing_reconciliation_jobs_live_checkout_uidx", + "columns": [ + { + "expression": "checkout_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_plan_change_uidx": { + "name": "billing_reconciliation_jobs_live_plan_change_uidx", + "columns": [ + { + "expression": "plan_change_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_subscription_uidx": { + "name": "billing_reconciliation_jobs_live_subscription_uidx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_customer_uidx": { + "name": "billing_reconciliation_jobs_live_customer_uidx", + "columns": [ + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk": { + "name": "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_plan_change_attempts", + "columnsFrom": [ + "plan_change_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk": { + "name": "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "provider_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_reconciliation_jobs_exactly_one_subject": { + "name": "billing_reconciliation_jobs_exactly_one_subject", + "value": "((CASE WHEN \"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL THEN 1 ELSE 0 END)) = 1" + }, + "billing_reconciliation_jobs_status_check": { + "name": "billing_reconciliation_jobs_status_check", + "value": "\"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed', 'completed', 'quarantined')" + }, + "billing_reconciliation_jobs_operation_check": { + "name": "billing_reconciliation_jobs_operation_check", + "value": "\"billing_reconciliation_jobs\".\"operation\" = 'reconcile' OR (\"billing_reconciliation_jobs\".\"operation\" = 'cancellation' AND \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_checkout_attempt_id": { + "name": "origin_checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_period_starts_at": { + "name": "current_period_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_ends_at": { + "name": "current_period_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_through_at": { + "name": "paid_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_entitlement_source": { + "name": "is_entitlement_source", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_version": { + "name": "provider_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reconciled_at": { + "name": "last_reconciled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_provider_subscription_uidx": { + "name": "billing_subscriptions_provider_subscription_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_entity_source_uidx": { + "name": "billing_subscriptions_entity_source_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_subscriptions\".\"is_entitlement_source\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_billable_entity_id_user_id_fk": { + "name": "billing_subscriptions_billable_entity_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_payer_id_user_id_fk": { + "name": "billing_subscriptions_payer_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "origin_checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_subscriptions_status_check": { + "name": "billing_subscriptions_status_check", + "value": "\"billing_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" + }, + "billing_subscriptions_plan_check": { + "name": "billing_subscriptions_plan_check", + "value": "\"billing_subscriptions\".\"plan\" IN ('pro')" + }, + "billing_subscriptions_interval_check": { + "name": "billing_subscriptions_interval_check", + "value": "\"billing_subscriptions\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_key_version": { + "name": "payload_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_key_version": { + "name": "verified_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_provider_event_uidx": { + "name": "billing_webhook_events_provider_event_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_queue_idx": { + "name": "billing_webhook_events_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_webhook_events_status_check": { + "name": "billing_webhook_events_status_check", + "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 61e851ee..7d6624dd 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1791427536182, "tag": "0007_drop_legacy_subscription", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1792230978766, + "tag": "0008_api_keys", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/apikey/bootstrap-config.ts b/apps/api/src/apikey/bootstrap-config.ts new file mode 100644 index 00000000..8c69c275 --- /dev/null +++ b/apps/api/src/apikey/bootstrap-config.ts @@ -0,0 +1,30 @@ +import { hashApiKeySecret } from "./secret"; + +export const BOOTSTRAP_API_KEY_ENV = "MEDIALIT_BOOTSTRAP_API_KEY"; + +/** At least 43 URL-safe characters: 256 bits if base64url, ~172 if hex. + * `ml_live_$(openssl rand -hex 32)` fits. Only the shape can be checked. */ +const BOOTSTRAP_KEY_PATTERN = /^[A-Za-z0-9_-]{43,}$/; + +export type BootstrapKeyConfig = + | { status: "unset" } + | { status: "invalid" } + | { status: "set"; secret: string; keyHash: string }; + +export function readBootstrapKeyConfig( + env: NodeJS.ProcessEnv = process.env, +): BootstrapKeyConfig { + const secret = env[BOOTSTRAP_API_KEY_ENV]?.trim(); + if (!secret) return { status: "unset" }; + if (!BOOTSTRAP_KEY_PATTERN.test(secret)) return { status: "invalid" }; + return { status: "set", secret, keyHash: hashApiKeySecret(secret) }; +} + +/** The hash of the configured bootstrap key, if any. That key is managed by + * configuration: it can't be revoked while it is set. */ +export function configuredBootstrapKeyHash( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const config = readBootstrapKeyConfig(env); + return config.status === "set" ? config.keyHash : null; +} diff --git a/apps/api/src/apikey/queries.ts b/apps/api/src/apikey/queries.ts index 55d58f41..4c90c8d5 100644 --- a/apps/api/src/apikey/queries.ts +++ b/apps/api/src/apikey/queries.ts @@ -1,34 +1,55 @@ -import { Apikey } from "@medialit/models"; import { - createApiKey as insertApiKey, - getApiKeyBySecret, - listApiKeys, + findApiKeyForAuth, + insertApiKey, + touchApiKeyLastUsed, + type ApiKeyAuthRecord, + type ApiKeyRecord, } from "@/db"; +import { + displayPrefix, + generateApiKeySecret, + hashApiKeySecret, +} from "./secret"; -export async function createApiKey( - userId: string, - name: string, - isDefault: boolean = false, -): Promise { - const created = await insertApiKey({ userId, name, isDefault }); - return created; -} +/** Most active keys an app can have. Bootstrap keys skip the check but count + * toward it. */ +export const MAX_ACTIVE_API_KEYS_PER_APP = 10; -export async function getApiKeyUsingKeyId(key: string): Promise { - return getApiKeyBySecret(key); +/** + * Creates a key for the app and returns its secret. The secret is never stored + * or returned again; only its hash and display prefix are kept. + */ +export async function createApiKey(input: { + /** Internal app ID. */ + appId: string; + name: string; + createdById?: string; + expiresAt?: Date; +}): Promise<{ apiKey: ApiKeyRecord; secret: string }> { + const secret = generateApiKeySecret(); + const apiKey = await insertApiKey({ + appId: input.appId, + name: input.name, + keyHash: hashApiKeySecret(secret), + keyPrefix: displayPrefix(secret), + createdByType: "user", + createdById: input.createdById, + expiresAt: input.expiresAt, + limit: MAX_ACTIVE_API_KEYS_PER_APP, + }); + return { apiKey, secret }; } -export async function getApiKeyByUserId( - userId: string, - keyId?: string, -): Promise { - const rows = await listApiKeys(userId, keyId); - if (keyId) return rows[0] ?? null; - return rows; +/** + * Resolves a submitted secret to its key, app and owner state, or `null` if + * no usable key matches. Records the use without delaying the request. + */ +export async function getApiKeyBySecret( + secret: string, +): Promise { + const found = await findApiKeyForAuth(hashApiKeySecret(secret)); + if (found) { + touchApiKeyLastUsed(found.key.id).catch(() => undefined); + } + return found; } - -export default { - createApiKey, - getApiKeyUsingKeyId, - getApiKeyByUserId, -}; diff --git a/apps/api/src/apikey/secret.test.ts b/apps/api/src/apikey/secret.test.ts new file mode 100644 index 00000000..87e9b265 --- /dev/null +++ b/apps/api/src/apikey/secret.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import { + API_KEY_PREFIX, + displayPrefix, + generateApiKeySecret, + hashApiKeySecret, +} from "./secret"; + +test("keys carry the ml_live_ prefix and 256 random bits", () => { + const secret = generateApiKeySecret(); + assert.ok(secret.startsWith(API_KEY_PREFIX)); + const random = secret.slice(API_KEY_PREFIX.length); + assert.match(random, /^[A-Za-z0-9_-]{43}$/); + assert.equal(Buffer.from(random, "base64url").length, 32); + assert.notEqual(generateApiKeySecret(), secret); +}); + +test("hashes with SHA-256 hex", () => { + assert.equal( + hashApiKeySecret("ml_live_example"), + createHash("sha256").update("ml_live_example").digest("hex"), + ); +}); + +test("shows the prefix and four characters of the secret", () => { + assert.equal(displayPrefix("ml_live_abcdefgh"), "ml_live_abcd"); +}); diff --git a/apps/api/src/apikey/secret.ts b/apps/api/src/apikey/secret.ts new file mode 100644 index 00000000..371c16d6 --- /dev/null +++ b/apps/api/src/apikey/secret.ts @@ -0,0 +1,28 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** Stripe-style prefix: secret scanners can spot leaked MediaLit keys, and the + * credential type is obvious at a glance. */ +export const API_KEY_PREFIX = "ml_live_"; + +/** Characters of a key shown in lists (`ml_live_a1b2`). */ +const DISPLAY_PREFIX_LENGTH = 12; + +/** 32 CSPRNG bytes, 256 bits of entropy, base64url so the key stays URL and + * copy-paste safe. */ +export function generateApiKeySecret(): string { + return `${API_KEY_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +/** + * Keys are stored as a plain SHA-256 hash. A slow hash or a pepper only + * protects guessable secrets; a 256-bit random key can't be brute-forced from + * its hash, and a fast hash keeps every authenticated request cheap. See + * docs/prds/apikey.md §13. + */ +export function hashApiKeySecret(secret: string): string { + return createHash("sha256").update(secret, "utf8").digest("hex"); +} + +export function displayPrefix(secret: string): string { + return secret.slice(0, DISPLAY_PREFIX_LENGTH); +} diff --git a/apps/api/src/auth/__tests__/middleware.test.ts b/apps/api/src/auth/__tests__/middleware.test.ts index c7ad6c16..f341d7ab 100644 --- a/apps/api/src/auth/__tests__/middleware.test.ts +++ b/apps/api/src/auth/__tests__/middleware.test.ts @@ -18,7 +18,7 @@ function response() { }; } -test("REST middleware maps successful OAuth auth to req.user and req.apikey", async () => { +test("REST middleware maps successful OAuth auth to req.user and req.appId", async () => { const req: any = { headers: { authorization: "Bearer token" }, body: {} }; const res = response(); let nextCalled = false; @@ -29,7 +29,7 @@ test("REST middleware maps successful OAuth auth to req.user and req.apikey", as userId: "user-1", clientId: "client-1", scopes: ["read"], - apiKey: "default-key", + appId: "internal-default", })); await middleware(req, res as any, () => { @@ -38,10 +38,11 @@ test("REST middleware maps successful OAuth auth to req.user and req.apikey", as assert.equal(nextCalled, true); assert.deepEqual(req.user, { id: "user-1" }); - assert.equal(req.apikey, "default-key"); + assert.equal(req.appId, "internal-default"); + assert.equal(req.apiKeyId, undefined); }); -test("REST middleware maps API-key auth to the submitted API key", async () => { +test("REST middleware maps API-key auth to internal IDs, never the secret", async () => { const req: any = { headers: { "x-medialit-apikey": "submitted-key" }, body: {}, @@ -53,7 +54,8 @@ test("REST middleware maps API-key auth to the submitted API key", async () => { kind: "apikey", user: { id: "user-1" }, userId: "user-1", - apiKey: "submitted-key", + appId: "internal-app", + apiKeyId: "internal-key", })); await middleware(req, res as any, () => { @@ -62,7 +64,9 @@ test("REST middleware maps API-key auth to the submitted API key", async () => { assert.equal(nextCalled, true); assert.deepEqual(req.user, { id: "user-1" }); - assert.equal(req.apikey, "submitted-key"); + assert.equal(req.appId, "internal-app"); + assert.equal(req.apiKeyId, "internal-key"); + assert.equal(req.apikey, undefined); }); test("REST middleware uses shared invalid Bearer 401 behavior", async () => { @@ -94,7 +98,7 @@ test("MCP middleware maps OAuth auth fields", async () => { userId: "user-1", clientId: "client-1", scopes: ["read"], - apiKey: "default-key", + appId: "internal-default", })); await middleware(req, res as any, () => { @@ -106,7 +110,7 @@ test("MCP middleware maps OAuth auth fields", async () => { assert.equal(req.userId, "user-1"); assert.equal(req.clientId, "client-1"); assert.deepEqual(req.scopes, ["read"]); - assert.equal(req.apikey, "default-key"); + assert.equal(req.appId, "internal-default"); }); test("MCP middleware maps API-key auth fields with full data scopes", async () => { @@ -121,7 +125,8 @@ test("MCP middleware maps API-key auth fields with full data scopes", async () = kind: "apikey", user: { id: "user-1" }, userId: "user-1", - apiKey: "submitted-key", + appId: "internal-app", + apiKeyId: "internal-key", })); await middleware(req, res as any, () => { @@ -131,7 +136,8 @@ test("MCP middleware maps API-key auth fields with full data scopes", async () = assert.equal(nextCalled, true); assert.deepEqual(req.user, { id: "user-1" }); assert.equal(req.userId, "user-1"); - assert.equal(req.apikey, "submitted-key"); + assert.equal(req.appId, "internal-app"); + assert.equal(req.apiKeyId, "internal-key"); assert.equal(req.clientId, undefined); // API keys predate scopes and keep full access. assert.deepEqual(req.scopes, ["data:read", "data:write"]); diff --git a/apps/api/src/auth/__tests__/oauth-app-selection.test.ts b/apps/api/src/auth/__tests__/oauth-app-selection.test.ts index 9d253e05..a92baf9e 100644 --- a/apps/api/src/auth/__tests__/oauth-app-selection.test.ts +++ b/apps/api/src/auth/__tests__/oauth-app-selection.test.ts @@ -9,13 +9,13 @@ import { const NOW = Date.parse("2026-10-04T12:00:00Z"); const input = { user: { id: "user-1" }, session: { id: "session-1" } }; -function app(keyId: string, isDefault = false): OAuthApp { - return { id: `id-${keyId}`, keyId, name: keyId, isDefault }; +function app(appId: string, isDefault = false): OAuthApp { + return { id: `id-${appId}`, appId, name: appId, isDefault }; } function hooks( apps: OAuthApp[], - selection: { keyId: string; updatedAt: Date } | null = null, + selection: { appId: string; updatedAt: Date } | null = null, ) { const adapter: OAuthAppSelectionAdapter = { listAppsForUser: async () => apps, @@ -45,7 +45,7 @@ test("a user with several apps is sent to the picker", async () => { test("a fresh selection continues the flow with the picked app", async () => { const { postLogin } = hooks([app("a", true), app("b")], { - keyId: "b", + appId: "b", updatedAt: new Date(NOW - 60_000), }); @@ -55,7 +55,7 @@ test("a fresh selection continues the flow with the picked app", async () => { test("a selection from an earlier authorization shows the picker again", async () => { const { postLogin } = hooks([app("a", true), app("b")], { - keyId: "b", + appId: "b", updatedAt: new Date(NOW - 60 * 60_000), }); @@ -64,7 +64,7 @@ test("a selection from an earlier authorization shows the picker again", async ( test("a selection of an app the user no longer has is ignored", async () => { const { postLogin } = hooks([app("a", true), app("b")], { - keyId: "gone", + appId: "gone", updatedAt: new Date(NOW), }); diff --git a/apps/api/src/auth/__tests__/resolve-auth.test.ts b/apps/api/src/auth/__tests__/resolve-auth.test.ts index c1bfb8ff..b5f03efb 100644 --- a/apps/api/src/auth/__tests__/resolve-auth.test.ts +++ b/apps/api/src/auth/__tests__/resolve-auth.test.ts @@ -2,21 +2,44 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveAuth, - selectEffectiveApiKey, + selectEffectiveApp, sendAuthError, } from "../resolve-auth.js"; -import { Apikey } from "@medialit/models"; +import type { ApiKeyAuthRecord, AppRecord } from "@/db"; -const user = { _id: "user-1", id: "user-1" }; +const user = { _id: "user-1", id: "user-1", active: true }; -function apiKey(key: string, overrides: Partial = {}): Apikey { +function app(appId: string, overrides: Partial = {}): AppRecord { return { - key, - keyId: key, - name: key, - userId: { toString: () => "user-1" } as any, + id: `internal-${appId}`, + appId, + name: appId, + userId: "user-1", default: false, deleted: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function keyFor( + appId: string, + overrides: Partial = {}, +): ApiKeyAuthRecord { + return { + key: { + id: `key-of-${appId}`, + apiKeyId: `ak_${appId}`, + appId: `internal-${appId}`, + name: "Key", + keyHash: "hash", + keyPrefix: "ml_live_abcd", + createdByType: "user", + createdAt: new Date(), + }, + app: app(appId), + ownerActive: true, ...overrides, }; } @@ -29,57 +52,57 @@ function dependencies(overrides: Partial[1]>) { scopes: ["read"], }), getUser: async () => user, - getApiKeyByUserId: async () => [apiKey("first")], - getApiKeyUsingKeyId: async (key: string) => apiKey(key), + listApps: async () => [app("first")], + getApiKeyBySecret: async () => keyFor("keyed"), ...overrides, } as NonNullable[1]>; } -test("selectEffectiveApiKey prefers the default key", () => { - const selected = selectEffectiveApiKey([ - apiKey("first"), - apiKey("default", { default: true }), +test("selectEffectiveApp prefers the default app", () => { + const selected = selectEffectiveApp([ + app("first"), + app("default", { default: true }), ]); - assert.equal(selected?.key, "default"); + assert.equal(selected?.appId, "default"); }); -test("selectEffectiveApiKey falls back to the first key", () => { - const selected = selectEffectiveApiKey([apiKey("first"), apiKey("second")]); +test("selectEffectiveApp falls back to the first app", () => { + const selected = selectEffectiveApp([app("first"), app("second")]); - assert.equal(selected?.key, "first"); + assert.equal(selected?.appId, "first"); }); -test("OAuth auth resolves the user's default API key", async () => { +test("OAuth auth resolves the user's default app", async () => { const auth = await resolveAuth( { authorization: "Bearer valid-token" }, dependencies({ - getApiKeyByUserId: async () => [ - apiKey("first"), - apiKey("default", { default: true }), + listApps: async () => [ + app("first"), + app("default", { default: true }), ], }), ); assert.equal(auth.status, "authenticated"); assert.equal(auth.kind, "oauth"); - assert.equal(auth.apiKey, "default"); + assert.equal(auth.appId, "internal-default"); }); -test("OAuth auth falls back to the first API key when no default exists", async () => { +test("OAuth auth falls back to the first app when no default exists", async () => { const auth = await resolveAuth( { authorization: "Bearer valid-token" }, dependencies({ - getApiKeyByUserId: async () => [apiKey("first"), apiKey("second")], + listApps: async () => [app("first"), app("second")], }), ); assert.equal(auth.status, "authenticated"); assert.equal(auth.kind, "oauth"); - assert.equal(auth.apiKey, "first"); + assert.equal(auth.appId, "internal-first"); }); -test("OAuth auth uses the app picked at authorization", async () => { +test("OAuth auth maps the picked app's public ID to its internal ID", async () => { const lookups: unknown[] = []; const auth = await resolveAuth( { authorization: "Bearer valid-token" }, @@ -90,11 +113,11 @@ test("OAuth auth uses the app picked at authorization", async () => { scopes: ["read"], appId: "picked", }), - getApiKeyByUserId: async (userId: string, keyId?: string) => { - lookups.push([userId, keyId]); - return keyId === "picked" - ? apiKey("picked-secret", { keyId: "picked" }) - : [apiKey("default", { default: true })]; + listApps: async (userId: string, appId?: string) => { + lookups.push([userId, appId]); + return appId === "picked" + ? [app("picked")] + : [app("default", { default: true })]; }, }), ); @@ -102,7 +125,7 @@ test("OAuth auth uses the app picked at authorization", async () => { assert.deepEqual(lookups, [["user-1", "picked"]]); assert.equal(auth.status, "authenticated"); assert.equal(auth.kind, "oauth"); - assert.equal(auth.apiKey, "picked-secret"); + assert.equal(auth.appId, "internal-picked"); }); test("OAuth auth rejects a token whose picked app was deleted", async () => { @@ -115,23 +138,35 @@ test("OAuth auth rejects a token whose picked app was deleted", async () => { scopes: ["read"], appId: "deleted", }), - getApiKeyByUserId: async (_userId: string, keyId?: string) => - keyId ? null : [apiKey("default", { default: true })], + listApps: async (_userId: string, appId?: string) => + appId ? [] : [app("default", { default: true })], }), ); assert.equal(auth.status, "invalid_token"); }); -test("OAuth auth succeeds without an API key when the user has none", async () => { +test("OAuth auth succeeds without an app when the user has none", async () => { const auth = await resolveAuth( { authorization: "Bearer valid-token" }, - dependencies({ getApiKeyByUserId: async () => [] }), + dependencies({ listApps: async () => [] }), ); assert.equal(auth.status, "authenticated"); assert.equal(auth.kind, "oauth"); - assert.equal(auth.apiKey, undefined); + assert.equal(auth.appId, undefined); +}); + +test("OAuth auth rejects a deactivated account", async () => { + const auth = await resolveAuth( + { authorization: "Bearer valid-token" }, + dependencies({ getUser: async () => ({ ...user, active: false }) }), + ); + + assert.deepEqual(auth, { + status: "forbidden", + error: "account_inactive", + }); }); function noFallback() { @@ -139,7 +174,7 @@ function noFallback() { validateBearerToken: async () => { throw new Error("token validation must not run"); }, - getApiKeyUsingKeyId: async () => { + getApiKeyBySecret: async () => { throw new Error("API key fallback must not run"); }, }); @@ -157,13 +192,13 @@ test("Bearer token plus API key is ambiguous and validates neither", async () => assert.deepEqual(auth, { status: "ambiguous" }); }); -test("Bearer token plus a body API key is ambiguous", async () => { +test("a key in the request body is not a credential", async () => { const auth = await resolveAuth( - { authorization: "Bearer valid-token", bodyApiKey: "submitted-key" }, + { bodyApiKey: "submitted-key" } as any, noFallback(), ); - assert.deepEqual(auth, { status: "ambiguous" }); + assert.deepEqual(auth, { status: "missing" }); }); test("invalid Bearer token is rejected", async () => { @@ -190,47 +225,66 @@ test("malformed Authorization never falls back to the API key", async () => { } }); -test("different header and body API keys are ambiguous", async () => { +test("OAuth auth rejects a token for a missing user", async () => { const auth = await resolveAuth( - { apiKeyHeader: "header-key", bodyApiKey: "body-key" }, - noFallback(), + { authorization: "Bearer valid-token" }, + dependencies({ getUser: async () => null }), ); - assert.deepEqual(auth, { status: "ambiguous" }); + assert.deepEqual(auth, { status: "unauthorized" }); }); -test("the same API key in header and body is one credential", async () => { +test("API-key auth resolves internal app and key IDs, never the secret", async () => { + const submitted: string[] = []; const auth = await resolveAuth( - { apiKeyHeader: "submitted-key", bodyApiKey: "submitted-key" }, - dependencies({}), + { apiKeyHeader: "ml_live_submitted" }, + dependencies({ + getApiKeyBySecret: async (secret: string) => { + submitted.push(secret); + return keyFor("keyed"); + }, + }), ); - assert.equal(auth.status, "authenticated"); - assert.equal(auth.kind, "apikey"); - assert.equal(auth.apiKey, "submitted-key"); + assert.deepEqual(submitted, ["ml_live_submitted"]); + assert.deepEqual(auth, { + status: "authenticated", + kind: "apikey", + user, + userId: "user-1", + appId: "internal-keyed", + apiKeyId: "key-of-keyed", + }); + assert.equal(JSON.stringify(auth).includes("ml_live_submitted"), false); }); -test("OAuth auth rejects a token for a missing user", async () => { +test("an unknown, revoked or deleted-app key is unauthorized", async () => { const auth = await resolveAuth( - { authorization: "Bearer valid-token" }, - dependencies({ getUser: async () => null }), + { apiKeyHeader: "ml_live_unknown" }, + dependencies({ getApiKeyBySecret: async () => null }), ); assert.deepEqual(auth, { status: "unauthorized" }); }); -test("API-key auth preserves the submitted key", async () => { - const auth = await resolveAuth( - { apiKeyHeader: "submitted-key" }, - dependencies({ - getApiKeyUsingKeyId: async () => - apiKey("stored-key", { default: true }), - }), - ); +test("API-key auth rejects a deactivated account", async () => { + for (const deps of [ + { + getApiKeyBySecret: async () => + keyFor("keyed", { ownerActive: false }), + }, + { getUser: async () => ({ ...user, active: false }) }, + ]) { + const auth = await resolveAuth( + { apiKeyHeader: "ml_live_submitted" }, + dependencies(deps), + ); - assert.equal(auth.status, "authenticated"); - assert.equal(auth.kind, "apikey"); - assert.equal(auth.apiKey, "submitted-key"); + assert.deepEqual(auth, { + status: "forbidden", + error: "account_inactive", + }); + } }); test("missing auth returns missing", async () => { @@ -239,6 +293,32 @@ test("missing auth returns missing", async () => { assert.deepEqual(auth, { status: "missing" }); }); +test("a deactivated account gets 403 account_inactive", () => { + let status = 0; + let body: any; + const response = { + setHeader() {}, + status(code: number) { + status = code; + return this; + }, + json(value: unknown) { + body = value; + return this; + }, + }; + + assert.equal( + sendAuthError(response, { + status: "forbidden", + error: "account_inactive", + }), + true, + ); + assert.equal(status, 403); + assert.equal(body.error, "account_inactive"); +}); + test("ambiguous credentials return the Platform 400 error", () => { let status = 0; let body: unknown; diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index f7e8dac6..48c76330 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -6,7 +6,6 @@ import { API_KEY_SCOPES } from "./scopes"; type AuthResolver = (input: { authorization?: unknown; apiKeyHeader?: unknown; - bodyApiKey?: unknown; }) => Promise; type AuthMiddlewareMode = "rest" | "mcp"; @@ -19,7 +18,9 @@ function applyAuthToRequest( if (auth.status !== "authenticated") return; req.user = auth.user; - req.apikey = auth.apiKey; + // Internal IDs only; no secret ever reaches the request. + req.appId = auth.appId; + req.apiKeyId = auth.kind === "apikey" ? auth.apiKeyId : undefined; req.scopes = auth.kind === "oauth" ? auth.scopes : API_KEY_SCOPES; if (mode === "mcp") { @@ -43,7 +44,6 @@ export function createAuthMiddleware( const auth = await authResolver({ authorization: req.headers.authorization, apiKeyHeader: req.headers["x-medialit-apikey"], - bodyApiKey: req.body?.apikey, }); if ( diff --git a/apps/api/src/auth/oauth-app-pages.ts b/apps/api/src/auth/oauth-app-pages.ts index 1697ec66..fe196e52 100644 --- a/apps/api/src/auth/oauth-app-pages.ts +++ b/apps/api/src/auth/oauth-app-pages.ts @@ -59,7 +59,7 @@ function pickerPage(apps: OAuthApp[], authBasePath: string): string { const choices = apps .map( (app) => - ``, + ``, ) .join(""); return page( @@ -144,7 +144,7 @@ export function createOAuthAppPages(options: { return; } const apps = await options.adapter.listAppsForUser(session.user.id); - const selected = apps.find((app) => app.keyId === appId); + const selected = apps.find((app) => app.appId === appId); if (!selected) { response.status(404).json({ error: "app_not_found", diff --git a/apps/api/src/auth/oauth-app-selection.ts b/apps/api/src/auth/oauth-app-selection.ts index 37b78443..ff631250 100644 --- a/apps/api/src/auth/oauth-app-selection.ts +++ b/apps/api/src/auth/oauth-app-selection.ts @@ -1,4 +1,4 @@ -import { getOAuthAppSelection, listApiKeys, setOAuthAppSelection } from "@/db"; +import { getOAuthAppSelection, listApps, setOAuthAppSelection } from "@/db"; export const APP_ID_CLAIM = "app_id"; @@ -8,8 +8,10 @@ export const APP_ID_CLAIM = "app_id"; const SELECTION_FRESH_MS = 10 * 60 * 1000; export type OAuthApp = { + /** Internal ID. */ id: string; - keyId: string; + /** Public ID; what the picker posts and the `app_id` claim carries. */ + appId: string; name: string; isDefault: boolean; }; @@ -18,18 +20,19 @@ export interface OAuthAppSelectionAdapter { listAppsForUser(userId: string): Promise; getSelection( sessionId: string, - ): Promise<{ keyId: string; updatedAt: Date } | null>; - setSelection(sessionId: string, apiKeyId: string): Promise; + ): Promise<{ appId: string; updatedAt: Date } | null>; + /** `appId` is the internal app ID. */ + setSelection(sessionId: string, appId: string): Promise; } export const oauthAppSelectionAdapter: OAuthAppSelectionAdapter = { async listAppsForUser(userId) { - const keys = await listApiKeys(userId); - return keys.map((key) => ({ - id: key.id, - keyId: key.keyId, - name: key.name, - isDefault: Boolean(key.default), + const apps = await listApps(userId); + return apps.map((app) => ({ + id: app.id, + appId: app.appId, + name: app.name, + isDefault: Boolean(app.default), })); }, getSelection: getOAuthAppSelection, @@ -45,20 +48,20 @@ async function resolveSelection( const apps = await adapter.listAppsForUser(userId); // A single app is selected without asking. if (apps.length === 1) { - return { requiresSelection: false, keyId: apps[0].keyId }; + return { requiresSelection: false, appId: apps[0].appId }; } if (apps.length === 0) { - return { requiresSelection: false, keyId: undefined }; + return { requiresSelection: false, appId: undefined }; } const selection = await adapter.getSelection(sessionId); const valid = selection - ? apps.some((app) => app.keyId === selection.keyId) + ? apps.some((app) => app.appId === selection.appId) : false; const fresh = valid && now - selection!.updatedAt.getTime() < SELECTION_FRESH_MS; return { requiresSelection: !fresh, - keyId: valid ? selection!.keyId : undefined, + appId: valid ? selection!.appId : undefined, }; } @@ -100,7 +103,7 @@ export function createOAuthAppSelectionHooks(options: { input.session.id, now(), ) - ).keyId; + ).appId; }, }, async customAccessTokenClaims(input: { referenceId?: string | null }) { diff --git a/apps/api/src/auth/resolve-auth.ts b/apps/api/src/auth/resolve-auth.ts index 0590856d..4f9969af 100644 --- a/apps/api/src/auth/resolve-auth.ts +++ b/apps/api/src/auth/resolve-auth.ts @@ -4,8 +4,8 @@ import { type PresentedCredential, selectSingleCredential, } from "@codelitdev/platform"; -import { Apikey } from "@medialit/models"; -import { getApiKeyByUserId, getApiKeyUsingKeyId } from "../apikey/queries"; +import { getApiKeyBySecret } from "../apikey/queries"; +import { listApps, type ApiKeyAuthRecord, type AppRecord } from "@/db"; import { validateBearerToken } from "./bearer"; import { getUser } from "../user/queries"; @@ -15,23 +15,21 @@ type OAuthClaims = { userId: string; clientId: string; scopes: string[]; + /** Public app ID from the token's `app_id` claim. */ appId?: string; }; export type AuthInput = { authorization?: unknown; apiKeyHeader?: unknown; - bodyApiKey?: unknown; }; export type AuthDependencies = { validateBearerToken: (bearer: string) => Promise; getUser: (id: string) => Promise; - getApiKeyByUserId: ( - userId: string, - keyId?: string, - ) => Promise; - getApiKeyUsingKeyId: (key: string) => Promise; + /** The user's apps that aren't deleted, optionally by public ID. */ + listApps: (userId: string, appId?: string) => Promise; + getApiKeyBySecret: (secret: string) => Promise; }; export type AuthResult = @@ -42,17 +40,22 @@ export type AuthResult = userId: string; clientId: string; scopes: string[]; - apiKey?: string; + /** Internal ID of the app the token acts on, if the user has one. */ + appId?: string; } | { status: "authenticated"; kind: "apikey"; user: UserRecord; userId: string; - apiKey: string; + /** Internal ID of the key's app. */ + appId: string; + /** Internal ID of the key. Never the secret. */ + apiKeyId: string; } | { status: "invalid_token" } | { status: "unauthorized" } + | { status: "forbidden"; error: "account_inactive" } | { status: "ambiguous" } | { status: "missing" }; @@ -89,6 +92,14 @@ export function sendAuthError( return true; } + if (auth.status === "forbidden") { + res.status(403).json({ + error: auth.error, + error_description: "This account is deactivated", + }); + return true; + } + if (auth.status === "ambiguous") { res.status(PLATFORM_ERROR_HTTP_STATUS.credential_ambiguous).json({ error: "credential_ambiguous", @@ -103,8 +114,8 @@ export function sendAuthError( const defaultDependencies: AuthDependencies = { validateBearerToken, getUser, - getApiKeyByUserId, - getApiKeyUsingKeyId, + listApps, + getApiKeyBySecret, }; function getHeaderValue(value: unknown): string | undefined { @@ -114,42 +125,41 @@ function getHeaderValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -export function selectEffectiveApiKey( - keys: Apikey | Apikey[] | null, -): Apikey | null { - if (!keys) return null; - if (!Array.isArray(keys)) return keys; - return keys.find((key) => key.default === true) || keys[0] || null; +export function selectEffectiveApp(apps: AppRecord[]): AppRecord | null { + return apps.find((app) => app.default) || apps[0] || null; } /** - * The app an OAuth token acts on: the one picked at authorization, or the - * user's default app for tokens issued before the picker existed. `null` - * means the picked app no longer exists. + * The internal ID of the app an OAuth token acts on: the one picked at + * authorization, or the user's default app for tokens issued before the picker + * existed. `null` means the picked app no longer exists. */ -async function getEffectiveOAuthApiKey( +async function getEffectiveOAuthApp( userId: string, appId: string | undefined, dependencies: AuthDependencies, ): Promise { try { if (appId) { - const key = selectEffectiveApiKey( - await dependencies.getApiKeyByUserId(userId, appId), - ); - return key && key.keyId === appId ? key.key : null; + const [app] = await dependencies.listApps(userId, appId); + return app && app.appId === appId ? app.id : null; } - const keys = await dependencies.getApiKeyByUserId(userId); - return selectEffectiveApiKey(keys)?.key; + return selectEffectiveApp(await dependencies.listApps(userId))?.id; } catch { return undefined; } } +const ACCOUNT_INACTIVE = { + status: "forbidden", + error: "account_inactive", +} as const; + /** * Follows the Platform credential contract (ADR 0001): a request carries * exactly one mechanism, and a malformed or invalid credential is rejected - * rather than falling back to another one. + * rather than falling back to another one. API keys come only from the + * `x-medialit-apikey` header. */ export async function resolveAuth( input: AuthInput, @@ -157,7 +167,6 @@ export async function resolveAuth( ): Promise { const authorization = getHeaderValue(input.authorization); const headerApiKey = getHeaderValue(input.apiKeyHeader); - const bodyApiKey = getHeaderValue(input.bodyApiKey); let bearer: string | undefined; if (authorization) { @@ -165,17 +174,11 @@ export async function resolveAuth( if (!match) return { status: "invalid_token" }; bearer = match[1]; } - // The header and the legacy body field are one mechanism; two different - // keys are ambiguous. - if (headerApiKey && bodyApiKey && headerApiKey !== bodyApiKey) { - return { status: "ambiguous" }; - } const presented: PresentedCredential[] = []; if (bearer) presented.push({ kind: "oauth", secret: bearer }); - const submittedApiKey = bodyApiKey || headerApiKey; - if (submittedApiKey) { - presented.push({ kind: "api_key", secret: submittedApiKey }); + if (headerApiKey) { + presented.push({ kind: "api_key", secret: headerApiKey }); } const selection = selectSingleCredential(presented); if (selection.kind === "absent") return { status: "missing" }; @@ -192,15 +195,16 @@ export async function resolveAuth( const user = await dependencies.getUser(claims.userId); if (!user) return { status: "unauthorized" }; + if (user.active === false) return ACCOUNT_INACTIVE; - const apiKey = await getEffectiveOAuthApiKey( + const appId = await getEffectiveOAuthApp( claims.userId, claims.appId, dependencies, ); // The picked app was deleted; the client must re-authorize and pick // another rather than silently act on a different app. - if (apiKey === null) return { status: "invalid_token" }; + if (appId === null) return { status: "invalid_token" }; return { status: "authenticated", @@ -209,23 +213,26 @@ export async function resolveAuth( userId: claims.userId, clientId: claims.clientId, scopes: claims.scopes, - apiKey, + appId, }; } - const submitted = selection.credential.secret; - const apiKey = await dependencies.getApiKeyUsingKeyId(submitted); - if (!apiKey) return { status: "unauthorized" }; + const found = await dependencies.getApiKeyBySecret( + selection.credential.secret, + ); + if (!found) return { status: "unauthorized" }; - const userId = apiKey.userId.toString(); + const userId = found.app.userId; const user = await dependencies.getUser(userId); if (!user) return { status: "unauthorized" }; + if (!found.ownerActive || user.active === false) return ACCOUNT_INACTIVE; return { status: "authenticated", kind: "apikey", user, userId, - apiKey: submitted, + appId: found.app.id, + apiKeyId: found.key.id, }; } diff --git a/apps/api/src/bootstrap.test.ts b/apps/api/src/bootstrap.test.ts new file mode 100644 index 00000000..63beda9a --- /dev/null +++ b/apps/api/src/bootstrap.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { before, describe, test } from "node:test"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { + findUserByEmail, + listActiveApiKeys, + listApps, + setDb, + type AppDb, +} from "@/db"; +import * as schema from "./db/schema/index.js"; +import { createApiKey, getApiKeyBySecret } from "./apikey/queries"; +import { + BOOTSTRAP_KEY_NAME, + bootstrap, + checkBootstrapConfig, +} from "./bootstrap"; + +before(async () => { + const client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../drizzle", + ), + }); + setDb(db as unknown as AppDb); +}); + +const KEY_A = `ml_live_${"a".repeat(64)}`; +const KEY_B = `ml_live_${"b".repeat(64)}`; + +describe("bootstrap", () => { + test("without EMAIL it does nothing", async () => { + assert.deepEqual(await bootstrap({}), {}); + }); + + test("without a bootstrap key it creates the admin and a keyless app", async () => { + assert.deepEqual(await bootstrap({ EMAIL: "Solo@Example.com" }), {}); + const admin = await findUserByEmail("solo@example.com"); + assert.ok(admin); + const apps = await listApps(admin.id); + assert.equal(apps.length, 1); + assert.deepEqual(await listActiveApiKeys(apps[0].id), []); + }); + + test("syncs the configured key on every start", async () => { + const env = { + EMAIL: "admin@example.com", + MEDIALIT_BOOTSTRAP_API_KEY: KEY_A, + }; + + const first = await bootstrap(env); + assert.equal(first.key, "created"); + const found = await getApiKeyBySecret(KEY_A); + assert.equal(found?.key.name, BOOTSTRAP_KEY_NAME); + assert.equal(found?.key.createdByType, "bootstrap"); + assert.equal(found?.app.appId, first.appId); + assert.equal(found?.app.default, true); + + assert.equal((await bootstrap(env)).key, "unchanged"); + + const rotated = await bootstrap({ + ...env, + MEDIALIT_BOOTSTRAP_API_KEY: KEY_B, + }); + assert.equal(rotated.key, "created"); + assert.equal(await getApiKeyBySecret(KEY_A), null); + assert.notEqual(await getApiKeyBySecret(KEY_B), null); + + assert.equal((await bootstrap(env)).key, "restored"); + assert.notEqual(await getApiKeyBySecret(KEY_A), null); + assert.equal(await getApiKeyBySecret(KEY_B), null); + + // Removing the setting leaves the key working. + await bootstrap({ EMAIL: "admin@example.com" }); + assert.notEqual(await getApiKeyBySecret(KEY_A), null); + }); + + test("refuses a value that belongs to a dashboard key", async () => { + await bootstrap({ EMAIL: "owner@example.com" }); + const owner = await findUserByEmail("owner@example.com"); + const [app] = await listApps(owner!.id); + const { secret } = await createApiKey({ appId: app.id, name: "Mine" }); + + await assert.rejects( + bootstrap({ + EMAIL: "owner@example.com", + MEDIALIT_BOOTSTRAP_API_KEY: secret, + }), + /matches an API key created in the dashboard/, + ); + }); +}); + +describe("checkBootstrapConfig", () => { + test("rejects a malformed key", () => { + for (const value of [ + "short", + `ml_live_${"a".repeat(30)}`, + `${KEY_A} x`, + ]) { + assert.throws( + () => + checkBootstrapConfig({ + EMAIL: "admin@example.com", + MEDIALIT_BOOTSTRAP_API_KEY: value, + }), + /MEDIALIT_BOOTSTRAP_API_KEY must be/, + value, + ); + } + }); + + test("requires EMAIL with a key", () => { + assert.throws( + () => checkBootstrapConfig({ MEDIALIT_BOOTSTRAP_API_KEY: KEY_A }), + /EMAIL is required/, + ); + }); + + test("accepts a well-formed key or no key", () => { + checkBootstrapConfig({}); + checkBootstrapConfig({ + EMAIL: "a@example.com", + MEDIALIT_BOOTSTRAP_API_KEY: KEY_A, + }); + checkBootstrapConfig({ + EMAIL: "a@example.com", + MEDIALIT_BOOTSTRAP_API_KEY: + "0123456789abcdef0123456789abcdef0123456789abcdef", + }); + }); +}); diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 00000000..56679549 --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,79 @@ +import { + ensureProfile, + findUserByEmail, + insertAuthUser, + listApps, + syncBootstrapApiKey, + type BootstrapKeyOutcome, +} from "@/db"; +import { + BOOTSTRAP_API_KEY_ENV, + readBootstrapKeyConfig, +} from "./apikey/bootstrap-config"; +import { displayPrefix } from "./apikey/secret"; +import { selectEffectiveApp } from "./auth/resolve-auth"; + +export const BOOTSTRAP_KEY_NAME = `Bootstrap (${BOOTSTRAP_API_KEY_ENV})`; + +/** Throws when the bootstrap settings can't work, so startup fails before the + * API listens instead of running without the key a bundled product needs. */ +export function checkBootstrapConfig(env: NodeJS.ProcessEnv = process.env) { + const config = readBootstrapKeyConfig(env); + if (config.status === "invalid") { + throw new Error( + `${BOOTSTRAP_API_KEY_ENV} must be at least 43 characters from [A-Za-z0-9_-]. ` + + "Generate one with: echo ml_live_$(openssl rand -hex 32)", + ); + } + if (config.status === "set" && !env.EMAIL?.trim()) { + throw new Error( + `EMAIL is required when ${BOOTSTRAP_API_KEY_ENV} is set: the key belongs to that user's default app`, + ); + } +} + +export type BootstrapResult = { + /** Public ID of the app the bootstrap key belongs to. */ + appId?: string; + key?: BootstrapKeyOutcome; +}; + +/** + * Runs before the API listens. With `EMAIL` set, makes sure the admin user + * and its default app exist. With `MEDIALIT_BOOTSTRAP_API_KEY` also set, + * makes that value the admin's one active bootstrap key, so a product bundled + * in the same compose file has a working key from its first start. Never logs + * a secret. Throws on any problem. + */ +export async function bootstrap( + env: NodeJS.ProcessEnv = process.env, +): Promise { + checkBootstrapConfig(env); + const email = env.EMAIL?.trim().toLowerCase(); + if (!email) return {}; + + const existing = await findUserByEmail(email); + const userId = existing?.id ?? (await insertAuthUser({ email })); + // Idempotent: creates the profile and the keyless default app if missing. + await ensureProfile({ userId }); + + const config = readBootstrapKeyConfig(env); + if (config.status !== "set") return {}; + + const app = selectEffectiveApp(await listApps(userId)); + if (!app) + throw new Error("The admin user has no app for the bootstrap key"); + const outcome = await syncBootstrapApiKey({ + appId: app.id, + keyHash: config.keyHash, + keyPrefix: displayPrefix(config.secret), + name: BOOTSTRAP_KEY_NAME, + }); + if (outcome === "conflict") { + throw new Error( + `${BOOTSTRAP_API_KEY_ENV} matches an API key created in the dashboard. ` + + "Set it to a new value instead: echo ml_live_$(openssl rand -hex 32)", + ); + } + return { appId: app.appId, key: outcome }; +} diff --git a/apps/api/src/config/constants.ts b/apps/api/src/config/constants.ts index 32b30a18..98b03815 100644 --- a/apps/api/src/config/constants.ts +++ b/apps/api/src/config/constants.ts @@ -22,9 +22,6 @@ export const SIGNATURE_VALIDITY_MINUTES = +( ); // 1 day export const SIGNATURE_LENGTH = 100; export const MEDIA_ID_LENGTH = 40; -export const APIKEY_RESTRICTION_REFERRER = "referrer"; -export const APIKEY_RESTRICTION_IP = "ipaddress"; -export const APIKEY_RESTRICTION_CUSTOM = "custom"; export const imagePattern = /^image\/(jpe?g|png)$/; export const imagePatternForThumbnailGeneration = /^image\/(jpe?g|png|webp)$/; export const videoPattern = /video/; diff --git a/apps/api/src/dashboard/routes.test.ts b/apps/api/src/dashboard/routes.test.ts index 31d4aa8c..8355c78c 100644 --- a/apps/api/src/dashboard/routes.test.ts +++ b/apps/api/src/dashboard/routes.test.ts @@ -14,15 +14,20 @@ import express, { } from "express"; import { createAccount, - createApiKey, + createApp, createMediaRecord, + findApiKeyByHash, getMediaRecord, getSignature, + listApps, setDb, + syncBootstrapApiKey, type AccountUser, - type ApiKeyRecord, type AppDb, + type AppRecord, } from "@/db"; +import { getApiKeyBySecret } from "../apikey/queries"; +import { hashApiKeySecret } from "../apikey/secret"; import * as schema from "../db/schema/index.js"; import type { MedialitAuth } from "../auth/better-auth"; import mediaService from "../media/service"; @@ -48,8 +53,8 @@ let server: Server; let baseUrl: string; let owner: AccountUser; let stranger: AccountUser; -let ownerApp: ApiKeyRecord; -let strangerApp: ApiKeyRecord; +let ownerApp: AppRecord; +let strangerApp: AppRecord; /* Covers every kind, including the mixed-case types the old SQL missed. */ const ownerFiles = [ @@ -64,7 +69,7 @@ const ownerFiles = [ async function addMedia( user: AccountUser, - app: ApiKeyRecord, + app: AppRecord, file: { mediaId: string; mimeType: string; size: number }, options: { temp?: boolean; access?: "public" | "private" } = {}, ) { @@ -72,7 +77,7 @@ async function addMedia( fileName: `${file.mediaId}.bin`, mediaId: file.mediaId, userId: user.id, - apikey: app.key, + appId: app.id, originalFileName: `${file.mediaId}.bin`, mimeType: file.mimeType, size: file.size, @@ -124,8 +129,8 @@ before(async () => { owner = await createAccount({ email: "owner@example.com" }); stranger = await createAccount({ email: "stranger@example.com" }); - ownerApp = await createApiKey({ userId: owner.id, name: "Photos" }); - strangerApp = await createApiKey({ userId: stranger.id, name: "Theirs" }); + ownerApp = await createApp({ userId: owner.id, name: "Photos" }); + strangerApp = await createApp({ userId: stranger.id, name: "Theirs" }); for (const file of ownerFiles) await addMedia(owner, ownerApp, file); // Temporary uploads never count. @@ -168,44 +173,47 @@ describe("dashboard routes: session and ownership", () => { "/api/account", "/api/apps", "/api/apps/overview", - `/api/apps/${ownerApp.keyId}/media`, + `/api/apps/${ownerApp.appId}/media`, ]) { const response = await call("GET", route, { user: null }); assert.equal(response.status, 401, route); } const signature = await call( "POST", - `/api/apps/${ownerApp.keyId}/media/signature`, + `/api/apps/${ownerApp.appId}/media/signature`, { user: null, body: {} }, ); assert.equal(signature.status, 401); }); test("hides another user's app", async () => { - const keyId = strangerApp.keyId; + const appId = strangerApp.appId; const attempts: Array<[string, string, unknown?]> = [ - ["GET", `/api/apps/${keyId}`], - ["GET", `/api/apps/${keyId}/summary`], - ["GET", `/api/apps/${keyId}/media`], - ["GET", `/api/apps/${keyId}/media/count`], - ["GET", `/api/apps/${keyId}/media/stranger-png`], - ["POST", `/api/apps/${keyId}/media/signature`, {}], - ["POST", `/api/apps/${keyId}/media/stranger-png/seal`, {}], + ["GET", `/api/apps/${appId}`], + ["GET", `/api/apps/${appId}/summary`], + ["GET", `/api/apps/${appId}/media`], + ["GET", `/api/apps/${appId}/media/count`], + ["GET", `/api/apps/${appId}/media/stranger-png`], + ["POST", `/api/apps/${appId}/media/signature`, {}], + ["POST", `/api/apps/${appId}/media/stranger-png/seal`, {}], + ["GET", `/api/apps/${appId}/keys`], + ["POST", `/api/apps/${appId}/keys`, { name: "Sneaky" }], + ["DELETE", `/api/apps/${appId}/keys/ak_anything`], [ "POST", - `/api/apps/${keyId}/media/delete`, + `/api/apps/${appId}/media/delete`, { mediaIds: ["stranger-png"] }, ], ]; for (const [method, route, body] of attempts) { const response = await json(method, route, { body }); assert.equal(response.status, 404, `${method} ${route}`); - assert.equal(response.body.error, "Apikey not found"); + assert.equal(response.body.error, "App not found"); } assert.notEqual( await getMediaRecord({ userId: stranger.id, - apikey: strangerApp.key, + appId: strangerApp.id, mediaId: "stranger-png", }), null, @@ -217,7 +225,7 @@ describe("dashboard routes: summaries", () => { test("counts each kind once, whatever the type's case", async () => { const { status, body } = await json( "GET", - `/api/apps/${ownerApp.keyId}/summary`, + `/api/apps/${ownerApp.appId}/summary`, ); assert.equal(status, 200); assert.equal(body.count, 7); @@ -244,15 +252,15 @@ describe("dashboard routes: summaries", () => { assert.equal(status, 200); assert.equal(body.appCount, 2); // Photos and the default app const photos = body.apps.find( - (app: { keyId: string }) => app.keyId === ownerApp.keyId, + (app: { appId: string }) => app.appId === ownerApp.appId, ); const summary = await json( "GET", - `/api/apps/${ownerApp.keyId}/summary`, + `/api/apps/${ownerApp.appId}/summary`, ); assert.deepEqual(photos, summary.body); assert.equal(body.totalOthers, 2); - assert.equal(body.largestApp.keyId, ownerApp.keyId); + assert.equal(body.largestApp.appId, ownerApp.appId); }); }); @@ -260,7 +268,7 @@ describe("dashboard routes: media list", () => { test("tags each file with the kind the filters use", async () => { const { status, body } = await json( "GET", - `/api/apps/${ownerApp.keyId}/media?limit=100`, + `/api/apps/${ownerApp.appId}/media?limit=100`, ); assert.equal(status, 200); assert.equal(body.length, 7); @@ -271,11 +279,11 @@ describe("dashboard routes: media list", () => { for (const kind of ["image", "video", "pdf", "other"]) { const list = await json( "GET", - `/api/apps/${ownerApp.keyId}/media?limit=100&kind=${kind}`, + `/api/apps/${ownerApp.appId}/media?limit=100&kind=${kind}`, ); const count = await json( "GET", - `/api/apps/${ownerApp.keyId}/media/count?kind=${kind}`, + `/api/apps/${ownerApp.appId}/media/count?kind=${kind}`, ); assert.ok( list.body.every((item: { kind: string }) => item.kind === kind), @@ -297,13 +305,13 @@ describe("dashboard routes: media list", () => { ]) { const response = await call( "GET", - `/api/apps/${ownerApp.keyId}/media?${query}`, + `/api/apps/${ownerApp.appId}/media?${query}`, ); assert.equal(response.status, 400, query); } const largest = await json( "GET", - `/api/apps/${ownerApp.keyId}/media?page=2&limit=3&sort=largest`, + `/api/apps/${ownerApp.appId}/media?page=2&limit=3&sort=largest`, ); assert.equal(largest.status, 200); assert.deepEqual( @@ -315,7 +323,7 @@ describe("dashboard routes: media list", () => { test("returns one file with its kind", async () => { const { status, body } = await json( "GET", - `/api/apps/${ownerApp.keyId}/media/pdf-upper`, + `/api/apps/${ownerApp.appId}/media/pdf-upper`, ); assert.equal(status, 200); assert.equal(body.kind, "pdf"); @@ -327,13 +335,14 @@ describe("dashboard routes: uploads", () => { test("issues a signature bound to the app, with the upload endpoint", async () => { const { status, body } = await json( "POST", - `/api/apps/${ownerApp.keyId}/media/signature`, + `/api/apps/${ownerApp.appId}/media/signature`, { body: { group: "avatars" } }, ); assert.equal(status, 200); assert.equal(body.endpoint, PUBLIC_API_URL); const stored = await getSignature(body.signature); - assert.equal(stored?.apikey, ownerApp.key); + assert.equal(stored?.appId, ownerApp.id); + assert.equal(stored?.apiKeyId, undefined); assert.equal(stored?.userId, owner.id); assert.equal(stored?.group, "avatars"); }); @@ -341,7 +350,7 @@ describe("dashboard routes: uploads", () => { test("rejects a signature with a bad group", async () => { const response = await call( "POST", - `/api/apps/${ownerApp.keyId}/media/signature`, + `/api/apps/${ownerApp.appId}/media/signature`, { body: { group: 42 } }, ); assert.equal(response.status, 400); @@ -350,7 +359,7 @@ describe("dashboard routes: uploads", () => { test("seals through the media service and maps its errors", async () => { const missing = await json( "POST", - `/api/apps/${ownerApp.keyId}/media/no-such-media/seal`, + `/api/apps/${ownerApp.appId}/media/no-such-media/seal`, { body: {} }, ); assert.equal(missing.status, 404); @@ -367,14 +376,14 @@ describe("dashboard routes: uploads", () => { })); const sealed = await json( "POST", - `/api/apps/${ownerApp.keyId}/media/temp-png/seal`, + `/api/apps/${ownerApp.appId}/media/temp-png/seal`, { body: {} }, ); assert.equal(sealed.status, 200); assert.equal(sealed.body.kind, "image"); assert.deepEqual(seal.mock.calls[0].arguments[0], { userId: owner.id, - apikey: ownerApp.key, + appId: ownerApp.id, mediaId: "temp-png", }); }); @@ -391,7 +400,7 @@ describe("dashboard routes: bulk delete", () => { ]) { const response = await call( "POST", - `/api/apps/${ownerApp.keyId}/media/delete`, + `/api/apps/${ownerApp.appId}/media/delete`, { body: { mediaIds } }, ); assert.equal(response.status, 400, JSON.stringify(mediaIds)); @@ -413,7 +422,7 @@ describe("dashboard routes: bulk delete", () => { const { status, body } = await json( "POST", - `/api/apps/${ownerApp.keyId}/media/delete`, + `/api/apps/${ownerApp.appId}/media/delete`, { body: { mediaIds: [ @@ -430,16 +439,198 @@ describe("dashboard routes: bulk delete", () => { assert.deepEqual(body, { deleted: 1, failed: 3 }); assert.deepEqual(removed, ["text-plain"]); - const lookup = (userId: string, apikey: string, mediaId: string) => - getMediaRecord({ userId, apikey, mediaId }); - assert.equal(await lookup(owner.id, ownerApp.key, "text-plain"), null); + const lookup = (userId: string, appId: string, mediaId: string) => + getMediaRecord({ userId, appId, mediaId }); + assert.equal(await lookup(owner.id, ownerApp.id, "text-plain"), null); + assert.notEqual(await lookup(owner.id, ownerApp.id, "audio-mp3"), null); assert.notEqual( - await lookup(owner.id, ownerApp.key, "audio-mp3"), + await lookup(stranger.id, strangerApp.id, "stranger-png"), null, ); - assert.notEqual( - await lookup(stranger.id, strangerApp.key, "stranger-png"), - null, + }); +}); + +describe("dashboard routes: apps", () => { + test("never returns internal IDs or secrets for an app", async () => { + const { status, body } = await json( + "GET", + `/api/apps/${ownerApp.appId}`, + ); + assert.equal(status, 200); + assert.deepEqual(body, { + appId: ownerApp.appId, + name: "Photos", + default: false, + }); + }); + + test("creating an app returns its first key once", async () => { + const { status, body } = await json("POST", "/api/apps", { + body: { name: "Fresh" }, + }); + assert.equal(status, 201); + assert.equal(body.name, "Fresh"); + assert.match(body.key, /^ml_live_/); + assert.equal(body.apiKey.keyPrefix, body.key.slice(0, 12)); + const found = await getApiKeyBySecret(body.key); + assert.equal(found?.app.appId, body.appId); + + const keys = await json("GET", `/api/apps/${body.appId}/keys`); + assert.equal(JSON.stringify(keys.body).includes(body.key), false); + }); + + test("a duplicate name is a clear 409, never a database error", async () => { + const created = await json("POST", "/api/apps", { + body: { name: "Twin" }, + }); + const twin = await json("POST", "/api/apps", { + body: { name: "Twin" }, + }); + assert.equal(twin.status, 409); + assert.equal(twin.body.error, "You already have an app with this name"); + + const renamed = await json("PATCH", `/api/apps/${ownerApp.appId}`, { + body: { name: "Twin" }, + }); + assert.equal(renamed.status, 409); + assert.doesNotMatch( + JSON.stringify(renamed.body), + /insert|update|apps/i, ); + assert.equal(created.status, 201); + }); + + test("deleting an app revokes its keys", async () => { + const created = await json("POST", "/api/apps", { + body: { name: "Short-lived" }, + }); + const deleted = await call("DELETE", `/api/apps/${created.body.appId}`); + assert.equal(deleted.status, 200); + assert.equal(await getApiKeyBySecret(created.body.key), null); + }); +}); + +describe("dashboard routes: API keys", () => { + const freshApp = (name: string) => createApp({ userId: owner.id, name }); + + test("creates, lists and revokes keys", async () => { + const app = await freshApp("Keys"); + const created = await json("POST", `/api/apps/${app.appId}/keys`, { + body: { name: "Server" }, + }); + assert.equal(created.status, 201); + assert.match(created.body.key, /^ml_live_/); + assert.match(created.body.keyId, /^ak_/); + assert.equal(created.body.name, "Server"); + assert.equal(created.body.lastUsedAt, null); + assert.equal(created.body.managedByConfiguration, false); + + const listed = await json("GET", `/api/apps/${app.appId}/keys`); + assert.equal(listed.status, 200); + assert.deepEqual( + listed.body.items.map((key: { keyId: string }) => key.keyId), + [created.body.keyId], + ); + assert.equal(listed.body.items[0].key, undefined); + assert.equal(listed.body.limit, 10); + + const revoked = await call( + "DELETE", + `/api/apps/${app.appId}/keys/${created.body.keyId}`, + ); + assert.equal(revoked.status, 204); + assert.equal(await getApiKeyBySecret(created.body.key), null); + const again = await call( + "DELETE", + `/api/apps/${app.appId}/keys/${created.body.keyId}`, + ); + assert.equal(again.status, 404); + const afterRevoke = await json("GET", `/api/apps/${app.appId}/keys`); + assert.deepEqual(afterRevoke.body.items, []); + }); + + test("validates the name and expiry", async () => { + const app = await freshApp("Validation"); + for (const body of [ + {}, + { name: " " }, + { name: "x".repeat(101) }, + { name: "Key", expiresAt: "not a date" }, + { name: "Key", expiresAt: "2000-01-01T00:00:00Z" }, + { name: "Key", expiresAt: 42 }, + ]) { + const response = await call("POST", `/api/apps/${app.appId}/keys`, { + body, + }); + assert.equal(response.status, 400, JSON.stringify(body)); + } + const expiresAt = new Date(Date.now() + 86_400_000).toISOString(); + const created = await json("POST", `/api/apps/${app.appId}/keys`, { + body: { name: "Expiring", expiresAt }, + }); + assert.equal(created.status, 201); + assert.equal(created.body.expiresAt, expiresAt); + }); + + test("an app gets at most 10 active keys", async () => { + const app = await freshApp("Crowded"); + const keyIds: string[] = []; + for (let index = 0; index < 10; index += 1) { + const created = await json("POST", `/api/apps/${app.appId}/keys`, { + body: { name: `Key ${index}` }, + }); + assert.equal(created.status, 201); + keyIds.push(created.body.keyId); + } + const eleventh = await json("POST", `/api/apps/${app.appId}/keys`, { + body: { name: "Eleventh" }, + }); + assert.equal(eleventh.status, 409); + assert.equal(eleventh.body.error, "api_key_limit_reached"); + + await call("DELETE", `/api/apps/${app.appId}/keys/${keyIds[0]}`); + const replacement = await call("POST", `/api/apps/${app.appId}/keys`, { + body: { name: "Replacement" }, + }); + assert.equal(replacement.status, 201); + }); + + test("the configured bootstrap key can't be revoked while it is set", async () => { + const secret = `ml_live_${"b".repeat(64)}`; + const [defaultApp] = await listApps(owner.id); + await syncBootstrapApiKey({ + appId: defaultApp.id, + keyHash: hashApiKeySecret(secret), + keyPrefix: secret.slice(0, 12), + name: "Bootstrap", + }); + const key = await findApiKeyByHash(hashApiKeySecret(secret)); + const route = `/api/apps/${defaultApp.appId}/keys/${key!.apiKeyId}`; + + const previous = process.env.MEDIALIT_BOOTSTRAP_API_KEY; + process.env.MEDIALIT_BOOTSTRAP_API_KEY = secret; + try { + const listed = await json( + "GET", + `/api/apps/${defaultApp.appId}/keys`, + ); + const managed = listed.body.items.find( + (item: { keyId: string }) => item.keyId === key!.apiKeyId, + ); + assert.equal(managed.managedByConfiguration, true); + + const blocked = await json("DELETE", route); + assert.equal(blocked.status, 409); + assert.equal(blocked.body.error, "managed_by_configuration"); + } finally { + if (previous === undefined) { + delete process.env.MEDIALIT_BOOTSTRAP_API_KEY; + } else { + process.env.MEDIALIT_BOOTSTRAP_API_KEY = previous; + } + } + + const revoked = await call("DELETE", route); + assert.equal(revoked.status, 204); }); }); diff --git a/apps/api/src/dashboard/routes.ts b/apps/api/src/dashboard/routes.ts index 9ea56f82..a986b948 100644 --- a/apps/api/src/dashboard/routes.ts +++ b/apps/api/src/dashboard/routes.ts @@ -7,18 +7,25 @@ import { import { fromNodeHeaders } from "better-auth/node"; import rateLimit from "express-rate-limit"; import { - createApiKey, + ApiKeyLimitError, + createApp, findUserById, - getApiKeyByKeyId, - listApiKeys, - listApiKeyMediaSummaries, - renameApiKey, - setDefaultApiKey, - softDeleteApiKey, + getAppByPublicId, + listActiveApiKeys, + listAppMediaSummaries, + listApps, + renameApp, + revokeApiKey, + setDefaultApp, + softDeleteApp, type AccountUser, - type ApiKeyMediaSummary, type ApiKeyRecord, + type AppMediaSummary, + type AppRecord, } from "@/db"; +import type { ApiKey } from "@medialit/models"; +import { createApiKey, MAX_ACTIVE_API_KEYS_PER_APP } from "../apikey/queries"; +import { configuredBootstrapKeyHash } from "../apikey/bootstrap-config"; import type { MedialitAuth } from "../auth/better-auth"; import { accountSubscription, @@ -34,15 +41,88 @@ import { validatePresigningOptions } from "../signature/handlers"; import { generateSignature } from "../signature/service"; import logger from "../services/log"; -function publicApp(row: ApiKeyRecord, includeSecret: boolean) { +function publicApp(row: AppRecord) { return { - keyId: row.keyId, + appId: row.appId, name: row.name, default: row.default, - ...(includeSecret ? { key: row.key } : {}), }; } +/** A key as the dashboard sees it: public ID and display prefix only. */ +function publicApiKey( + row: ApiKeyRecord, + bootstrapKeyHash: string | null, +): ApiKey { + return { + keyId: row.apiKeyId, + name: row.name, + keyPrefix: row.keyPrefix, + createdAt: row.createdAt.toISOString(), + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + expiresAt: row.expiresAt?.toISOString() ?? null, + managedByConfiguration: row.keyHash === bootstrapKeyHash, + }; +} + +const MAX_KEY_NAME_LENGTH = 100; + +/* Validates a new key's body. `expiresAt` is optional; when given it must be + a future ISO date. */ +function parseNewKey( + body: any, +): { name: string; expiresAt?: Date } | { error: string } { + const name = String(body?.name ?? "").trim(); + if (!name) return { error: "Name is required" }; + if (name.length > MAX_KEY_NAME_LENGTH) { + return { + error: `Name must be at most ${MAX_KEY_NAME_LENGTH} characters`, + }; + } + if (body?.expiresAt === undefined || body?.expiresAt === null) { + return { name }; + } + const expiresAt = + typeof body.expiresAt === "string" ? new Date(body.expiresAt) : null; + if (!expiresAt || Number.isNaN(expiresAt.getTime())) { + return { error: "expiresAt must be an ISO 8601 date" }; + } + if (expiresAt.getTime() <= Date.now()) { + return { error: "expiresAt must be in the future" }; + } + return { name, expiresAt }; +} + +const DUPLICATE_APP_NAME = "You already have an app with this name"; + +/* Postgres unique violations, however deep Drizzle wraps them. Anything else + is unexpected and must not reach the client: its message can contain the + failed SQL. */ +function isUniqueViolation(error: unknown): boolean { + for (let current: any = error; current; current = current.cause) { + if (current.code === "23505") return true; + } + return false; +} + +/* An audit line for key changes. Never logs a secret. */ +function logApiKeyEvent( + message: string, + user: AccountUser, + app: AppRecord, + key: ApiKeyRecord, +) { + logger.info( + { + userId: user.id, + appId: app.appId, + keyId: key.apiKeyId, + keyPrefix: key.keyPrefix, + }, + message, + ); +} + /* Set by the session middleware below for every /api/account and /api/apps request, so handlers never resolve the session again. */ function currentUser(res: Response): AccountUser { @@ -51,12 +131,12 @@ function currentUser(res: Response): AccountUser { async function ownedApp(req: Request, res: Response) { const user = currentUser(res); - const key = await getApiKeyByKeyId(user.id, String(req.params.keyId)); - if (!key) { - res.status(404).json({ error: "Apikey not found" }); + const app = await getAppByPublicId(user.id, String(req.params.appId)); + if (!app) { + res.status(404).json({ error: "App not found" }); return null; } - return { user, key }; + return { user, app }; } function catchAsync( @@ -100,7 +180,7 @@ function withKind(media: T) { } type AppSummary = { - keyId: string; + appId: string; name: string; default: boolean; count: number; @@ -118,14 +198,11 @@ type AppSummary = { share: number; }; -function summarizeApp( - key: ApiKeyRecord, - summary: ApiKeyMediaSummary | undefined, -) { +function summarizeApp(app: AppRecord, summary: AppMediaSummary | undefined) { return { - keyId: key.keyId, - name: key.name, - default: key.default, + appId: app.appId, + name: app.name, + default: app.default, count: summary?.count ?? 0, storage: summary?.storage ?? 0, images: summary?.images ?? 0, @@ -143,16 +220,14 @@ function summarizeApp( /* Per-app usage for every app the user has, newest upload first, with each app's share of the account's storage. */ async function loadAppSummaries(userId: string) { - const [keys, summaries] = await Promise.all([ - listApiKeys(userId), - listApiKeyMediaSummaries(userId), + const [rows, summaries] = await Promise.all([ + listApps(userId), + listAppMediaSummaries(userId), ]); - const summaryByKey = new Map( - summaries.map((summary) => [summary.apikey, summary]), - ); - const apps = keys.map((key) => - summarizeApp(key, summaryByKey.get(key.key)), + const summaryByApp = new Map( + summaries.map((summary) => [summary.appId, summary]), ); + const apps = rows.map((app) => summarizeApp(app, summaryByApp.get(app.id))); const totalStorage = apps.reduce((total, app) => total + app.storage, 0); const storageShare = (storage: number) => { if (!totalStorage) return 0; @@ -236,8 +311,8 @@ export function createDashboardRouter(auth: MedialitAuth) { }); router.get("/api/apps", async (_req, res) => { - const rows = await listApiKeys(currentUser(res).id); - res.json(rows.map((row) => publicApp(row, false))); + const rows = await listApps(currentUser(res).id); + res.json(rows.map(publicApp)); }); // Session-only dashboard data; the public OpenAPI spec covers customer API routes. @@ -271,7 +346,7 @@ export function createDashboardRouter(auth: MedialitAuth) { totalOthers: totals.others, largestApp: largest ? { - keyId: largest.keyId, + appId: largest.appId, name: largest.name, storage: largest.storage, share: largest.share, @@ -286,51 +361,59 @@ export function createDashboardRouter(auth: MedialitAuth) { res.status(400).json({ error: "Name is required" }); return; } + const user = currentUser(res); + let app: AppRecord; try { - const created = await createApiKey({ - userId: currentUser(res).id, - name, - }); - res.status(201).json({ - key: created.key, - keyId: created.keyId, - name: created.name, - }); - } catch (error: any) { - res.status(400).json({ error: error.message }); + app = await createApp({ userId: user.id, name }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + res.status(409).json({ error: DUPLICATE_APP_NAME }); + return; } + // The first key comes with the app; its secret is only shown now. + const { apiKey, secret } = await createApiKey({ + appId: app.id, + name: "Default key", + createdById: user.id, + }); + logApiKeyEvent("API key created", user, app, apiKey); + res.status(201).json({ + ...publicApp(app), + key: secret, + apiKey: publicApiKey(apiKey, null), + }); }); - router.get("/api/apps/:keyId/summary", async (req, res) => { + router.get("/api/apps/:appId/summary", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const apps = await loadAppSummaries(owned.user.id); - const app = apps.find((summary) => summary.keyId === owned.key.keyId); + const app = apps.find((summary) => summary.appId === owned.app.appId); if (!app) { - res.status(404).json({ error: "Apikey not found" }); + res.status(404).json({ error: "App not found" }); return; } res.json(app); }); - router.get("/api/apps/:keyId/media/count", async (req, res) => { + router.get("/api/apps/:appId/media/count", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const count = await getMediaCount({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, search: querySearch(req.query.search), kind: parseMediaKind(req.query.kind), }); res.json({ count }); }); - router.get("/api/apps/:keyId/stats", async (req, res) => { + router.get("/api/apps/:appId/stats", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const storage = await getTotalSpace({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, }); res.json({ storage, @@ -340,7 +423,7 @@ export function createDashboardRouter(auth: MedialitAuth) { /* A signature for one browser (or app) upload to this app. The client uploads straight to `endpoint` with it, then seals the result below. */ - router.post("/api/apps/:keyId/media/signature", async (req, res) => { + router.post("/api/apps/:appId/media/signature", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const { error } = validatePresigningOptions(req.body); @@ -350,19 +433,19 @@ export function createDashboardRouter(auth: MedialitAuth) { } const signature = await generateSignature({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, group: req.body?.group, }); res.json({ signature, endpoint: auth.publicApiUrl }); }); - router.post("/api/apps/:keyId/media/:mediaId/seal", async (req, res) => { + router.post("/api/apps/:appId/media/:mediaId/seal", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; try { const media = await mediaService.sealMedia({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, mediaId: String(req.params.mediaId), }); res.json(withKind(media)); @@ -373,12 +456,12 @@ export function createDashboardRouter(auth: MedialitAuth) { } }); - router.get("/api/apps/:keyId/media/:mediaId", async (req, res) => { + router.get("/api/apps/:appId/media/:mediaId", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const media = await mediaService.getMediaDetails({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, mediaId: String(req.params.mediaId), }); if (!media) { @@ -388,18 +471,18 @@ export function createDashboardRouter(auth: MedialitAuth) { res.json(withKind(media)); }); - router.delete("/api/apps/:keyId/media/:mediaId", async (req, res) => { + router.delete("/api/apps/:appId/media/:mediaId", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; await mediaService.deleteMedia({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, mediaId: String(req.params.mediaId), }); res.json({ message: "success" }); }); - router.post("/api/apps/:keyId/media/delete", async (req, res) => { + router.post("/api/apps/:appId/media/delete", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const mediaIds = req.body?.mediaIds; @@ -416,13 +499,13 @@ export function createDashboardRouter(auth: MedialitAuth) { } const result = await mediaService.deleteMediaMany({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, mediaIds, }); res.json(result); }); - router.get("/api/apps/:keyId/media", async (req, res) => { + router.get("/api/apps/:appId/media", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const page = queryInteger(req.query.page, 1); @@ -441,7 +524,7 @@ export function createDashboardRouter(auth: MedialitAuth) { } const result = await mediaService.getPage({ userId: owned.user.id, - apikey: owned.key.key, + appId: owned.app.id, page, recordsPerPage: limit, search: querySearch(req.query.search), @@ -451,13 +534,83 @@ export function createDashboardRouter(auth: MedialitAuth) { res.json(result.map(withKind)); }); - router.get("/api/apps/:keyId", async (req, res) => { + router.get("/api/apps/:appId", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; - res.json(publicApp(owned.key, true)); + res.json(publicApp(owned.app)); }); - router.patch("/api/apps/:keyId", async (req, res) => { + router.get("/api/apps/:appId/keys", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const bootstrapKeyHash = configuredBootstrapKeyHash(); + const keys = await listActiveApiKeys(owned.app.id); + res.json({ + items: keys.map((key) => publicApiKey(key, bootstrapKeyHash)), + limit: MAX_ACTIVE_API_KEYS_PER_APP, + }); + }); + + router.post("/api/apps/:appId/keys", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const input = parseNewKey(req.body); + if ("error" in input) { + res.status(400).json({ error: input.error }); + return; + } + try { + const { apiKey, secret } = await createApiKey({ + appId: owned.app.id, + name: input.name, + createdById: owned.user.id, + expiresAt: input.expiresAt, + }); + logApiKeyEvent("API key created", owned.user, owned.app, apiKey); + res.status(201).json({ + ...publicApiKey(apiKey, configuredBootstrapKeyHash()), + key: secret, + }); + } catch (error) { + if (error instanceof ApiKeyLimitError) { + res.status(409).json({ + error: "api_key_limit_reached", + error_description: error.message, + }); + return; + } + throw error; + } + }); + + router.delete("/api/apps/:appId/keys/:keyId", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const keyId = String(req.params.keyId); + const keys = await listActiveApiKeys(owned.app.id); + const key = keys.find((candidate) => candidate.apiKeyId === keyId); + if (!key) { + res.status(404).json({ error: "API key not found" }); + return; + } + // Revoking it would only last until the next restart revives it. + if (key.keyHash === configuredBootstrapKeyHash()) { + res.status(409).json({ + error: "managed_by_configuration", + error_description: + "This key is set by MEDIALIT_BOOTSTRAP_API_KEY. Change or remove that setting to rotate it.", + }); + return; + } + if (!(await revokeApiKey(owned.app.id, keyId))) { + res.status(404).json({ error: "API key not found" }); + return; + } + logApiKeyEvent("API key revoked", owned.user, owned.app, key); + res.status(204).end(); + }); + + router.patch("/api/apps/:appId", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; const name = String(req.body?.name || "").trim(); @@ -466,33 +619,34 @@ export function createDashboardRouter(auth: MedialitAuth) { return; } try { - await renameApiKey({ + await renameApp({ userId: owned.user.id, - keyId: owned.key.keyId, + appId: owned.app.appId, newName: name, }); res.json({ success: true }); - } catch (error: any) { - res.status(400).json({ error: error.message }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + res.status(409).json({ error: DUPLICATE_APP_NAME }); } }); - router.patch("/api/apps/:keyId/default", async (req, res) => { + router.patch("/api/apps/:appId/default", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; try { - await setDefaultApiKey(owned.user.id, owned.key.keyId); + await setDefaultApp(owned.user.id, owned.app.appId); res.json({ success: true }); } catch (error: any) { res.status(400).json({ error: error.message }); } }); - router.delete("/api/apps/:keyId", async (req, res) => { + router.delete("/api/apps/:appId", async (req, res) => { const owned = await ownedApp(req, res); if (!owned) return; try { - await softDeleteApiKey(owned.user.id, owned.key.keyId); + await softDeleteApp(owned.user.id, owned.app.appId); res.json({ success: true }); } catch (error: any) { res.status(400).json({ error: error.message }); diff --git a/apps/api/src/db/migration-0008.test.ts b/apps/api/src/db/migration-0008.test.ts new file mode 100644 index 00000000..2b23e661 --- /dev/null +++ b/apps/api/src/db/migration-0008.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "node:test"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +const migrationsFolder = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", +); + +/** A copy of the migrations folder that stops before 0008. */ +function migrationsBefore0008(): string { + const folder = mkdtempSync(path.join(tmpdir(), "medialit-migrations-")); + cpSync(migrationsFolder, folder, { recursive: true }); + const journalPath = path.join(folder, "meta/_journal.json"); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + journal.entries = journal.entries.filter( + (entry: { tag: string }) => entry.tag < "0008", + ); + writeFileSync(journalPath, JSON.stringify(journal)); + return folder; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function databaseAt0007() { + const client = new PGlite(); + await migrate(drizzle(client), { + migrationsFolder: migrationsBefore0008(), + }); + const now = new Date().toISOString(); + await client.exec(` + INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) + VALUES ('u1', 'Owner', 'owner@example.com', true, '${now}', '${now}'); + INSERT INTO profiles (user_id, public_user_id, active) VALUES ('u1', 'pub-u1', true); + INSERT INTO api_keys (id, key_id, name, key, user_id, is_default, deleted, created_at, updated_at) + VALUES + ('app-live', 'public-live', 'Live', 'legacy-live-secret', 'u1', true, false, '${now}', '${now}'), + ('app-gone', 'public-gone', 'Gone', 'legacy-gone-secret', 'u1', false, true, '${now}', '${now}'); + INSERT INTO media (id, file_name, media_id, user_id, apikey, original_file_name, mime_type, size, created_at, updated_at) + VALUES ('m1', 'f', 'media-1', 'u1', 'legacy-live-secret', 'a.png', 'image/png', 1, '${now}', '${now}'); + INSERT INTO media_settings (id, user_id, apikey) VALUES ('s1', 'u1', 'legacy-live-secret'); + INSERT INTO signatures (id, user_id, apikey, signature, valid_till, created_at) + VALUES + ('sig1', 'u1', 'legacy-live-secret', 'sig-live', '${now}', '${now}'), + ('sig2', 'u1', 'no-such-key', 'sig-orphan', '${now}', '${now}'); + INSERT INTO tus_uploads (id, upload_id, user_id, apikey, upload_length, metadata, created_at, updated_at) + VALUES + ('t1', 'up-1', 'u1', 'legacy-live-secret', 1, '{}', '${now}', '${now}'), + ('t2', 'up-2', 'u1', 'no-such-key', 1, '{}', '${now}', '${now}'); + `); + return client; +} + +describe("migration 0008_api_keys", () => { + test("hashes legacy keys and re-keys tenant tables to the app", async () => { + const client = await databaseAt0007(); + await migrate(drizzle(client), { migrationsFolder }); + + const keys = await client.query<{ + app_id: string; + key_hash: string; + key_prefix: string; + created_by_type: string; + revoked_at: Date | null; + api_key_id: string; + }>( + `SELECT app_id, key_hash, key_prefix, created_by_type, revoked_at, api_key_id FROM api_keys ORDER BY app_id`, + ); + assert.equal(keys.rows.length, 2); + const [gone, live] = keys.rows; + assert.equal(live.app_id, "app-live"); + assert.equal(live.key_hash, sha256("legacy-live-secret")); + assert.equal(live.key_prefix, "lega"); + assert.equal(live.created_by_type, "legacy"); + assert.equal(live.revoked_at, null); + assert.match(live.api_key_id, /^ak_/); + assert.equal(gone.app_id, "app-gone"); + assert.notEqual(gone.revoked_at, null); + + const appsRows = await client.query>( + `SELECT * FROM apps WHERE id = 'app-live'`, + ); + assert.equal(appsRows.rows[0].app_id, "public-live"); + assert.equal("key" in appsRows.rows[0], false); + assert.equal("restriction" in appsRows.rows[0], false); + + const media = await client.query<{ app_id: string }>( + `SELECT app_id FROM media`, + ); + assert.deepEqual(media.rows, [{ app_id: "app-live" }]); + const settings = await client.query<{ app_id: string }>( + `SELECT app_id FROM media_settings`, + ); + assert.deepEqual(settings.rows, [{ app_id: "app-live" }]); + + const signatures = await client.query<{ + signature: string; + app_id: string; + api_key_id: string; + }>(`SELECT signature, app_id, api_key_id FROM signatures`); + assert.equal(signatures.rows.length, 1, "orphaned signature deleted"); + assert.equal(signatures.rows[0].app_id, "app-live"); + const liveKeyId = await client.query<{ id: string }>( + `SELECT id FROM api_keys WHERE app_id = 'app-live'`, + ); + assert.equal(signatures.rows[0].api_key_id, liveKeyId.rows[0].id); + + const uploads = await client.query<{ upload_id: string }>( + `SELECT upload_id FROM tus_uploads`, + ); + assert.deepEqual(uploads.rows, [{ upload_id: "up-1" }]); + + const constraints = await client.query<{ conname: string }>( + `SELECT conname FROM pg_constraint WHERE conrelid = 'public.apps'::regclass AND conname NOT LIKE 'apps\\_%\\_not\\_null' ESCAPE '\\' ORDER BY conname`, + ); + assert.deepEqual( + constraints.rows.map((row) => row.conname), + ["apps_app_id_unique", "apps_pkey", "apps_user_id_user_id_fk"], + ); + const leftovers = await client.query<{ name: string }>( + `SELECT conname AS name FROM pg_constraint WHERE conrelid = 'public.apps'::regclass AND conname LIKE 'api\\_keys\\_%' ESCAPE '\\' + UNION ALL + SELECT indexname AS name FROM pg_indexes WHERE tablename = 'apps' AND indexname LIKE 'api\\_keys\\_%' ESCAPE '\\'`, + ); + assert.deepEqual( + leftovers.rows, + [], + "no api_keys_* names left on apps", + ); + }); + + test("refuses to drop media whose key matches no app", async () => { + const client = await databaseAt0007(); + const now = new Date().toISOString(); + await client.exec(` + INSERT INTO media (id, file_name, media_id, user_id, apikey, original_file_name, mime_type, size, created_at, updated_at) + VALUES ('m2', 'f', 'media-2', 'u1', 'no-such-key', 'b.png', 'image/png', 1, '${now}', '${now}'); + `); + await assert.rejects( + migrate(drizzle(client), { migrationsFolder }), + /media rows reference an API key that matches no app/, + ); + const stillOld = await client.query<{ apikey: string }>( + `SELECT apikey FROM media WHERE id = 'm2'`, + ); + assert.equal(stillOld.rows[0].apikey, "no-such-key"); + }); +}); diff --git a/apps/api/src/db/repository.test.ts b/apps/api/src/db/repository.test.ts index a2e08d77..f6f9df75 100644 --- a/apps/api/src/db/repository.test.ts +++ b/apps/api/src/db/repository.test.ts @@ -3,25 +3,36 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { before, describe, test } from "node:test"; import { PGlite } from "@electric-sql/pglite"; +import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; -import { setDb, type AppDb } from "./client.js"; +import { getDb, setDb, type AppDb } from "./client.js"; import * as schema from "./schema/index.js"; import { + ApiKeyLimitError, countMedia, createAccount, - createApiKey, + createApp, createMediaRecord, createSignature, deleteExpiredSignatures, - getApiKeyByKeyId, - getApiKeyBySecret, + findApiKeyByHash, + findApiKeyForAuth, + getAppByPublicId, getMediaRecord, getSignature, + insertApiKey, + listActiveApiKeys, + listApps, listMedia, + revokeApiKey, sealMediaRecord, - softDeleteApiKey, + softDeleteApp, + syncBootstrapApiKey, + touchApiKeyLastUsed, } from "./repository.js"; +import { createApiKey, getApiKeyBySecret } from "../apikey/queries.js"; +import { hashApiKeySecret } from "../apikey/secret.js"; before(async () => { const client = new PGlite(); @@ -35,23 +46,22 @@ before(async () => { setDb(db as unknown as AppDb); }); +let accountCount = 0; +async function account() { + accountCount += 1; + return createAccount({ email: `owner${accountCount}@example.com` }); +} + describe("repository", () => { - test("keeps plaintext keys, temp semantics, and group prefixes", async () => { - const user = await createAccount({ - email: "owner@example.com", - name: "Owner", - }); - const extra = await createApiKey({ - userId: user.id, - name: "Second", - key: "plain-secret", - }); + test("keeps temp semantics and group prefixes per app", async () => { + const user = await account(); + const app = await createApp({ userId: user.id, name: "Second" }); await createMediaRecord({ fileName: "objects/a", mediaId: "media-sealed", userId: user.id, - apikey: extra.key, + appId: app.id, originalFileName: "a.png", mimeType: "image/png", size: 10, @@ -64,7 +74,7 @@ describe("repository", () => { fileName: "objects/b", mediaId: "media-listed", userId: user.id, - apikey: extra.key, + appId: app.id, originalFileName: "b.png", mimeType: "image/png", size: 20, @@ -74,14 +84,11 @@ describe("repository", () => { temp: false, }); - assert.equal( - await countMedia({ userId: user.id, apikey: extra.key }), - 1, - ); + assert.equal(await countMedia({ userId: user.id, appId: app.id }), 1); assert.notEqual( await getMediaRecord({ userId: user.id, - apikey: extra.key, + appId: app.id, mediaId: "media-sealed", }), null, @@ -89,17 +96,14 @@ describe("repository", () => { await sealMediaRecord({ userId: user.id, - apikey: extra.key, + appId: app.id, mediaId: "media-sealed", }); - assert.equal( - await countMedia({ userId: user.id, apikey: extra.key }), - 2, - ); + assert.equal(await countMedia({ userId: user.id, appId: app.id }), 2); const prefixed = await listMedia({ userId: user.id, - apikey: extra.key, + appId: app.id, group: "photos", }); assert.deepEqual( @@ -109,17 +113,239 @@ describe("repository", () => { const signature = await createSignature({ userId: user.id, - apikey: extra.key, + appId: app.id, validTill: new Date(Date.now() - 1000), }); assert.notEqual(await getSignature(signature.signature), null); await deleteExpiredSignatures(user.id); assert.equal(await getSignature(signature.signature), null); + }); + + test("a new account gets a default app with no API key", async () => { + const user = await account(); + const apps = await listApps(user.id); + + assert.equal(apps.length, 1); + assert.equal(apps[0].default, true); + assert.deepEqual(await listActiveApiKeys(apps[0].id), []); + }); +}); + +describe("API keys", () => { + test("stores only the hash and display prefix", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey, secret } = await createApiKey({ + appId: app.id, + name: "Server", + createdById: user.id, + }); + + assert.match(secret, /^ml_live_[A-Za-z0-9_-]{43}$/); + assert.match(apiKey.apiKeyId, /^ak_/); + assert.equal(apiKey.keyPrefix, secret.slice(0, 12)); + assert.equal(apiKey.keyHash, hashApiKeySecret(secret)); + const [row] = await getDb() + .select() + .from(schema.apiKeys) + .where(eq(schema.apiKeys.id, apiKey.id)); + assert.equal(JSON.stringify(row).includes(secret), false); + }); + + test("resolves a secret to its key, app and owner", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey, secret } = await createApiKey({ + appId: app.id, + name: "Server", + }); + + const found = await getApiKeyBySecret(secret); + assert.equal(found?.key.id, apiKey.id); + assert.equal(found?.app.id, app.id); + assert.equal(found?.ownerActive, true); + assert.equal(await getApiKeyBySecret(`${secret}x`), null); + }); + + test("revoked keys, expired keys and keys of deleted apps don't resolve", async () => { + const user = await account(); + const app = await createApp({ userId: user.id, name: "Doomed" }); + const revoked = await createApiKey({ appId: app.id, name: "Revoked" }); + const expired = await insertApiKey({ + appId: app.id, + name: "Expired", + keyHash: hashApiKeySecret("expired-secret"), + keyPrefix: "expi", + createdByType: "user", + expiresAt: new Date(Date.now() - 1000), + }); + const live = await createApiKey({ appId: app.id, name: "Live" }); + + assert.equal(await revokeApiKey(app.id, revoked.apiKey.apiKeyId), true); + assert.equal(await findApiKeyForAuth(revoked.apiKey.keyHash), null); + assert.equal(await findApiKeyForAuth(expired.keyHash), null); + assert.notEqual(await findApiKeyForAuth(live.apiKey.keyHash), null); + assert.deepEqual( + (await listActiveApiKeys(app.id)).map((key) => key.name), + ["Live"], + ); + + await softDeleteApp(user.id, app.appId); + assert.equal(await findApiKeyForAuth(live.apiKey.keyHash), null); + assert.equal(await getAppByPublicId(user.id, app.appId), null); + assert.notEqual( + (await findApiKeyByHash(live.apiKey.keyHash))?.revokedAt, + undefined, + "deleting the app revokes its keys", + ); + }); + + test("revoking a missing or already revoked key returns false", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey } = await createApiKey({ appId: app.id, name: "Once" }); + + assert.equal(await revokeApiKey(app.id, "ak_missing"), false); + assert.equal(await revokeApiKey(app.id, apiKey.apiKeyId), true); + assert.equal(await revokeApiKey(app.id, apiKey.apiKeyId), false); + }); + + test("deactivated owners are reported, not hidden", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { secret } = await createApiKey({ appId: app.id, name: "Key" }); + await getDb() + .update(schema.profiles) + .set({ active: false }) + .where(eq(schema.profiles.userId, user.id)); + + assert.equal((await getApiKeyBySecret(secret))?.ownerActive, false); + }); + + test("records last use at most every five minutes", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey } = await createApiKey({ appId: app.id, name: "Key" }); + + await touchApiKeyLastUsed(apiKey.id); + const first = (await findApiKeyByHash(apiKey.keyHash))?.lastUsedAt; + assert.ok(first); + await touchApiKeyLastUsed(apiKey.id); + const second = (await findApiKeyByHash(apiKey.keyHash))?.lastUsedAt; + assert.equal(second?.getTime(), first.getTime()); + }); + + test("an app has at most 10 active keys", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const keys = []; + for (let index = 0; index < 10; index += 1) { + keys.push( + await createApiKey({ appId: app.id, name: `Key ${index}` }), + ); + } + + await assert.rejects( + createApiKey({ appId: app.id, name: "Eleventh" }), + ApiKeyLimitError, + ); + await revokeApiKey(app.id, keys[0].apiKey.apiKeyId); + await createApiKey({ appId: app.id, name: "Replacement" }); + }); + + test("revoking a key deletes the signatures it minted, and only those", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey } = await createApiKey({ appId: app.id, name: "Key" }); + const minted = await createSignature({ + userId: user.id, + appId: app.id, + apiKeyId: apiKey.id, + }); + const dashboard = await createSignature({ + userId: user.id, + appId: app.id, + }); + + await revokeApiKey(app.id, apiKey.apiKeyId); + assert.equal(await getSignature(minted.signature), null); + assert.notEqual(await getSignature(dashboard.signature), null); + }); + + test("deleting an app deletes all of its signatures", async () => { + const user = await account(); + const app = await createApp({ userId: user.id, name: "Doomed" }); + const signature = await createSignature({ + userId: user.id, + appId: app.id, + }); + + await softDeleteApp(user.id, app.appId); + assert.equal(await getSignature(signature.signature), null); + }); +}); + +describe("bootstrap keys", () => { + test("syncs the configured key and keeps it on its app", async () => { + const user = await account(); + const [defaultApp] = await listApps(user.id); + const other = await createApp({ userId: user.id, name: "Other" }); + const sync = (appId: string, secret: string) => + syncBootstrapApiKey({ + appId, + keyHash: hashApiKeySecret(secret), + keyPrefix: secret.slice(0, 12), + name: "Bootstrap", + }); + + assert.equal(await sync(defaultApp.id, "first-secret"), "created"); + assert.equal(await sync(defaultApp.id, "first-secret"), "unchanged"); + // A later default app doesn't move or break the existing key. + assert.equal(await sync(other.id, "first-secret"), "unchanged"); + assert.equal( + (await findApiKeyForAuth(hashApiKeySecret("first-secret")))?.app.id, + defaultApp.id, + ); - await softDeleteApiKey(user.id, extra.keyId); - assert.equal(await getApiKeyByKeyId(user.id, extra.keyId), null); - const hidden = await getApiKeyBySecret("plain-secret"); - assert.equal(hidden?.deleted, true); - assert.equal(hidden?.key, "plain-secret"); + const signature = await createSignature({ + userId: user.id, + appId: defaultApp.id, + apiKeyId: (await findApiKeyByHash( + hashApiKeySecret("first-secret"), + ))!.id, + }); + assert.equal(await sync(defaultApp.id, "second-secret"), "created"); + assert.equal( + await findApiKeyForAuth(hashApiKeySecret("first-secret")), + null, + "rotating revokes the old key", + ); + assert.equal(await getSignature(signature.signature), null); + + assert.equal(await sync(defaultApp.id, "first-secret"), "restored"); + assert.notEqual( + await findApiKeyForAuth(hashApiKeySecret("first-secret")), + null, + ); + assert.equal( + await findApiKeyForAuth(hashApiKeySecret("second-secret")), + null, + ); + }); + + test("refuses a value that belongs to a dashboard key", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const { apiKey } = await createApiKey({ appId: app.id, name: "Mine" }); + + assert.equal( + await syncBootstrapApiKey({ + appId: app.id, + keyHash: apiKey.keyHash, + keyPrefix: apiKey.keyPrefix, + name: "Bootstrap", + }), + "conflict", + ); }); }); diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index 5039558e..225b07a6 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -3,18 +3,24 @@ import { getUniqueId } from "@medialit/utils"; import { and, asc, + count, desc, eq, + gt, ilike, inArray, + isNull, like, lt, + ne, + or, sql, type SQL, } from "drizzle-orm"; import { getDb } from "./client.js"; import { apiKeys, + apps, media, mediaSettings, oauthAppSelections, @@ -23,6 +29,7 @@ import { signatures, tusUploads, user, + type ApiKeyCreatedByType, } from "./schema/index.js"; const SIGNATURE_LENGTH = 100; @@ -39,31 +46,58 @@ export type AccountUser = { name?: string; }; -const API_KEY_RESTRICTIONS = ["referrer", "ipaddress", "custom"] as const; -type ApiKeyRestriction = (typeof API_KEY_RESTRICTIONS)[number]; - -export type ApiKeyRecord = { +/** An app: the tenant that media, settings, signatures and uploads belong + * to. `id` is internal; `appId` is the public ID. */ +export type AppRecord = { id: string; - keyId: string; + appId: string; name: string; - key: string; userId: string; - restriction?: ApiKeyRestriction; - httpReferrers: string[]; - ipAddresses: string[]; default: boolean; deleted: boolean; createdAt: Date; updatedAt: Date; }; +/** An API key. `id` is internal, `apiKeyId` is the public ID, and `appId` is + * the internal ID of the app it belongs to. The secret is never stored. */ +export type ApiKeyRecord = { + id: string; + apiKeyId: string; + appId: string; + name: string; + keyHash: string; + keyPrefix: string; + expiresAt?: Date; + lastUsedAt?: Date; + revokedAt?: Date; + createdByType: ApiKeyCreatedByType; + createdById?: string; + createdAt: Date; +}; + +/** What authenticating with an API key resolves to. */ +export type ApiKeyAuthRecord = { + key: ApiKeyRecord; + app: AppRecord; + ownerActive: boolean; +}; + +export class ApiKeyLimitError extends Error { + constructor(readonly limit: number) { + super(`An app can have at most ${limit} active API keys`); + this.name = "ApiKeyLimitError"; + } +} + export type MediaRecord = { id: string; _id: string; fileName: string; mediaId: string; userId: string; - apikey: string; + /** Internal ID of the app the media belongs to. */ + appId: string; originalFileName: string; mimeType: string; size: number; @@ -76,8 +110,9 @@ export type MediaRecord = { updatedAt: Date; }; -export type ApiKeyMediaSummary = { - apikey: string; +export type AppMediaSummary = { + /** Internal app ID. */ + appId: string; count: number; storage: number; images: number; @@ -93,7 +128,7 @@ export type ApiKeyMediaSummary = { export type MediaSettingsRecord = { userId: string; - apikey: string; + appId: string; useWebP?: boolean; webpOutputQuality?: number; thumbnailWidth?: number; @@ -103,12 +138,18 @@ export type MediaSettingsRecord = { export type SignatureRecord = { id: string; userId: string; - apikey: string; + appId: string; + /** The key that minted it, if it was minted with an API key. */ + apiKeyId?: string; signature: string; validTill: Date; group?: string; }; +/** A signature that is still usable: its app exists and its key, if any, is + * not revoked. `ownerActive` lets callers reject deactivated accounts. */ +export type ActiveSignatureRecord = SignatureRecord & { ownerActive: boolean }; + export type TusMetadata = { fileName: string; mimeType: string; @@ -122,7 +163,7 @@ export type TusUploadRecord = { _id: string; uploadId: string; userId: string; - apikey: string; + appId: string; uploadLength: number; uploadOffset: number; metadata: TusMetadata; @@ -151,20 +192,12 @@ function mapUser( }; } -function mapKey(row: typeof apiKeys.$inferSelect): ApiKeyRecord { +function mapApp(row: typeof apps.$inferSelect): AppRecord { return { id: row.id, - keyId: row.keyId, + appId: row.appId, name: row.name, - key: row.key, userId: row.userId, - restriction: API_KEY_RESTRICTIONS.includes( - row.restriction as ApiKeyRestriction, - ) - ? (row.restriction as ApiKeyRestriction) - : undefined, - httpReferrers: row.httpReferrers ?? [], - ipAddresses: row.ipAddresses ?? [], default: row.isDefault, deleted: row.deleted, createdAt: row.createdAt, @@ -172,6 +205,23 @@ function mapKey(row: typeof apiKeys.$inferSelect): ApiKeyRecord { }; } +function mapApiKey(row: typeof apiKeys.$inferSelect): ApiKeyRecord { + return { + id: row.id, + apiKeyId: row.apiKeyId, + appId: row.appId, + name: row.name, + keyHash: row.keyHash, + keyPrefix: row.keyPrefix, + expiresAt: row.expiresAt ?? undefined, + lastUsedAt: row.lastUsedAt ?? undefined, + revokedAt: row.revokedAt ?? undefined, + createdByType: row.createdByType, + createdById: row.createdById ?? undefined, + createdAt: row.createdAt, + }; +} + function mapMedia(row: typeof media.$inferSelect): MediaRecord { return { id: row.id, @@ -179,7 +229,7 @@ function mapMedia(row: typeof media.$inferSelect): MediaRecord { fileName: row.fileName, mediaId: row.mediaId, userId: row.userId, - apikey: row.apikey, + appId: row.appId, originalFileName: row.originalFileName, mimeType: row.mimeType, size: row.size, @@ -289,15 +339,16 @@ export async function ensureProfile(input: { active: input.active ?? true, }); } - const keys = await db - .select({ id: apiKeys.id }) - .from(apiKeys) - .where( - and(eq(apiKeys.userId, input.userId), eq(apiKeys.deleted, false)), - ) + // The default app has no API key: a key nobody has seen would be a live + // credential with no use. Keys are created in the dashboard, or from + // MEDIALIT_BOOTSTRAP_API_KEY. + const existingApps = await db + .select({ id: apps.id }) + .from(apps) + .where(and(eq(apps.userId, input.userId), eq(apps.deleted, false))) .limit(1); - if (!keys[0]) { - await createApiKey({ + if (!existingApps[0]) { + await createApp({ userId: input.userId, name: process.env.DEFAULT_APP_NAME || "My Store", isDefault: true, @@ -321,180 +372,417 @@ export async function createAccount(input: { }); } -export async function createApiKey(input: { +export async function createApp(input: { userId: string; name: string; isDefault?: boolean; - key?: string; - keyId?: string; - restriction?: string; - httpReferrers?: string[]; - ipAddresses?: string[]; + /** Public ID; generated when omitted. The Mongo importer passes one. */ + appId?: string; deleted?: boolean; -}): Promise { +}): Promise { const db = getDb(); const timestamp = now(); const [row] = await db - .insert(apiKeys) + .insert(apps) .values({ id: randomUUID(), - keyId: input.keyId || getUniqueId(), + appId: input.appId || getUniqueId(), name: input.name, - key: input.key || getUniqueId(), userId: input.userId, - restriction: input.restriction, - httpReferrers: input.httpReferrers ?? [], - ipAddresses: input.ipAddresses ?? [], deleted: input.deleted ?? false, isDefault: input.isDefault ?? false, createdAt: timestamp, updatedAt: timestamp, }) .returning(); - if (!row) throw new Error("Failed to create API key"); - return mapKey(row); + if (!row) throw new Error("Failed to create app"); + return mapApp(row); } -export async function getApiKeyBySecret( - key: string, -): Promise { - const db = getDb(); - const [row] = await db - .select() - .from(apiKeys) - .where(eq(apiKeys.key, key)) - .limit(1); - return row ? mapKey(row) : null; -} - -export async function listApiKeys( +export async function listApps( userId: string, - keyId?: string, -): Promise { + appId?: string, +): Promise { const db = getDb(); - const filters = [eq(apiKeys.userId, userId), eq(apiKeys.deleted, false)]; - if (keyId) filters.push(eq(apiKeys.keyId, keyId)); + const filters = [eq(apps.userId, userId), eq(apps.deleted, false)]; + if (appId) filters.push(eq(apps.appId, appId)); const rows = await db .select() - .from(apiKeys) + .from(apps) .where(and(...filters)) - .orderBy(asc(apiKeys.createdAt)); - return rows.map(mapKey); + .orderBy(asc(apps.createdAt)); + return rows.map(mapApp); } -export async function getApiKeyByKeyId( +/** The user's app with this public ID, unless it is deleted. */ +export async function getAppByPublicId( userId: string, - keyId: string, -): Promise { - const rows = await listApiKeys(userId, keyId); + appId: string, +): Promise { + const rows = await listApps(userId, appId); return rows[0] ?? null; } -export async function renameApiKey(input: { +/** The app with this internal ID, deleted or not. */ +export async function getAppById(id: string): Promise { + const db = getDb(); + const [row] = await db.select().from(apps).where(eq(apps.id, id)).limit(1); + return row ? mapApp(row) : null; +} + +export async function renameApp(input: { userId: string; - keyId?: string; + appId?: string; name?: string; newName: string; }): Promise { const db = getDb(); - const filters = [ - eq(apiKeys.userId, input.userId), - eq(apiKeys.deleted, false), - ]; - if (input.keyId) filters.push(eq(apiKeys.keyId, input.keyId)); - if (input.name) filters.push(eq(apiKeys.name, input.name)); + const filters = [eq(apps.userId, input.userId), eq(apps.deleted, false)]; + if (input.appId) filters.push(eq(apps.appId, input.appId)); + if (input.name) filters.push(eq(apps.name, input.name)); await db - .update(apiKeys) + .update(apps) .set({ name: input.newName, updatedAt: now() }) .where(and(...filters)); } -export async function setDefaultApiKey( +export async function setDefaultApp( userId: string, - keyId: string, + appId: string, ): Promise { const db = getDb(); await db.transaction(async (tx) => { const [target] = await tx - .select({ id: apiKeys.id, isDefault: apiKeys.isDefault }) - .from(apiKeys) + .select({ id: apps.id, isDefault: apps.isDefault }) + .from(apps) .where( and( - eq(apiKeys.userId, userId), - eq(apiKeys.keyId, keyId), - eq(apiKeys.deleted, false), + eq(apps.userId, userId), + eq(apps.appId, appId), + eq(apps.deleted, false), ), ) .limit(1); - if (!target) throw new Error("Apikey not found"); + if (!target) throw new Error("App not found"); if (target.isDefault) return; const timestamp = now(); await tx - .update(apiKeys) + .update(apps) .set({ isDefault: false, updatedAt: timestamp }) .where( and( - eq(apiKeys.userId, userId), - eq(apiKeys.isDefault, true), - eq(apiKeys.deleted, false), + eq(apps.userId, userId), + eq(apps.isDefault, true), + eq(apps.deleted, false), ), ); await tx - .update(apiKeys) + .update(apps) .set({ isDefault: true, updatedAt: timestamp }) - .where(eq(apiKeys.id, target.id)); + .where(eq(apps.id, target.id)); }); } -export async function softDeleteApiKey( +/** Deletes the app, revokes all of its keys and deletes its signatures, so + * nothing issued for it keeps working. The default app can't be deleted. */ +export async function softDeleteApp( userId: string, - keyId: string, + appId: string, ): Promise { const db = getDb(); - const existing = await getApiKeyByKeyId(userId, keyId); + const existing = await getAppByPublicId(userId, appId); if (!existing) return; if (existing.default) { - throw new Error("Default API key cannot be deleted"); + throw new Error("Default app cannot be deleted"); } + await db.transaction(async (tx) => { + const timestamp = now(); + await tx + .update(apps) + .set({ deleted: true, updatedAt: timestamp }) + .where(eq(apps.id, existing.id)); + await tx + .update(apiKeys) + .set({ revokedAt: timestamp }) + .where( + and(eq(apiKeys.appId, existing.id), isNull(apiKeys.revokedAt)), + ); + await tx.delete(signatures).where(eq(signatures.appId, existing.id)); + }); +} + +/** Not revoked and not expired. */ +function activeKeyCondition(at: Date): SQL { + return and( + isNull(apiKeys.revokedAt), + or(isNull(apiKeys.expiresAt), gt(apiKeys.expiresAt, at)), + )!; +} + +export async function insertApiKey(input: { + appId: string; + name: string; + keyHash: string; + keyPrefix: string; + createdByType: ApiKeyCreatedByType; + createdById?: string; + expiresAt?: Date; + /** When set, refuse with ApiKeyLimitError if the app already has this many + * active keys. The app row is locked so concurrent creates can't race. */ + limit?: number; +}): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + if (input.limit !== undefined) { + await tx + .select({ id: apps.id }) + .from(apps) + .where(eq(apps.id, input.appId)) + .for("update"); + const [active] = await tx + .select({ count: count() }) + .from(apiKeys) + .where( + and( + eq(apiKeys.appId, input.appId), + activeKeyCondition(now()), + ), + ); + if (Number(active?.count ?? 0) >= input.limit) { + throw new ApiKeyLimitError(input.limit); + } + } + const [row] = await tx + .insert(apiKeys) + .values({ + id: randomUUID(), + apiKeyId: `ak_${getUniqueId()}`, + appId: input.appId, + name: input.name, + keyHash: input.keyHash, + keyPrefix: input.keyPrefix, + expiresAt: input.expiresAt, + createdByType: input.createdByType, + createdById: input.createdById, + createdAt: now(), + }) + .returning(); + if (!row) throw new Error("Failed to create API key"); + return mapApiKey(row); + }); +} + +/** The app's usable keys, oldest first. */ +export async function listActiveApiKeys( + appId: string, +): Promise { + const db = getDb(); + const rows = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.appId, appId), activeKeyCondition(now()))) + .orderBy(asc(apiKeys.createdAt)); + return rows.map(mapApiKey); +} + +/** The usable key with this hash, its app and whether the owner is active. + * Revoked or expired keys and keys of deleted apps don't resolve. */ +export async function findApiKeyForAuth( + keyHash: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ key: apiKeys, app: apps, ownerActive: profiles.active }) + .from(apiKeys) + .innerJoin(apps, eq(apps.id, apiKeys.appId)) + .innerJoin(profiles, eq(profiles.userId, apps.userId)) + .where( + and( + eq(apiKeys.keyHash, keyHash), + activeKeyCondition(now()), + eq(apps.deleted, false), + ), + ) + .limit(1); + if (!row) return null; + return { + key: mapApiKey(row.key), + app: mapApp(row.app), + ownerActive: row.ownerActive, + }; +} + +/** The key with this hash in any state. */ +export async function findApiKeyByHash( + keyHash: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.keyHash, keyHash)) + .limit(1); + return row ? mapApiKey(row) : null; +} + +/** Revokes an active key of the app and deletes the signatures it minted. + * Returns false when the app has no active key with that public ID. */ +export async function revokeApiKey( + appId: string, + apiKeyId: string, +): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const revoked = await tx + .update(apiKeys) + .set({ revokedAt: now() }) + .where( + and( + eq(apiKeys.appId, appId), + eq(apiKeys.apiKeyId, apiKeyId), + activeKeyCondition(now()), + ), + ) + .returning({ id: apiKeys.id }); + if (!revoked[0]) return false; + await tx + .delete(signatures) + .where(eq(signatures.apiKeyId, revoked[0].id)); + return true; + }); +} + +/** Records use at most once every five minutes, so busy keys don't write on + * every request. */ +export async function touchApiKeyLastUsed(id: string): Promise { + const db = getDb(); await db .update(apiKeys) - .set({ deleted: true, updatedAt: now() }) - .where(and(eq(apiKeys.userId, userId), eq(apiKeys.keyId, keyId))); + .set({ lastUsedAt: now() }) + .where( + and( + eq(apiKeys.id, id), + or( + isNull(apiKeys.lastUsedAt), + lt(apiKeys.lastUsedAt, sql`now() - interval '5 minutes'`), + ), + ), + ); } -/** Upserts so revisiting the app picker overwrites the earlier choice. */ +export type BootstrapKeyOutcome = + | "unchanged" + | "created" + | "restored" + | "conflict"; + +/** + * Makes the key with this hash the one active bootstrap key. A new key goes on + * `appId`; an existing bootstrap key stays on its app, so changing the default + * app later doesn't move or break it. Every other active bootstrap key is + * revoked and its signatures deleted. `conflict` means the hash belongs to a + * key that isn't a bootstrap key. + */ +export async function syncBootstrapApiKey(input: { + appId: string; + keyHash: string; + keyPrefix: string; + name: string; +}): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(apiKeys) + .where(eq(apiKeys.keyHash, input.keyHash)) + .limit(1); + if (existing && existing.createdByType !== "bootstrap") { + return "conflict"; + } + + const timestamp = now(); + const others = await tx + .update(apiKeys) + .set({ revokedAt: timestamp }) + .where( + and( + eq(apiKeys.createdByType, "bootstrap"), + isNull(apiKeys.revokedAt), + ne(apiKeys.keyHash, input.keyHash), + ), + ) + .returning({ id: apiKeys.id }); + if (others.length > 0) { + await tx.delete(signatures).where( + inArray( + signatures.apiKeyId, + others.map((key) => key.id), + ), + ); + } + + if (existing) { + if (!existing.revokedAt && !existing.expiresAt) return "unchanged"; + await tx + .update(apiKeys) + .set({ revokedAt: null, expiresAt: null }) + .where(eq(apiKeys.id, existing.id)); + return "restored"; + } + + // Another replica starting at the same time may insert it first. + const inserted = await tx + .insert(apiKeys) + .values({ + id: randomUUID(), + apiKeyId: `ak_${getUniqueId()}`, + appId: input.appId, + name: input.name, + keyHash: input.keyHash, + keyPrefix: input.keyPrefix, + createdByType: "bootstrap", + createdAt: timestamp, + }) + .onConflictDoNothing({ target: apiKeys.keyHash }) + .returning({ id: apiKeys.id }); + return inserted.length > 0 ? "created" : "unchanged"; + }); +} + +/** Upserts so revisiting the app picker overwrites the earlier choice. + * `appId` is the internal app ID. */ export async function setOAuthAppSelection( sessionId: string, - apiKeyId: string, + appId: string, ): Promise { const db = getDb(); await db .insert(oauthAppSelections) - .values({ sessionId, apiKeyId }) + .values({ sessionId, appId }) .onConflictDoUpdate({ target: oauthAppSelections.sessionId, - set: { apiKeyId, updatedAt: now() }, + set: { appId, updatedAt: now() }, }); } -/** The app picked for this session, or `null` if the user has not picked one - * or the app has since been deleted. */ +/** The public ID of the app picked for this session, or `null` if the user + * has not picked one or the app has since been deleted. */ export async function getOAuthAppSelection( sessionId: string, -): Promise<{ keyId: string; updatedAt: Date } | null> { +): Promise<{ appId: string; updatedAt: Date } | null> { const db = getDb(); const [row] = await db .select({ - keyId: apiKeys.keyId, + appId: apps.appId, updatedAt: oauthAppSelections.updatedAt, }) .from(oauthAppSelections) - .innerJoin(apiKeys, eq(apiKeys.id, oauthAppSelections.apiKeyId)) + .innerJoin(apps, eq(apps.id, oauthAppSelections.appId)) .where( and( eq(oauthAppSelections.sessionId, sessionId), - eq(apiKeys.deleted, false), + eq(apps.deleted, false), ), ) .limit(1); @@ -538,7 +826,7 @@ export async function createMediaRecord( fileName: input.fileName, mediaId: input.mediaId, userId: input.userId, - apikey: input.apikey, + appId: input.appId, originalFileName: input.originalFileName, mimeType: input.mimeType, size: input.size, @@ -557,7 +845,7 @@ export async function createMediaRecord( export async function getMediaRecord(input: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { const db = getDb(); @@ -567,7 +855,7 @@ export async function getMediaRecord(input: { .where( and( eq(media.userId, input.userId), - eq(media.apikey, input.apikey), + eq(media.appId, input.appId), eq(media.mediaId, input.mediaId), ), ) @@ -577,7 +865,7 @@ export async function getMediaRecord(input: { export async function countMedia(input: { userId: string; - apikey: string; + appId: string; access?: "public" | "private"; group?: string; search?: string; @@ -586,7 +874,7 @@ export async function countMedia(input: { const db = getDb(); const filters = [ eq(media.userId, input.userId), - eq(media.apikey, input.apikey), + eq(media.appId, input.appId), eq(media.temp, false), ]; if (input.access) filters.push(eq(media.accessControl, input.access)); @@ -611,7 +899,7 @@ export async function countMedia(input: { export async function listMedia(input: { userId: string; - apikey: string; + appId: string; access?: "public" | "private"; group?: string; page?: number; @@ -625,7 +913,7 @@ export async function listMedia(input: { const offset = input.page ? (input.page - 1) * limit : 0; const filters = [ eq(media.userId, input.userId), - eq(media.apikey, input.apikey), + eq(media.appId, input.appId), eq(media.temp, false), ]; if (input.access) filters.push(eq(media.accessControl, input.access)); @@ -661,11 +949,11 @@ export async function listMedia(input: { export async function totalMediaSize(input: { userId: string; - apikey?: string; + appId?: string; }): Promise { const db = getDb(); const filters = [eq(media.userId, input.userId), eq(media.temp, false)]; - if (input.apikey) filters.push(eq(media.apikey, input.apikey)); + if (input.appId) filters.push(eq(media.appId, input.appId)); const [row] = await db .select({ total: sql`coalesce(sum(${media.size}), 0)::float8` }) .from(media) @@ -673,20 +961,20 @@ export async function totalMediaSize(input: { return Number(row?.total ?? 0); } -export async function listApiKeyMediaSummaries( +export async function listAppMediaSummaries( userId: string, - apikey?: string, -): Promise { + appId?: string, +): Promise { const db = getDb(); const filters = [eq(media.userId, userId), eq(media.temp, false)]; - if (apikey) filters.push(eq(media.apikey, apikey)); + if (appId) filters.push(eq(media.appId, appId)); const countOf = (kind: MediaKindFilter) => sql`count(*) filter (where ${mediaKindCondition(kind)})::int`; const storageOf = (kind: MediaKindFilter) => sql`coalesce(sum(${media.size}) filter (where ${mediaKindCondition(kind)}), 0)::float8`; const rows = await db .select({ - apikey: media.apikey, + appId: media.appId, count: sql`count(*)::int`, storage: sql`coalesce(sum(${media.size}), 0)::float8`, images: countOf("image"), @@ -701,7 +989,7 @@ export async function listApiKeyMediaSummaries( }) .from(media) .where(and(...filters)) - .groupBy(media.apikey); + .groupBy(media.appId); return rows.map((row) => ({ ...row, @@ -721,7 +1009,7 @@ export async function listApiKeyMediaSummaries( export async function listMediaRecordsByIds(input: { userId: string; - apikey: string; + appId: string; mediaIds: string[]; }): Promise { if (input.mediaIds.length === 0) return []; @@ -732,7 +1020,7 @@ export async function listMediaRecordsByIds(input: { .where( and( eq(media.userId, input.userId), - eq(media.apikey, input.apikey), + eq(media.appId, input.appId), inArray(media.mediaId, input.mediaIds), ), ); @@ -751,7 +1039,7 @@ export async function deleteMediaRecord( export async function sealMediaRecord(input: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { const db = getDb(); @@ -761,7 +1049,7 @@ export async function sealMediaRecord(input: { .where( and( eq(media.userId, input.userId), - eq(media.apikey, input.apikey), + eq(media.appId, input.appId), eq(media.mediaId, input.mediaId), ), ); @@ -785,7 +1073,7 @@ export async function deleteMediaById(id: string): Promise { export async function getMediaSettingsRecord( userId: string, - apikey: string, + appId: string, ): Promise { const db = getDb(); const [row] = await db @@ -794,14 +1082,14 @@ export async function getMediaSettingsRecord( .where( and( eq(mediaSettings.userId, userId), - eq(mediaSettings.apikey, apikey), + eq(mediaSettings.appId, appId), ), ) .limit(1); if (!row) return null; return { userId: row.userId, - apikey: row.apikey, + appId: row.appId, useWebP: row.useWebP ?? undefined, webpOutputQuality: row.webpOutputQuality ?? undefined, thumbnailWidth: row.thumbnailWidth ?? undefined, @@ -818,14 +1106,14 @@ export async function upsertMediaSettings( .values({ id: randomUUID(), userId: input.userId, - apikey: input.apikey, + appId: input.appId, useWebP: input.useWebP, webpOutputQuality: input.webpOutputQuality, thumbnailWidth: input.thumbnailWidth, thumbnailHeight: input.thumbnailHeight, }) .onConflictDoUpdate({ - target: mediaSettings.apikey, + target: mediaSettings.appId, set: { useWebP: input.useWebP, webpOutputQuality: input.webpOutputQuality, @@ -837,7 +1125,8 @@ export async function upsertMediaSettings( export async function createSignature(input: { userId: string; - apikey: string; + appId: string; + apiKeyId?: string; group?: string; id?: string; signature?: string; @@ -850,7 +1139,8 @@ export async function createSignature(input: { .values({ id: input.id || randomUUID(), userId: input.userId, - apikey: input.apikey, + appId: input.appId, + apiKeyId: input.apiKeyId, signature: input.signature || getUniqueId(SIGNATURE_LENGTH), validTill: input.validTill || @@ -860,34 +1150,46 @@ export async function createSignature(input: { }) .returning(); if (!row) throw new Error("Failed to create signature"); + return mapSignature(row); +} + +function mapSignature(row: typeof signatures.$inferSelect): SignatureRecord { return { id: row.id, userId: row.userId, - apikey: row.apikey, + appId: row.appId, + apiKeyId: row.apiKeyId ?? undefined, signature: row.signature, validTill: row.validTill, group: row.group || undefined, }; } +/** The signature, if its app is not deleted and the key that minted it (if + * any) is not revoked. Expiry is left to the caller. */ export async function getSignature( signature: string, -): Promise { +): Promise { const db = getDb(); const [row] = await db - .select() + .select({ + signature: signatures, + ownerActive: profiles.active, + }) .from(signatures) - .where(eq(signatures.signature, signature)) + .innerJoin(apps, eq(apps.id, signatures.appId)) + .innerJoin(profiles, eq(profiles.userId, apps.userId)) + .leftJoin(apiKeys, eq(apiKeys.id, signatures.apiKeyId)) + .where( + and( + eq(signatures.signature, signature), + eq(apps.deleted, false), + or(isNull(signatures.apiKeyId), isNull(apiKeys.revokedAt)), + ), + ) .limit(1); if (!row) return null; - return { - id: row.id, - userId: row.userId, - apikey: row.apikey, - signature: row.signature, - validTill: row.validTill, - group: row.group || undefined, - }; + return { ...mapSignature(row.signature), ownerActive: row.ownerActive }; } export async function deleteSignatureById(id: string): Promise { @@ -912,7 +1214,7 @@ export async function deleteExpiredSignatures(userId: string): Promise { export async function createTusUploadRecord(input: { uploadId: string; userId: string; - apikey: string; + appId: string; uploadLength: number; metadata: TusMetadata; tempFilePath?: string; @@ -927,7 +1229,7 @@ export async function createTusUploadRecord(input: { id: randomUUID(), uploadId: input.uploadId, userId: input.userId, - apikey: input.apikey, + appId: input.appId, uploadLength: input.uploadLength, uploadOffset: 0, metadata: input.metadata, @@ -949,7 +1251,7 @@ function mapTus(row: typeof tusUploads.$inferSelect): TusUploadRecord { _id: row.id, uploadId: row.uploadId, userId: row.userId, - apikey: row.apikey, + appId: row.appId, uploadLength: row.uploadLength, uploadOffset: row.uploadOffset, metadata: row.metadata as TusMetadata, diff --git a/apps/api/src/db/schema/domain.ts b/apps/api/src/db/schema/domain.ts index f36c8ec3..2556b95c 100644 --- a/apps/api/src/db/schema/domain.ts +++ b/apps/api/src/db/schema/domain.ts @@ -1,6 +1,8 @@ import { bigint, boolean, + check, + index, jsonb, pgTable, text, @@ -18,87 +20,158 @@ export const profiles = pgTable("profiles", { active: boolean("active").notNull().default(true), }); -export const apiKeys = pgTable( - "api_keys", +/** + * An app is a tenant: every media record, setting, signature and upload belongs + * to one. `id` is internal and every foreign key references it. `app_id` is the + * public ID used in URLs, the dashboard API and the OAuth `app_id` claim. + */ +export const apps = pgTable( + "apps", { id: text("id").primaryKey(), - keyId: text("key_id").notNull().unique(), + appId: text("app_id").notNull().unique(), name: text("name").notNull(), - key: text("key").notNull().unique(), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), - restriction: text("restriction"), - httpReferrers: text("http_referrers") - .array() - .notNull() - .default(sql`'{}'::text[]`), - ipAddresses: text("ip_addresses") - .array() - .notNull() - .default(sql`'{}'::text[]`), deleted: boolean("deleted").notNull().default(false), isDefault: boolean("is_default").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }, (table) => ({ - userName: uniqueIndex("api_keys_user_name_uidx") + userName: uniqueIndex("apps_user_name_uidx") .on(table.userId, table.name) .where(sql`${table.deleted} = false`), - oneDefault: uniqueIndex("api_keys_one_default_uidx") + oneDefault: uniqueIndex("apps_one_default_uidx") .on(table.userId) .where(sql`${table.isDefault} = true AND ${table.deleted} = false`), }), ); -export const media = pgTable("media", { - id: text("id").primaryKey(), - fileName: text("file_name").notNull(), - mediaId: text("media_id").notNull().unique(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - apikey: text("apikey").notNull(), - originalFileName: text("original_file_name").notNull(), - mimeType: text("mime_type").notNull(), - size: bigint("size", { mode: "number" }).notNull(), - thumbnailGenerated: boolean("thumbnail_generated").notNull().default(false), - accessControl: text("access_control").notNull().default("private"), - group: text("group_name"), - caption: text("caption"), - temp: boolean("temp").notNull().default(true), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), -}); +export const API_KEY_CREATED_BY_TYPES = [ + "user", + "bootstrap", + "legacy", +] as const; +export type ApiKeyCreatedByType = (typeof API_KEY_CREATED_BY_TYPES)[number]; + +/** + * An app's API keys. Only a SHA-256 hash of the secret is stored; the secret + * itself is shown once, when the key is created. `api_key_id` is the public ID. + */ +export const apiKeys = pgTable( + "api_keys", + { + id: text("id").primaryKey(), + apiKeyId: text("api_key_id").notNull().unique(), + appId: text("app_id") + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + name: text("name").notNull(), + keyHash: text("key_hash").notNull().unique(), + keyPrefix: text("key_prefix").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdByType: text("created_by_type") + .$type() + .notNull(), + createdById: text("created_by_id").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + appIdx: index("api_keys_app_id_idx").on(table.appId), + createdByTypeCheck: check( + "api_keys_created_by_type_check", + sql`${table.createdByType} IN ('user', 'bootstrap', 'legacy')`, + ), + }), +); + +export const media = pgTable( + "media", + { + id: text("id").primaryKey(), + fileName: text("file_name").notNull(), + mediaId: text("media_id").notNull().unique(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + appId: text("app_id") + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + originalFileName: text("original_file_name").notNull(), + mimeType: text("mime_type").notNull(), + size: bigint("size", { mode: "number" }).notNull(), + thumbnailGenerated: boolean("thumbnail_generated") + .notNull() + .default(false), + accessControl: text("access_control").notNull().default("private"), + group: text("group_name"), + caption: text("caption"), + temp: boolean("temp").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + owner: index("media_owner_idx").on( + table.userId, + table.appId, + table.temp, + ), + }), +); export const mediaSettings = pgTable("media_settings", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), - apikey: text("apikey").notNull().unique(), + appId: text("app_id") + .notNull() + .unique() + .references(() => apps.id, { onDelete: "cascade" }), useWebP: boolean("use_web_p"), webpOutputQuality: bigint("webp_output_quality", { mode: "number" }), thumbnailWidth: bigint("thumbnail_width", { mode: "number" }), thumbnailHeight: bigint("thumbnail_height", { mode: "number" }), }); -export const signatures = pgTable("signatures", { - id: text("id").primaryKey(), - userId: text("user_id").notNull(), - apikey: text("apikey").notNull(), - signature: text("signature").notNull().unique(), - validTill: timestamp("valid_till", { withTimezone: true }).notNull(), - group: text("group_name"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), -}); +export const signatures = pgTable( + "signatures", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + appId: text("app_id") + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + /** The key that minted it; null when minted through OAuth or the + * dashboard. Revoking the key deletes its signatures. */ + apiKeyId: text("api_key_id").references(() => apiKeys.id, { + onDelete: "cascade", + }), + signature: text("signature").notNull().unique(), + validTill: timestamp("valid_till", { withTimezone: true }).notNull(), + group: text("group_name"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + apiKeyIdx: index("signatures_api_key_id_idx").on(table.apiKeyId), + }), +); export const tusUploads = pgTable("tus_uploads", { id: text("id").primaryKey(), uploadId: text("upload_id").notNull().unique(), userId: text("user_id").notNull(), - apikey: text("apikey").notNull(), + appId: text("app_id") + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), uploadLength: bigint("upload_length", { mode: "number" }).notNull(), uploadOffset: bigint("upload_offset", { mode: "number" }) .notNull() @@ -122,9 +195,9 @@ export const oauthAppSelections = pgTable("oauth_app_selections", { sessionId: text("session_id") .primaryKey() .references(() => session.id, { onDelete: "cascade" }), - apiKeyId: text("api_key_id") + appId: text("app_id") .notNull() - .references(() => apiKeys.id, { onDelete: "cascade" }), + .references(() => apps.id, { onDelete: "cascade" }), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7ea4ce4f..3c127dd5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,9 +4,7 @@ loadDotFile(); import { checkDatabaseConnection } from "./config/db"; import { closeMcpSessions } from "./mcp/routes"; import logger, { shutdownObservability } from "./services/log"; -import { createUser, findByEmail } from "./user/queries"; -import { User } from "@medialit/models"; -import { getApiKeyByUserId } from "./apikey/queries"; +import { bootstrap, checkBootstrapConfig } from "./bootstrap"; import { closeDb, getDb, seedWebOAuthClient } from "@/db"; import { sql } from "drizzle-orm"; import type { GracefulShutdown } from "@codelitdev/platform"; @@ -54,8 +52,22 @@ checkConfig() await seedWebOAuthClient({ redirectUris: [`${webOrigin}/api/auth/callback/medialit`], }); - if (process.env.EMAIL) { - await createAdminUser(); + // Before listening: a bundled product must find its key working as + // soon as this API reports healthy. + try { + const result = await bootstrap(); + if (result.key) { + logger.info( + { appId: result.appId, outcome: result.key }, + "Bootstrap API key ready", + ); + } + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : error }, + "Bootstrap failed", + ); + process.exit(1); } const stopBillingMaintenance = startBillingMaintenance(); const server = app.listen(port, () => { @@ -137,6 +149,7 @@ async function checkConfig() { "If CDN_ENDPOINT is not set, both CLOUD_ENDPOINT and CLOUD_ENDPOINT_PUBLIC must be provided", ); } + checkBootstrapConfig(); if ( !process.env.BETTER_AUTH_SECRET || Buffer.byteLength(process.env.BETTER_AUTH_SECRET, "utf8") < 32 @@ -176,19 +189,3 @@ async function checkDependencies() { process.exit(1); } } - -async function createAdminUser() { - try { - const email = process.env.EMAIL!.toLowerCase(); - const user: User | null = await findByEmail(email); - - if (!user) { - const user = await createUser(email); - const keys = await getApiKeyByUserId(user.id); - const firstKey = Array.isArray(keys) ? keys[0] : keys; - logger.info({ apiKey: firstKey?.key }, "Admin user created"); - } - } catch (error) { - logger.error({ error }, "Failed to create admin user"); - } -} diff --git a/apps/api/src/mcp/auth-context.ts b/apps/api/src/mcp/auth-context.ts index 0fa04840..34305794 100644 --- a/apps/api/src/mcp/auth-context.ts +++ b/apps/api/src/mcp/auth-context.ts @@ -5,9 +5,12 @@ export type MedialitMcpAuthExtra = { /** Internal id of the authenticated account. */ userId: string; user: any; - /** The app's API key. OAuth requests act on the app picked at + /** Internal ID of the app. OAuth requests act on the app picked at * authorization; API-key requests act on the key's own app. */ - apikey: string; + appId: string; + /** Internal ID of the API key, for API-key requests only. Signatures + * record it so revoking the key revokes them. Never the secret. */ + apiKeyId?: string; }; export type MedialitMcpAuthInfo = AuthInfo & { @@ -18,7 +21,8 @@ type McpAuthRequest = { authKind?: unknown; userId?: unknown; clientId?: unknown; - apikey?: unknown; + appId?: unknown; + apiKeyId?: unknown; scopes?: unknown; user?: any; }; @@ -32,8 +36,8 @@ export function createMcpAuthInfo(input: unknown): MedialitMcpAuthInfo | null { (req.authKind !== "oauth" && req.authKind !== "apikey") || typeof req.userId !== "string" || !req.userId || - typeof req.apikey !== "string" || - !req.apikey || + typeof req.appId !== "string" || + !req.appId || !req.user ) { return null; @@ -56,7 +60,10 @@ export function createMcpAuthInfo(input: unknown): MedialitMcpAuthInfo | null { authKind: req.authKind, userId: req.userId, user: req.user, - apikey: req.apikey, + appId: req.appId, + ...(req.authKind === "apikey" && typeof req.apiKeyId === "string" + ? { apiKeyId: req.apiKeyId } + : {}), }, }; } @@ -68,7 +75,7 @@ export function getMcpAuth(extra: any): MedialitMcpAuthExtra | null { !auth || (auth.authKind !== "oauth" && auth.authKind !== "apikey") || !auth.userId || - !auth.apikey || + !auth.appId || !auth.user ) { return null; diff --git a/apps/api/src/mcp/tools/media.ts b/apps/api/src/mcp/tools/media.ts index 7515b345..e383f170 100644 --- a/apps/api/src/mcp/tools/media.ts +++ b/apps/api/src/mcp/tools/media.ts @@ -50,13 +50,13 @@ export function registerMediaTools(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { const currentPage = args.page || 1; const recordsPerPage = args.limit || 10; const result = await mediaService.getPage({ userId, - apikey, + appId, access: args.access, page: currentPage, group: args.group, @@ -94,11 +94,11 @@ export function registerMediaTools(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { const media = await mediaService.getMediaDetails({ userId, - apikey, + appId, mediaId: args.mediaId, }); if (!media) { @@ -141,11 +141,11 @@ export function registerMediaTools(server: McpToolRegistrar): void { async (_args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { const count = await mediaQueries.getMediaCount({ userId, - apikey, + appId, }); return { content: [ @@ -198,11 +198,11 @@ export function registerMediaTools(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { await mediaService.deleteMedia({ userId, - apikey, + appId, mediaId: args.mediaId, }); const response = { message: SUCCESS }; @@ -240,11 +240,11 @@ export function registerMediaTools(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { const media = await mediaService.sealMedia({ userId, - apikey, + appId, mediaId: args.mediaId, }); return { @@ -267,9 +267,9 @@ export async function handleGetTotalStorageTool( ) { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { user, userId, apikey } = auth; + const { user, userId, appId } = auth; try { - const storage = await dependencies.getTotalSpace({ userId, apikey }); + const storage = await dependencies.getTotalSpace({ userId, appId }); const response = { storage, maxStorage: await maxStorageFor(user), diff --git a/apps/api/src/mcp/tools/settings.ts b/apps/api/src/mcp/tools/settings.ts index f0dc28a2..a81a37c1 100644 --- a/apps/api/src/mcp/tools/settings.ts +++ b/apps/api/src/mcp/tools/settings.ts @@ -26,9 +26,9 @@ export function registerSettingsTools(server: McpToolRegistrar): void { async (_args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { - const settings = await getMediaSettings(userId, apikey); + const settings = await getMediaSettings(userId, appId); return { content: [ { @@ -86,11 +86,11 @@ export function registerSettingsTools(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId } = auth; try { await updateMediaSettings({ userId, - apikey, + appId, useWebP: args.useWebP, webpOutputQuality: args.webpOutputQuality, thumbnailWidth: args.thumbnailWidth, diff --git a/apps/api/src/mcp/tools/signature.ts b/apps/api/src/mcp/tools/signature.ts index fdccdc91..774fdd54 100644 --- a/apps/api/src/mcp/tools/signature.ts +++ b/apps/api/src/mcp/tools/signature.ts @@ -31,11 +31,12 @@ export function registerSignatureTool(server: McpToolRegistrar): void { async (args: any, extra: any) => { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { userId, apikey } = auth; + const { userId, appId, apiKeyId } = auth; try { const signature = await generateSignature({ userId, - apikey, + appId, + apiKeyId, group: args.group, }); return { diff --git a/apps/api/src/mcp/tools/upload.ts b/apps/api/src/mcp/tools/upload.ts index a43a34d4..3a2090e7 100644 --- a/apps/api/src/mcp/tools/upload.ts +++ b/apps/api/src/mcp/tools/upload.ts @@ -95,7 +95,7 @@ export function registerUploadTool(server: McpToolRegistrar): void { export async function handleUploadMediaTool(args: any, extra: any) { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { user, userId, apikey } = auth; + const { user, userId, appId } = auth; // Validate base64 if (!isValidBase64(args.fileBase64)) { @@ -143,7 +143,7 @@ export async function handleUploadMediaTool(args: any, extra: any) { const mediaId = await mediaService.upload({ userId, - apikey, + appId, file: uploadedFile, access: args.access || "private", caption: args.caption || "", @@ -151,7 +151,7 @@ export async function handleUploadMediaTool(args: any, extra: any) { }); const media = await mediaService.getMediaDetails({ userId, - apikey, + appId, mediaId, }); diff --git a/apps/api/src/mcp/tools/whoami.ts b/apps/api/src/mcp/tools/whoami.ts index 5b580953..3015b00b 100644 --- a/apps/api/src/mcp/tools/whoami.ts +++ b/apps/api/src/mcp/tools/whoami.ts @@ -1,4 +1,4 @@ -import { getApiKeyUsingKeyId } from "../../apikey/queries"; +import { getAppById } from "@/db"; import { maxStorageFor } from "../../billing/entitlements"; import * as mediaQueries from "../../media/queries"; import { getMcpAuth } from "../auth-context"; @@ -29,27 +29,27 @@ export async function handleWhoamiTool( _args: unknown, extra: any, dependencies = { - getApiKey: getApiKeyUsingKeyId, + getApp: getAppById, getMediaCount: mediaQueries.getMediaCount, getTotalSpace: mediaQueries.getTotalSpace, }, ) { const auth = getMcpAuth(extra); if (!auth) return AUTH_ERROR; - const { authKind, user, userId, apikey } = auth; + const { authKind, user, userId, appId } = auth; try { const [app, files, storage] = await Promise.all([ - dependencies.getApiKey(apikey), - dependencies.getMediaCount({ userId, apikey }), - dependencies.getTotalSpace({ userId, apikey }), + dependencies.getApp(appId), + dependencies.getMediaCount({ userId, appId }), + dependencies.getTotalSpace({ userId, appId }), ]); - if (!app) return AUTH_ERROR; - // Identify the app by its public id; never return the key itself. + if (!app || app.deleted) return AUTH_ERROR; + // Identify the app by its public ID; never an internal ID or a key. const response = { auth: authKind, email: user.email, app: { - id: app.keyId, + id: app.appId, name: app.name, default: Boolean(app.default), }, diff --git a/apps/api/src/media-settings/handlers.ts b/apps/api/src/media-settings/handlers.ts index 4ed8f244..54cc1b26 100644 --- a/apps/api/src/media-settings/handlers.ts +++ b/apps/api/src/media-settings/handlers.ts @@ -27,7 +27,7 @@ export async function updateMediaSettingsHandler( try { await updateMediaSettings({ userId: req.user.id, - apikey: req.apikey, + appId: req.appId, useWebP, webpOutputQuality, thumbnailWidth, @@ -44,7 +44,7 @@ export async function getMediaSettingsHandler(req: any, res: any) { try { const mediaSettings = await mediaSettingsService.getMediaSettings( req.user.id, - req.apikey, + req.appId, ); return res.status(200).json(mediaSettings); } catch (err: any) { diff --git a/apps/api/src/media-settings/model.ts b/apps/api/src/media-settings/model.ts index 5cd1285b..692c6526 100644 --- a/apps/api/src/media-settings/model.ts +++ b/apps/api/src/media-settings/model.ts @@ -1,6 +1,6 @@ export interface MediaSettings { userId: string | undefined; - apikey: string; + appId: string; useWebP?: boolean; webpOutputQuality?: number; thumbnailWidth?: number; diff --git a/apps/api/src/media-settings/queries.ts b/apps/api/src/media-settings/queries.ts index e9b60104..a62ab526 100644 --- a/apps/api/src/media-settings/queries.ts +++ b/apps/api/src/media-settings/queries.ts @@ -4,14 +4,14 @@ import { getMediaSettingsRecord, upsertMediaSettings } from "@/db"; export async function getMediaSettings( userId: string, - apikey: string, + appId: string, ): Promise { - return getMediaSettingsRecord(userId, apikey); + return getMediaSettingsRecord(userId, appId); } export async function updateMediaSettings({ userId, - apikey, + appId, useWebP, webpOutputQuality, thumbnailWidth, @@ -19,7 +19,7 @@ export async function updateMediaSettings({ }: UpdateMediaSettingsProps): Promise { await upsertMediaSettings({ userId, - apikey, + appId, useWebP, webpOutputQuality, thumbnailWidth, diff --git a/apps/api/src/media-settings/service.ts b/apps/api/src/media-settings/service.ts index e6fe61f9..f53817db 100644 --- a/apps/api/src/media-settings/service.ts +++ b/apps/api/src/media-settings/service.ts @@ -7,9 +7,9 @@ import { export async function getMediaSettings( userId: string, - apikey: string, + appId: string, ): Promise { - const mediaSettings = await queries.getMediaSettings(userId, apikey); + const mediaSettings = await queries.getMediaSettings(userId, appId); if (!mediaSettings) { return { @@ -31,7 +31,7 @@ export async function getMediaSettings( export interface UpdateMediaSettingsProps { userId: string; - apikey: string; + appId: string; useWebP: boolean; webpOutputQuality: number; thumbnailWidth: number; @@ -40,7 +40,7 @@ export interface UpdateMediaSettingsProps { export async function updateMediaSettings({ userId, - apikey, + appId, useWebP, webpOutputQuality, thumbnailWidth, @@ -48,7 +48,7 @@ export async function updateMediaSettings({ }: UpdateMediaSettingsProps): Promise { await queries.updateMediaSettings({ userId, - apikey, + appId, useWebP, webpOutputQuality, thumbnailWidth, diff --git a/apps/api/src/media/GetPageProps.ts b/apps/api/src/media/GetPageProps.ts index d294ab5c..0c42fcab 100644 --- a/apps/api/src/media/GetPageProps.ts +++ b/apps/api/src/media/GetPageProps.ts @@ -2,7 +2,7 @@ import { AccessControl } from "@medialit/models"; export default interface GetPageProps { userId: string; - apikey: string; + appId: string; page?: number; access?: AccessControl; group?: string; diff --git a/apps/api/src/media/handlers.ts b/apps/api/src/media/handlers.ts index e5105f6a..9c21971c 100644 --- a/apps/api/src/media/handlers.ts +++ b/apps/api/src/media/handlers.ts @@ -30,14 +30,16 @@ export async function uploadMedia( } const { file } = req.files; - const { access, caption, group } = req.body; + const { access, caption } = req.body; + // A signature's group wins over the form's. + const group = req.group ?? req.body.group; const userId = req.user.id; - const apikey = req.apikey; + const appId = req.appId; try { const mediaId = await mediaService.upload({ userId, - apikey, + appId, file, access, caption, @@ -47,7 +49,7 @@ export async function uploadMedia( const media = await mediaService.getMediaDetails({ userId: req.user.id, - apikey, + appId, mediaId, }); @@ -76,7 +78,7 @@ export async function getMedia( try { const result = await mediaService.getPage({ userId: req.user._id, - apikey: req.apikey, + appId: req.appId, access, page, group, @@ -91,10 +93,10 @@ export async function getMedia( export async function getMediaCount(req: any, res: any) { const userId = req.user._id; - const apikey = req.apikey; + const appId = req.appId; try { - const totalMediaFiles = await getCount({ userId, apikey }); + const totalMediaFiles = await getCount({ userId, appId }); return res.status(200).json({ count: totalMediaFiles }); } catch (err: any) { return res.status(500).json({ error: err.message }); @@ -103,10 +105,10 @@ export async function getMediaCount(req: any, res: any) { export async function getTotalSpaceOccupied(req: any, res: any) { const userId = req.user._id; - const apikey = req.apikey; + const appId = req.appId; try { - const totalSpaceOccupied = await getTotalSpace({ userId, apikey }); + const totalSpaceOccupied = await getTotalSpace({ userId, appId }); return res.status(200).json({ storage: totalSpaceOccupied, maxStorage: await maxStorageFor(req.user), @@ -122,7 +124,7 @@ export async function getMediaDetails(req: any, res: any) { try { const media = await mediaService.getMediaDetails({ userId: req.user.id, - apikey: req.apikey, + appId: req.appId, mediaId, }); if (!media) { @@ -142,7 +144,7 @@ export async function deleteMedia(req: any, res: any) { try { await mediaService.deleteMedia({ userId: req.user.id, - apikey: req.apikey, + appId: req.appId, mediaId, }); @@ -167,7 +169,7 @@ export async function sealMedia(req: any, res: any) { try { const media = await mediaService.sealMedia({ userId: req.user.id, - apikey: req.apikey, + appId: req.appId, mediaId, }); diff --git a/apps/api/src/media/queries.ts b/apps/api/src/media/queries.ts index 0334da44..670e2c4b 100644 --- a/apps/api/src/media/queries.ts +++ b/apps/api/src/media/queries.ts @@ -16,20 +16,20 @@ import { export function buildMediaCountQuery({ userId, - apikey, + appId, access, group, search, kind, }: { userId: string; - apikey: string; + appId: string; access?: AccessControl; group?: string; search?: string; kind?: GetPageProps["kind"]; }) { - return { userId: String(userId), apikey, access, group, search, kind }; + return { userId: String(userId), appId, access, group, search, kind }; } function escapeRegex(value: string): string { @@ -40,27 +40,27 @@ export { escapeRegex }; export async function getMedia({ userId, - apikey, + appId, mediaId, }: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { - const row = await getMediaRecord({ userId, apikey, mediaId }); + const row = await getMediaRecord({ userId, appId, mediaId }); return row; } export async function getMediaCount({ userId, - apikey, + appId, access, group, search, kind, }: { userId: string; - apikey: string; + appId: string; access?: AccessControl; group?: string; search?: string; @@ -68,7 +68,7 @@ export async function getMediaCount({ }): Promise { return countMedia({ userId: String(userId), - apikey, + appId, access: access === Constants.AccessControl.PRIVATE ? "private" @@ -83,17 +83,17 @@ export async function getMediaCount({ export async function getTotalSpace({ userId, - apikey, + appId, }: { userId: string; - apikey?: string; + appId?: string; }): Promise { - return totalMediaSize({ userId: String(userId), apikey }); + return totalMediaSize({ userId: String(userId), appId }); } export async function getPaginatedMedia({ userId, - apikey, + appId, access, page, group, @@ -104,7 +104,7 @@ export async function getPaginatedMedia({ }: GetPageProps): Promise> { return listMedia({ userId: String(userId), - apikey, + appId, access: access ? access === Constants.AccessControl.PRIVATE ? "private" @@ -133,7 +133,7 @@ export async function createMedia( fileName: mediaData.fileName, mediaId: mediaData.mediaId, userId: String(mediaData.userId), - apikey: mediaData.apikey, + appId: mediaData.appId, originalFileName: mediaData.originalFileName, mimeType: mediaData.mimeType, size: mediaData.size, diff --git a/apps/api/src/media/routes.ts b/apps/api/src/media/routes.ts index d101cb81..7890fde8 100644 --- a/apps/api/src/media/routes.ts +++ b/apps/api/src/media/routes.ts @@ -1,10 +1,5 @@ -import express, { Request, Response } from "express"; +import express from "express"; import cors from "cors"; -import fileUpload from "express-fileupload"; -import { - tempFileDirForUploads, - maxFileUploadSizeSubscribed, -} from "../config/constants"; import apikey from "../apikey/middleware"; import { getMedia, @@ -15,9 +10,12 @@ import { getMediaCount, getTotalSpaceOccupied, } from "./handlers"; -import signatureMiddleware from "../signature/middleware"; import storage from "./storage-middleware"; -import { getSignatureFromReq } from "../signature/utils"; +import { + authenticateUpload, + parseUpload, + preflightUploadSize, +} from "./upload-guards"; import { authenticatedApiLimiter } from "../auth/limiters"; import { DATA_READ_SCOPE, DATA_WRITE_SCOPE } from "../auth/options"; import { requireScope } from "../auth/scopes"; @@ -77,28 +75,11 @@ router.post( */ cors(), authenticatedApiLimiter, - fileUpload({ - useTempFiles: true, - tempFileDir: tempFileDirForUploads, - limits: { - fileSize: maxFileUploadSizeSubscribed, - files: 1, - }, - }), - (req: Request, res: Response, next: (...args: any[]) => void) => { - const signature = getSignatureFromReq(req); - if (signature) { - signatureMiddleware( - req as Request & { user: any; apikey: string }, - res, - next, - ); - } else { - apikey(req, res, () => - requireScope(DATA_WRITE_SCOPE)(req, res, next), - ); - } - }, + // Authenticate and check the declared size before reading the body, so + // an unauthenticated or oversized upload never reaches the disk. + authenticateUpload, + preflightUploadSize, + parseUpload, storage, uploadMedia, ); diff --git a/apps/api/src/media/service.ts b/apps/api/src/media/service.ts index 526a386f..4751536d 100644 --- a/apps/api/src/media/service.ts +++ b/apps/api/src/media/service.ts @@ -87,7 +87,7 @@ const generateAndUploadThumbnail = async ({ interface UploadProps { userId: string; - apikey: string; + appId: string; file: any; access: string; caption: string; @@ -97,7 +97,7 @@ interface UploadProps { async function upload({ userId, - apikey, + appId, file, access, caption, @@ -105,7 +105,7 @@ async function upload({ signature, }: UploadProps): Promise { const fileName = generateFileName(file.name); - const mediaSettings = await getMediaSettings(userId, apikey); + const mediaSettings = await getMediaSettings(userId, appId); const useWebP = mediaSettings?.useWebP || false; const webpOutputQuality = mediaSettings?.webpOutputQuality || 0; @@ -166,7 +166,7 @@ async function upload({ fileName: `main.${fileExtension}`, mediaId: fileName.name, userId, - apikey, + appId, originalFileName: file.name, mimeType, size: file.size, @@ -203,7 +203,7 @@ type MappedMedia = Partial< async function getPage({ userId, - apikey, + appId, access, page, group, @@ -214,7 +214,7 @@ async function getPage({ }: GetPageProps): Promise { const result = await getPaginatedMedia({ userId, - apikey, + appId, access, page, group, @@ -245,13 +245,13 @@ async function getPage({ async function getMediaCountWithFilter({ userId, - apikey, + appId, access, group, }: GetPageProps): Promise { return await getMediaCount({ userId: String(userId), - apikey, + appId, access, group, }); @@ -259,16 +259,16 @@ async function getMediaCountWithFilter({ async function getMediaDetails({ userId, - apikey, + appId, mediaId, }: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { const media: MediaWithUserId | null = await getMedia({ userId, - apikey, + appId, mediaId, }); if (!media) { @@ -376,14 +376,14 @@ async function removeStoredFiles(media: MediaWithUserId): Promise { async function deleteMedia({ userId, - apikey, + appId, mediaId, }: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { - const media = await getMedia({ userId, apikey, mediaId }); + const media = await getMedia({ userId, appId, mediaId }); if (!media) return; await mediaService.removeStoredFiles(media); @@ -397,17 +397,17 @@ const DELETE_CONCURRENCY = 10; failed. */ async function deleteMediaMany({ userId, - apikey, + appId, mediaIds, }: { userId: string; - apikey: string; + appId: string; mediaIds: string[]; }): Promise<{ deleted: number; failed: number }> { const uniqueIds = Array.from(new Set(mediaIds)); const found = await listMediaRecordsByIds({ userId, - apikey, + appId, mediaIds: uniqueIds, }); @@ -435,14 +435,14 @@ async function deleteMediaMany({ async function sealMedia({ userId, - apikey, + appId, mediaId, }: { userId: string; - apikey: string; + appId: string; mediaId: string; }): Promise { - const media = await getMedia({ userId, apikey, mediaId }); + const media = await getMedia({ userId, appId, mediaId }); if (!media) { throw new Error("Media not found"); } @@ -535,10 +535,10 @@ async function sealMedia({ } // Update media record to remove temp flag - await sealMediaRecord({ mediaId, userId: String(userId), apikey }); + await sealMediaRecord({ mediaId, userId: String(userId), appId }); // Fetch and return the updated media - const updatedMedia = await getMedia({ userId, apikey, mediaId }); + const updatedMedia = await getMedia({ userId, appId, mediaId }); if (!updatedMedia) { throw new Error("Failed to retrieve updated media"); } diff --git a/apps/api/src/media/upload-guards.test.ts b/apps/api/src/media/upload-guards.test.ts new file mode 100644 index 00000000..325e5bcf --- /dev/null +++ b/apps/api/src/media/upload-guards.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readdirSync } from "node:fs"; +import http, { type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, afterEach, before, describe, mock, test } from "node:test"; +import { PGlite } from "@electric-sql/pglite"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import express from "express"; + +// Uploads land here; read before the modules that capture it load. +const uploadDir = mkdtempSync(path.join(tmpdir(), "medialit-upload-guards-")); +process.env.TEMP_FILE_DIR_FOR_UPLOADS = uploadDir; + +let server: Server; +let baseUrl: string; +let secret: string; +let appId: string; +let ownerId: string; +let db: any; +let schema: typeof import("../db/schema/index.js"); +let mediaQueries: typeof import("./queries").default; + +before(async () => { + schema = await import("../db/schema/index.js"); + const { setDb, createAccount, listApps } = await import("@/db"); + const { createApiKey } = await import("../apikey/queries"); + const guards = await import("./upload-guards"); + mediaQueries = (await import("./queries")).default; + + const client = new PGlite(); + db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", + ), + }); + setDb(db); + + const owner = await createAccount({ email: "uploader@example.com" }); + ownerId = owner.id; + const [app] = await listApps(owner.id); + appId = app.id; + ({ secret } = await createApiKey({ appId: app.id, name: "Uploads" })); + + const appServer = express(); + appServer.post( + "/media/create", + guards.authenticateUpload, + guards.preflightUploadSize, + guards.parseUpload, + (req: any, res) => { + res.json({ + appId: req.appId, + file: req.files?.file?.name ?? null, + }); + }, + ); + server = appServer.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +afterEach(() => mock.restoreAll()); + +function multipart(fields: Record, fileBody = "hello") { + const form = new FormData(); + for (const [name, value] of Object.entries(fields)) { + form.append(name, value); + } + form.append("file", new Blob([fileBody]), "hello.txt"); + return form; +} + +function upload(headers: Record, form = multipart({})) { + return fetch(`${baseUrl}/media/create`, { + method: "POST", + headers, + body: form, + }); +} + +/** Sends only the headers and a few body bytes, then reports the response: + * proof the server answered without reading the declared body. */ +function declareUpload( + headers: Record, +): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const request = http.request(`${baseUrl}/media/create`, { + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=x", + ...headers, + }, + }); + request.on("response", (response) => { + let data = ""; + response.on("data", (chunk) => (data += chunk)); + response.on("end", () => { + request.destroy(); + resolve({ + status: response.statusCode!, + body: JSON.parse(data), + }); + }); + }); + request.on("error", (error: any) => { + if (error.code !== "ECONNRESET" && error.code !== "EPIPE") { + reject(error); + } + }); + request.write("--x\r\n"); + }); +} + +function tempFiles(): string[] { + return readdirSync(uploadDir); +} + +describe("upload guards", () => { + test("rejects an upload without credentials before writing it", async () => { + const before = tempFiles().length; + const response = await upload({}); + assert.equal(response.status, 401); + assert.equal(tempFiles().length, before); + }); + + test("ignores a key sent in the form body", async () => { + const before = tempFiles().length; + const response = await upload({}, multipart({ apikey: secret })); + assert.equal(response.status, 401); + const body = await response.json(); + assert.match(body.error_description, /x-medialit-apikey/); + assert.equal(tempFiles().length, before); + }); + + test("accepts the key header and tags the upload with its app", async () => { + const response = await upload({ "x-medialit-apikey": secret }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + appId, + file: "hello.txt", + }); + }); + + test("rejects a revoked key", async () => { + const { createApiKey } = await import("../apikey/queries"); + const { revokeApiKey } = await import("@/db"); + const revoked = await createApiKey({ appId, name: "Gone" }); + await revokeApiKey(appId, revoked.apiKey.apiKeyId); + + const response = await upload({ + "x-medialit-apikey": revoked.secret, + }); + assert.equal(response.status, 401); + }); + + test("rejects a declared size over the plan limit before reading it", async () => { + const response = await declareUpload({ + "x-medialit-apikey": secret, + "content-length": String(500 * 1024 * 1024), + }); + assert.equal(response.status, 400); + assert.match(response.body.error, /Allowed/); + }); + + test("rejects a declared size over the remaining storage before reading it", async () => { + mock.method(mediaQueries, "getTotalSpace", async () => 1024 ** 4); + const response = await declareUpload({ + "x-medialit-apikey": secret, + "content-length": String(1024 * 1024), + }); + assert.equal(response.status, 403); + }); + + test("rejects a deactivated account's key and signatures", async () => { + const { createSignature } = await import("@/db"); + const signature = await createSignature({ userId: ownerId, appId }); + await db + .update(schema.profiles) + .set({ active: false }) + .where(eq(schema.profiles.userId, ownerId)); + try { + const byKey = await upload({ "x-medialit-apikey": secret }); + assert.equal(byKey.status, 403); + assert.equal((await byKey.json()).error, "account_inactive"); + + const bySignature = await upload({ + "x-medialit-signature": signature.signature, + }); + assert.equal(bySignature.status, 403); + assert.equal((await bySignature.json()).error, "account_inactive"); + } finally { + await db + .update(schema.profiles) + .set({ active: true }) + .where(eq(schema.profiles.userId, ownerId)); + } + }); + + test("accepts a signature and applies its group", async () => { + const { createSignature } = await import("@/db"); + const signature = await createSignature({ + userId: ownerId, + appId, + group: "avatars", + }); + const response = await upload({ + "x-medialit-signature": signature.signature, + }); + assert.equal(response.status, 200); + assert.equal((await response.json()).appId, appId); + }); +}); diff --git a/apps/api/src/media/upload-guards.ts b/apps/api/src/media/upload-guards.ts new file mode 100644 index 00000000..a534469e --- /dev/null +++ b/apps/api/src/media/upload-guards.ts @@ -0,0 +1,80 @@ +import type { NextFunction, Request, Response } from "express"; +import fileUpload from "express-fileupload"; +import { + maxFileUploadSizeSubscribed, + tempFileDirForUploads, +} from "../config/constants"; +import { maxUploadFor } from "../billing/entitlements"; +import apikey from "../apikey/middleware"; +import signatureMiddleware from "../signature/middleware"; +import { getSignatureFromReq } from "../signature/utils"; +import { DATA_WRITE_SCOPE } from "../auth/options"; +import { requireScope } from "../auth/scopes"; +import { validateUploadConstraints } from "./storage-middleware"; + +/** + * Authenticates an upload from its headers and query string alone: a + * signature (`?signature=` or `x-medialit-signature`), otherwise the + * `x-medialit-apikey` header or a Bearer token. Runs before the body is read, + * so an unauthenticated upload is rejected without touching the disk. + */ +export function authenticateUpload( + req: Request, + res: Response, + next: NextFunction, +): void { + if (getSignatureFromReq(req)) { + void signatureMiddleware(req as any, res, next); + return; + } + void apikey(req, res, () => requireScope(DATA_WRITE_SCOPE)(req, res, next)); +} + +/** `Content-Length` covers the whole multipart body; allow this much for the + * boundaries, part headers and form fields around the file. */ +const MULTIPART_OVERHEAD_BYTES = 64 * 1024; + +/** + * Rejects an upload whose declared size can't fit the caller's plan or + * remaining storage, before any of it is read. Uploads without a + * `Content-Length` are still capped by the parser and re-checked after it. + */ +export async function preflightUploadSize( + req: any, + res: Response, + next: NextFunction, +): Promise { + const declared = Number(req.headers["content-length"]); + if (!Number.isFinite(declared) || declared <= 0) { + next(); + return; + } + const validation = await validateUploadConstraints({ + size: Math.max(0, declared - MULTIPART_OVERHEAD_BYTES), + user: req.user, + }); + if (!validation.valid) { + const status = validation.reason === "file_size_exceeded" ? 400 : 403; + res.status(status).json({ error: validation.error }); + return; + } + next(); +} + +/** Parses the upload with the caller's plan limit, never above the global + * `MAX_UPLOAD_SIZE_SUBSCRIBED` cap. */ +export async function parseUpload( + req: any, + res: Response, + next: NextFunction, +): Promise { + const planLimit = await maxUploadFor({ id: req.user._id ?? req.user.id }); + fileUpload({ + useTempFiles: true, + tempFileDir: tempFileDirForUploads, + limits: { + fileSize: Math.min(planLimit, maxFileUploadSizeSubscribed), + files: 1, + }, + })(req, res, next); +} diff --git a/apps/api/src/signature/handlers.ts b/apps/api/src/signature/handlers.ts index 00f7fcb8..59126cf6 100644 --- a/apps/api/src/signature/handlers.ts +++ b/apps/api/src/signature/handlers.ts @@ -27,7 +27,8 @@ export async function getSignature( try { const signature = await preSignedUrlService.generateSignature({ userId: req.user.id, - apikey: req.apikey, + appId: req.appId, + apiKeyId: req.apiKeyId, group: req.body.group, }); return res.status(200).json({ signature }); diff --git a/apps/api/src/signature/middleware.ts b/apps/api/src/signature/middleware.ts index 2cd1550e..406743eb 100644 --- a/apps/api/src/signature/middleware.ts +++ b/apps/api/src/signature/middleware.ts @@ -3,8 +3,13 @@ import { PRESIGNED_URL_INVALID } from "../config/strings"; import * as preSignedUrlService from "./service"; import { getSignatureFromReq } from "./utils"; +/** + * Authenticates a request by its signature (`?signature=` or the + * `x-medialit-signature` header). Reads nothing from the body, so it can run + * before an upload is parsed. The signature's group is put on `req.group`. + */ export default async function signature( - req: Request & { user?: any; apikey?: string }, + req: Request & { user?: any; appId?: string; group?: string }, res: Response, next: (...args: any[]) => void, ) { @@ -13,16 +18,20 @@ export default async function signature( const response = await preSignedUrlService.getUserAndGroupFromPresignedUrl( signature as string, ); - if (!response) { + if (response.status === "account_inactive") { + return res.status(403).json({ + error: "account_inactive", + error_description: "This account is deactivated", + }); + } + if (response.status !== "ok") { return res.status(404).json({ error: PRESIGNED_URL_INVALID }); } - const { user, apikey, group } = response; - - req.user = user; - req.apikey = apikey; - if (group) { - req.body.group = group; + req.user = response.user; + req.appId = response.appId; + if (response.group) { + req.group = response.group; } next(); diff --git a/apps/api/src/signature/queries.ts b/apps/api/src/signature/queries.ts index f3f0bedb..7f45fc68 100644 --- a/apps/api/src/signature/queries.ts +++ b/apps/api/src/signature/queries.ts @@ -4,20 +4,16 @@ import { deleteSignatureById, deleteSignatureValue, getSignature, + type ActiveSignatureRecord, + type SignatureRecord, } from "@/db"; -export interface PreSignedUrl { - id: string; - userId: string; - apikey: string; - signature: string; - validTill: Date; - group?: string; -} +export type PreSignedUrl = SignatureRecord; +/** The signature, unless its app is deleted or its key revoked. */ export async function getPresignedUrl( signature: string, -): Promise { +): Promise { return getSignature(signature); } @@ -25,12 +21,13 @@ export async function deletePresignedUrl(id: string): Promise { await deleteSignatureById(id); } -export async function createPresignedUrl( - userId: string, - apikey: string, - group?: string, -): Promise { - return createSignature({ userId, apikey, group }); +export async function createPresignedUrl(input: { + userId: string; + appId: string; + apiKeyId?: string; + group?: string; +}): Promise { + return createSignature(input); } export async function cleanupExpiredLinks(userId: string): Promise { diff --git a/apps/api/src/signature/service.ts b/apps/api/src/signature/service.ts index f7f7c070..2e10bdaf 100644 --- a/apps/api/src/signature/service.ts +++ b/apps/api/src/signature/service.ts @@ -1,55 +1,73 @@ import logger from "../services/log"; import { getUser } from "../user/queries"; import * as queries from "./queries"; -import { PreSignedUrl } from "./queries"; import { User } from "@medialit/models"; -interface PresignedUrlProps { - user: User; - apikey: string; - group?: string; -} +export type SignatureAuth = + | { + status: "ok"; + user: User; + /** Internal ID of the signature's app. */ + appId: string; + group?: string; + } + /** Unknown, expired, its app deleted, or its key revoked. */ + | { status: "invalid" } + | { status: "account_inactive" }; export async function getUserAndGroupFromPresignedUrl( signature: string, -): Promise { - const signedUrl: PreSignedUrl | null = - await queries.getPresignedUrl(signature); +): Promise { + const signedUrl = await queries.getPresignedUrl(signature); if (!signedUrl) { - return null; + return { status: "invalid" }; } if (signedUrl.validTill.getTime() <= new Date().getTime()) { await queries.deletePresignedUrl(signedUrl.id); - return null; + return { status: "invalid" }; } const user: User | null = await getUser(String(signedUrl.userId)); if (!user) { - return null; + return { status: "invalid" }; + } + if (!signedUrl.ownerActive || user.active === false) { + return { status: "account_inactive" }; } - return { user, apikey: signedUrl.apikey, group: signedUrl.group }; + return { + status: "ok", + user, + appId: signedUrl.appId, + group: signedUrl.group, + }; } interface GenerateSignedUrlProps { userId: string; - apikey: string; + /** Internal app ID. */ + appId: string; + /** Internal ID of the API key minting it, so revoking the key revokes + * the signature. Omitted for OAuth and dashboard callers. */ + apiKeyId?: string; group?: string; } export async function generateSignature({ userId, - apikey, + appId, + apiKeyId, group, }: GenerateSignedUrlProps): Promise { - const presignedUrl = await queries.createPresignedUrl( + const presignedUrl = await queries.createPresignedUrl({ userId, - apikey, + appId, + apiKeyId, group, - ); + }); queries.cleanupExpiredLinks(userId).catch((err: any) => { logger.error( diff --git a/apps/api/src/tus/finalize.ts b/apps/api/src/tus/finalize.ts index 79656120..295eab20 100644 --- a/apps/api/src/tus/finalize.ts +++ b/apps/api/src/tus/finalize.ts @@ -47,7 +47,7 @@ export default async function finalizeUpload( return ""; } - const { userId, apikey, metadata, uploadLength, tempFilePath, signature } = + const { userId, appId, metadata, uploadLength, tempFilePath, signature } = tusUpload; const user = await getUser(userId); @@ -74,7 +74,7 @@ export default async function finalizeUpload( throw new Error(`Tus file not found: ${tusFilePath}`); } - const mediaSettings = await getMediaSettings(userId, apikey); + const mediaSettings = await getMediaSettings(userId, appId); const useWebP = mediaSettings?.useWebP || false; const webpOutputQuality = mediaSettings?.webpOutputQuality || 0; @@ -140,7 +140,7 @@ export default async function finalizeUpload( fileName: `main.${fileExtension}`, mediaId: fileName.name, userId, - apikey, + appId, originalFileName: metadata.fileName, mimeType, size: uploadLength, diff --git a/apps/api/src/tus/queries.ts b/apps/api/src/tus/queries.ts index 8c98cc6f..2954705d 100644 --- a/apps/api/src/tus/queries.ts +++ b/apps/api/src/tus/queries.ts @@ -12,7 +12,7 @@ import { export interface TusUpload { uploadId: string; userId: string; - apikey: string; + appId: string; uploadLength: number; uploadOffset: number; metadata: Pick< @@ -51,7 +51,7 @@ export async function createTusUpload( const created = await createTusUploadRecord({ uploadId: data.uploadId, userId: data.userId, - apikey: data.apikey, + appId: data.appId, uploadLength: data.uploadLength, metadata: { fileName: data.metadata.fileName, @@ -67,7 +67,7 @@ export async function createTusUpload( return { uploadId: created.uploadId, userId: created.userId, - apikey: created.apikey, + appId: created.appId, uploadLength: created.uploadLength, uploadOffset: created.uploadOffset, metadata: toTusMetadata(created.metadata), @@ -86,7 +86,7 @@ export async function getTusUpload( return { uploadId: row.uploadId, userId: row.userId, - apikey: row.apikey, + appId: row.appId, uploadLength: row.uploadLength, uploadOffset: row.uploadOffset, metadata: toTusMetadata(row.metadata), @@ -119,7 +119,7 @@ export async function getTusUploadsByUserId( return rows.map((row) => ({ uploadId: row.uploadId, userId: row.userId, - apikey: row.apikey, + appId: row.appId, uploadLength: row.uploadLength, uploadOffset: row.uploadOffset, metadata: toTusMetadata(row.metadata), diff --git a/apps/api/src/tus/tus-server.ts b/apps/api/src/tus/tus-server.ts index 645d3375..1fcf5065 100644 --- a/apps/api/src/tus/tus-server.ts +++ b/apps/api/src/tus/tus-server.ts @@ -5,9 +5,14 @@ import logger from "../services/log"; import finalizeUpload from "./finalize"; import * as preSignedUrlService from "../signature/service"; import { PRESIGNED_URL_INVALID, UNAUTHORISED } from "../config/strings"; -import { Apikey, User } from "@medialit/models"; -import { getApiKeyUsingKeyId } from "../apikey/queries"; -import { getUser } from "../user/queries"; +import { User } from "@medialit/models"; +import { resolveAuth } from "../auth/resolve-auth"; +import { DATA_WRITE_SCOPE } from "../auth/options"; +import { + API_KEY_SCOPES, + hasScope, + insufficientScopeMessage, +} from "../auth/scopes"; import { validateUploadConstraints } from "../media/storage-middleware"; import { createTusUpload, updateTusUploadOffset } from "./queries"; import mediaService from "../media/service"; @@ -23,12 +28,12 @@ export const server = new Server({ exposedHeaders: ["media"], onIncomingRequest: async (req: any) => { try { - const response = await getUserAndAPIKey(req); + const response = await getUploadAuth(req); if (!isUser(response)) { throw response; } req.user = response.user; - req.apikey = response.apikey; + req.appId = response.appId; req.group = response.group; } catch (err) { logger.error({ err }, "Error validating user creds"); @@ -43,7 +48,7 @@ export const server = new Server({ console.timeEnd("finalize"); const media = await mediaService.getMediaDetails({ userId: req.user._id, - apikey: req.apikey, + appId: req.appId, mediaId, }); return { @@ -77,7 +82,7 @@ export function uploadGroup( export async function handleTusUploadCreate(req: any, upload: any) { const metadata = upload.metadata; - const { user, apikey, group } = req; + const { user, appId, group } = req; try { const validation = await validateUploadConstraints({ @@ -94,7 +99,7 @@ export async function handleTusUploadCreate(req: any, upload: any) { await createTusUpload({ uploadId: upload.id, userId: user.id, - apikey: apikey!, + appId: appId!, uploadLength: upload.size, metadata: { fileName: metadata.fileName || "unknown", @@ -120,50 +125,58 @@ server.on(EVENTS.POST_RECEIVE, async (req: any, upload: any) => { } }); -async function getUserAndAPIKey(req: any): Promise { +/** + * Authenticates every TUS request: a signature, otherwise the same API key or + * Bearer token resolution as the REST API, so revoked keys, deleted apps and + * deactivated accounts are rejected here too. + */ +async function getUploadAuth(req: any): Promise { const signature = req.headers.get("x-medialit-signature"); - let user, apikey, group; if (signature) { const response = await preSignedUrlService.getUserAndGroupFromPresignedUrl( signature as string, ); - if (!response) { - return { - status_code: 401, - body: PRESIGNED_URL_INVALID, - }; + if (response.status === "account_inactive") { + return { status_code: 403, body: "This account is deactivated" }; } - - user = response.user; - apikey = response.apikey; - group = response.group; - } else { - const apikeyFromHeader = req.headers.get("x-medialit-apikey"); - const apikeyFromDB: Apikey | null = - await getApiKeyUsingKeyId(apikeyFromHeader); - if (!apikeyFromDB) { + if (response.status !== "ok") { return { status_code: 401, - body: UNAUTHORISED, - }; - } - user = await getUser(apikeyFromDB.userId.toString()); - if (!user) { - return { - status_code: 401, - body: UNAUTHORISED, + body: PRESIGNED_URL_INVALID, }; } - apikey = apikeyFromHeader; + return { + user: response.user, + appId: response.appId, + group: response.group, + }; } - return { user, apikey, group }; + const auth = await resolveAuth({ + authorization: req.headers.get("authorization") ?? undefined, + apiKeyHeader: req.headers.get("x-medialit-apikey") ?? undefined, + }); + if (auth.status === "forbidden") { + return { status_code: 403, body: "This account is deactivated" }; + } + if (auth.status !== "authenticated" || !auth.appId) { + return { status_code: 401, body: UNAUTHORISED }; + } + const scopes = auth.kind === "oauth" ? auth.scopes : API_KEY_SCOPES; + if (!hasScope(scopes, DATA_WRITE_SCOPE)) { + return { + status_code: 403, + body: insufficientScopeMessage(DATA_WRITE_SCOPE), + }; + } + return { user: auth.user, appId: auth.appId }; } -interface UserWithAPIKey { +interface UploadAuth { user: User; - apikey: string; + /** Internal app ID. */ + appId: string; group?: string; } @@ -172,8 +185,6 @@ interface TusError { body: string; } -function isUser( - response: UserWithAPIKey | TusError, -): response is UserWithAPIKey { - return (response as UserWithAPIKey).user?.userId !== undefined; +function isUser(response: UploadAuth | TusError): response is UploadAuth { + return (response as UploadAuth).user?.userId !== undefined; } diff --git a/apps/docs/content/docs/api-keys.mdx b/apps/docs/content/docs/api-keys.mdx new file mode 100644 index 00000000..6596c8bb --- /dev/null +++ b/apps/docs/content/docs/api-keys.mdx @@ -0,0 +1,54 @@ +--- +title: API keys +description: Create, use, rotate and revoke an app's API keys. +icon: KeyRound +--- + +An API key lets your server use one [app](/concepts#apps-and-api-keys): upload, list, read and delete its files, and create [upload signatures](/concepts#upload-signatures) for browsers. A key only ever sees its own app's files. + + + Anyone with a key can upload, read and delete the app's files. Keep keys on your server and out of browser code. Browsers upload with signatures instead. + + +## Create a key + +In the [dashboard](/dashboard), open an app, go to **Settings**, and under **API keys** choose **Create key**. Name it after where you'll use it, for example `production server`, so you know which one to revoke later. + +MediaLit shows the key once. Copy it into your server's environment, usually as `MEDIALIT_API_KEY`. MediaLit stores only a hash of the key, so nobody, including MediaLit, can show it to you again. If you lose a key, create a new one and revoke the old one. + +Keys look like `ml_live_` followed by 43 random characters. The dashboard lists each key by its first characters, for example `ml_live_a1b2…`, with when it was created and last used. + +An app can have up to 10 keys. + +## Use a key + +Send the key in the `x-medialit-apikey` header: + +```sh +curl https://api.medialit.cloud/media/get/count \ + -H "x-medialit-apikey: $MEDIALIT_API_KEY" +``` + +The [Node.js SDK](/node-sdk) reads `MEDIALIT_API_KEY` and sends the header for you. MediaLit does not accept a key in the request body or the URL. + +## Rotate a key + +To replace a key without downtime: + +1. Create a new key. +2. Deploy it to everything that used the old one. +3. Check the old key's **Last used** date stops moving, then revoke it. + +## Revoke a key + +Choose **Revoke** next to the key. Anything using it stops working immediately, and so do the [upload signatures](/concepts#upload-signatures) it created that haven't been used yet. Revoking can't be undone. + +Deleting an app revokes all of its keys. + +## Keys from before hashing + +Keys created before MediaLit stored only hashes keep working unchanged. They appear as **Default key** with only their first four characters shown. Replace them with new keys when convenient: new keys are longer, and secret scanners recognize their `ml_live_` prefix. + +## Self-hosted servers + +A self-hosted server can create a key for its admin at startup, so another service in the same Docker Compose file can use MediaLit right away. See [create a key at startup](/self-hosting#create-a-key-at-startup). diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index f40abac9..9ffc5b8a 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -6,9 +6,9 @@ icon: BookOpen ## Apps and API keys -An app is a separate space for files, like a bucket. Each app has its own files, image settings and API key. Create one app per project or environment, for example `blog-production` and `blog-staging`. +An app is a separate space for files, like a bucket. Each app has its own files, image settings and [API keys](/api-keys). Create one app per project or environment, for example `blog-production` and `blog-staging`. -A request made with an app's API key only sees that app's files. When an agent connects to the [MCP server](/mcp-server) or you log in with the [CLI](/cli), you choose an app in the browser, and everything it does happens in that app. If you have only one app, it is chosen for you. +A request made with one of an app's API keys only sees that app's files. When an agent connects to the [MCP server](/mcp-server) or you log in with the [CLI](/cli), you choose an app in the browser, and everything it does happens in that app. If you have only one app, it is chosen for you. ## Temporary uploads and sealing diff --git a/apps/docs/content/docs/dashboard.mdx b/apps/docs/content/docs/dashboard.mdx index 28068df3..73e172c5 100644 --- a/apps/docs/content/docs/dashboard.mdx +++ b/apps/docs/content/docs/dashboard.mdx @@ -10,7 +10,7 @@ The [MediaLit dashboard](https://app.medialit.cloud) is where you manage apps an Create an app for each project or environment. In an app's settings you can: -- copy the app's API key +- create and revoke the app's [API keys](/api-keys) - rename the app - delete the app diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 08be480d..bd6e90b6 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -6,6 +6,7 @@ "quick-start", "what-is-medialit", "concepts", + "api-keys", "---For agents---", "mcp-server", "cli", diff --git a/apps/docs/content/docs/quick-start.mdx b/apps/docs/content/docs/quick-start.mdx index 03f66c65..3b738e54 100644 --- a/apps/docs/content/docs/quick-start.mdx +++ b/apps/docs/content/docs/quick-start.mdx @@ -11,7 +11,7 @@ import { Bot, Code, Server, Terminal } from "lucide-react"; ### Create an app -Sign in to the [MediaLit dashboard](https://app.medialit.cloud) with your email and create an app. Each app has its own files, settings and API key. Copy the API key from the app's settings. +Sign in to the [MediaLit dashboard](https://app.medialit.cloud) with your email and create an app. Each app has its own files, settings and [API keys](/api-keys). Creating the app shows its first API key. Copy it now: MediaLit shows a key only once. You can create more keys in the app's settings. Anyone with the API key can upload, read and delete the app's files. Keep it on your server and out of browser code. diff --git a/apps/docs/content/docs/self-hosting.mdx b/apps/docs/content/docs/self-hosting.mdx index d8779de0..1dd45ef9 100644 --- a/apps/docs/content/docs/self-hosting.mdx +++ b/apps/docs/content/docs/self-hosting.mdx @@ -41,7 +41,7 @@ To use the [CLI](/cli), run `medialit login --endpoint http://api.localtest.me`. ### Upload a test file -1. In the dashboard, open your app (named **My Store** at first) and go to **Settings**. Copy its API key. +1. In the dashboard, open your app (named **My Store** at first) and go to **Settings**. Under **API keys**, choose **Create key** and copy the key. It is shown only once. 2. Upload a file. Replace `YOUR_API_KEY` with the key you copied: ```sh @@ -299,6 +299,36 @@ MAX_STORAGE_ALLOWED_NOT_SUBSCRIBED=1073741824 # 1 GB TEMP_MEDIA_EXPIRATION_HOURS=24 # unsealed uploads are deleted after this ``` +## Create a key at startup + +When another service in the same Docker Compose file uses MediaLit, for example a bundled SendLit, FrontLit or CourseLit, it needs a working key before anyone can open the dashboard. Set `MEDIALIT_BOOTSTRAP_API_KEY` and MediaLit creates that key for the `EMAIL` user's default app every time it starts, before it reports healthy: + +```sh +# .env +MEDIALIT_APIKEY=ml_live_ +``` + +```yaml +services: + medialit: + environment: + EMAIL: ${ADMIN_EMAIL} + MEDIALIT_BOOTSTRAP_API_KEY: ${MEDIALIT_APIKEY} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + your-app: + environment: + MEDIALIT_APIKEY: ${MEDIALIT_APIKEY} + MEDIALIT_SERVER: http://medialit:8000 + depends_on: + medialit: { condition: service_healthy } +``` + +- The value must be at least 43 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`. MediaLit refuses to start otherwise, and also if `EMAIL` is missing. +- To rotate the key, change the value and restart. MediaLit revokes the old key when it starts with the new one. +- While the key is configured, the dashboard shows it as **Managed by configuration** and can't revoke it. Remove the setting to manage it like any other key. +- MediaLit never writes the key to its logs. + ## Point your apps at your server Set `MEDIALIT_ENDPOINT` wherever you use the [Node.js SDK](/node-sdk): @@ -311,6 +341,23 @@ MEDIALIT_API_KEY=your_api_key If your server reaches MediaLit on a different URL than browsers do, for example inside Docker, pass `publicEndpoint` to [`createSignatureHandler`](/node-sdk#createsignaturehandler). MCP clients connect to `https://medialit.example.com/mcp`, and the [CLI](/cli) logs in with `medialit login --endpoint https://medialit.example.com`. +## Upgrade your server + +`docker compose pull && docker compose up -d` upgrades MediaLit. The `init` service applies database migrations before the API starts. If you run the image without Docker Compose, run `bun apps/api/src/db/migrate.ts` with the new image before starting the API. + + + Back up the database before upgrading. Migrations can't be rolled back, and the hashed API keys migration removes plaintext keys for good. + + +### Upgrading to hashed API keys + +This release stores API keys as hashes and lets an app have several keys. + +- Existing keys keep working. They can no longer be shown in the dashboard, so make sure you have them saved. +- Send keys in the `x-medialit-apikey` header. A key in the request body is no longer accepted. +- Requests from a deactivated account now get `403 account_inactive`. +- If the migration finds media whose key matches no app, it stops and changes nothing. Fix or delete those rows, then upgrade again. + ## Monitor your server Find out that MediaLit is down before your users do. diff --git a/apps/docs/content/docs/upgrade-from-v0-4-0-to-v0-5-0.mdx b/apps/docs/content/docs/upgrade-from-v0-4-0-to-v0-5-0.mdx index cb8e8527..ad0908d6 100644 --- a/apps/docs/content/docs/upgrade-from-v0-4-0-to-v0-5-0.mdx +++ b/apps/docs/content/docs/upgrade-from-v0-4-0-to-v0-5-0.mdx @@ -102,7 +102,8 @@ bun --filter @medialit/scripts import:mongo ``` The importer checks that the PostgreSQL schema exists before writing data. It -preserves MongoDB user IDs, public user IDs, API key secrets, and media IDs. If +preserves MongoDB user IDs, public user IDs, app IDs, and media IDs. API keys +are imported as hashes, so existing keys keep working but can't be shown again. If a legacy record belongs to a user that no longer exists in MongoDB, the importer skips it and reports the count instead of creating an ownerless PostgreSQL row. diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index efc5a475..5d50f8c1 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -2,7 +2,7 @@ import { auth, type Session } from "@/auth"; import { serverApi } from "@/lib/server-api"; -import { Apikey, User } from "@medialit/models"; +import type { ApiKey, App, User } from "@medialit/models"; async function errorMessage(response: Response) { const data = await response.json().catch(() => ({})); @@ -34,14 +34,14 @@ export async function getSubscriber(): Promise< return response.json(); } -export async function getApiKeys() { +export async function getApps(): Promise { const response = await serverApi("/api/apps"); if (!response.ok) return; return response.json(); } export type AppSummary = { - keyId: string; + appId: string; name: string; default: boolean; count: number; @@ -69,7 +69,7 @@ export type AppsDashboardData = { totalPdfs: number; totalOthers: number; largestApp: { - keyId: string; + appId: string; name: string; storage: number; share: number; @@ -82,9 +82,9 @@ export async function getAppsDashboard(): Promise { return response.json(); } -export async function getAppSummary(keyId: string): Promise { +export async function getAppSummary(appId: string): Promise { const response = await serverApi( - `/api/apps/${encodeURIComponent(keyId)}/summary`, + `/api/apps/${encodeURIComponent(appId)}/summary`, ); if (!response.ok) { throw new Error(await errorMessage(response)); @@ -92,10 +92,8 @@ export async function getAppSummary(keyId: string): Promise { return response.json(); } -export async function getApikeyUsingKeyId( - keyId: string, -): Promise | null> { - const response = await serverApi(`/api/apps/${encodeURIComponent(keyId)}`); +export async function getApp(appId: string): Promise { + const response = await serverApi(`/api/apps/${encodeURIComponent(appId)}`); if (response.status === 404) return null; if (!response.ok) { throw new Error(await errorMessage(response)); @@ -103,11 +101,19 @@ export async function getApikeyUsingKeyId( return response.json(); } -export async function createNewApiKey( - prevState: Record, +export type CreateAppState = { + success: boolean; + error?: string; + appId?: string; + /** The new app's first API key. Shown once, then never available again. */ + key?: string; +}; + +export async function createNewApp( + prevState: CreateAppState, formData: FormData, -): Promise<{ success: boolean; error?: string; keyId?: string }> { - const name = formData.get("apiKey") as string; +): Promise { + const name = formData.get("name") as string; try { const response = await serverApi("/api/apps", { method: "POST", @@ -117,18 +123,18 @@ export async function createNewApiKey( return { success: false, error: await errorMessage(response) }; } const data = await response.json(); - return { success: true, keyId: data.keyId }; + return { success: true, appId: data.appId, key: data.key }; } catch (err: any) { return { success: false, error: err.message }; } } -export async function deleteApiKeyOfUser( - keyId: string, +export async function deleteApp( + appId: string, ): Promise<{ success: boolean; error?: string }> { try { const response = await serverApi( - `/api/apps/${encodeURIComponent(keyId)}`, + `/api/apps/${encodeURIComponent(appId)}`, { method: "DELETE" }, ); if (!response.ok) { @@ -140,32 +146,65 @@ export async function deleteApiKeyOfUser( } } -export async function editApiKeyforUser( - prevState: Record, - formData: FormData, -): Promise<{ success: boolean; error?: string }> { - const name = formData.get("name") as string; - const newName = formData.get("newName") as string; - if (!newName || !name) { - return { success: false, error: "Bad request" }; +export async function listApiKeys( + appId: string, +): Promise<{ items: ApiKey[]; limit: number }> { + const response = await serverApi( + `/api/apps/${encodeURIComponent(appId)}/keys`, + ); + if (!response.ok) { + throw new Error(await errorMessage(response)); } - const apps = await getApiKeys(); - const match = Array.isArray(apps) - ? apps.find((app) => app.name === name) - : null; - if (!match) { - return { success: false, error: "Apikey not found" }; + return response.json(); +} + +export async function createApiKey( + appId: string, + name: string, +): Promise< + | { success: true; key: string; apiKey: ApiKey } + | { success: false; error: string } +> { + try { + const response = await serverApi( + `/api/apps/${encodeURIComponent(appId)}/keys`, + { method: "POST", body: JSON.stringify({ name }) }, + ); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { + success: false, + error: + data.error === "api_key_limit_reached" + ? "This app already has the most keys it can have. Revoke one you no longer use first." + : data.error || "Could not create the key.", + }; + } + const { key, ...apiKey } = data; + return { success: true, key, apiKey }; + } catch (err: any) { + return { success: false, error: err.message }; } +} + +export async function revokeApiKey( + appId: string, + keyId: string, +): Promise<{ success: boolean; error?: string }> { try { const response = await serverApi( - `/api/apps/${encodeURIComponent(match.keyId)}`, - { - method: "PATCH", - body: JSON.stringify({ name: newName }), - }, + `/api/apps/${encodeURIComponent(appId)}/keys/${encodeURIComponent(keyId)}`, + { method: "DELETE" }, ); if (!response.ok) { - return { success: false, error: await errorMessage(response) }; + const data = await response.json().catch(() => ({})); + return { + success: false, + error: + data.error_description || + data.error || + "Could not revoke the key.", + }; } return { success: true }; } catch (err: any) { diff --git a/apps/web/app/app/[keyid]/connect/connect-guide.tsx b/apps/web/app/app/[appId]/connect/connect-guide.tsx similarity index 98% rename from apps/web/app/app/[keyid]/connect/connect-guide.tsx rename to apps/web/app/app/[appId]/connect/connect-guide.tsx index 5b40476c..2dac266b 100644 --- a/apps/web/app/app/[keyid]/connect/connect-guide.tsx +++ b/apps/web/app/app/[appId]/connect/connect-guide.tsx @@ -168,8 +168,8 @@ export default function ConnectGuide({
SDK and REST API

- Use this app’s API key to upload and serve files from - your own product. + Create an API key in this app’s settings to upload and + serve files from your own product.

; + params: Promise<{ appId: string }>; }) { - const [{ keyid }, session] = await Promise.all([props.params, auth()]); + const [{ appId }, session] = await Promise.all([props.params, auth()]); if (!session) redirect("/login"); - const app = await getApikeyUsingKeyId(keyid); + const app = await getApp(appId); if (!app) redirect("/"); const apiBase = getPublicApiBase(await headers()); diff --git a/apps/web/app/app/[keyid]/files/actions.ts b/apps/web/app/app/[appId]/files/actions.ts similarity index 87% rename from apps/web/app/app/[keyid]/files/actions.ts rename to apps/web/app/app/[appId]/files/actions.ts index 518fe0a7..b1121c1b 100644 --- a/apps/web/app/app/[keyid]/files/actions.ts +++ b/apps/web/app/app/[appId]/files/actions.ts @@ -19,7 +19,7 @@ function filtersQuery(filters: MediaFilters = {}) { } export async function getMediaFiles( - keyid: string, + appId: string, page: number, limit = 16, filters: MediaFilters = {}, @@ -28,7 +28,7 @@ export async function getMediaFiles( query.set("page", String(page || 1)); query.set("limit", String(limit)); const response = await serverApi( - `/api/apps/${encodeURIComponent(keyid)}/media?${query.toString()}`, + `/api/apps/${encodeURIComponent(appId)}/media?${query.toString()}`, ); if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -37,11 +37,11 @@ export async function getMediaFiles( return response.json(); } -export async function getCount(keyid: string, filters: MediaFilters = {}) { +export async function getCount(appId: string, filters: MediaFilters = {}) { const query = filtersQuery(filters); const suffix = query.size ? `?${query.toString()}` : ""; const response = await serverApi( - `/api/apps/${encodeURIComponent(keyid)}/media/count${suffix}`, + `/api/apps/${encodeURIComponent(appId)}/media/count${suffix}`, ); if (!response.ok) { const data = await response.json().catch(() => ({})); diff --git a/apps/web/app/app/[keyid]/files/empty-files.tsx b/apps/web/app/app/[appId]/files/empty-files.tsx similarity index 98% rename from apps/web/app/app/[keyid]/files/empty-files.tsx rename to apps/web/app/app/[appId]/files/empty-files.tsx index c6ddbb8e..ff0db0dc 100644 --- a/apps/web/app/app/[keyid]/files/empty-files.tsx +++ b/apps/web/app/app/[appId]/files/empty-files.tsx @@ -27,13 +27,13 @@ const stepNumberClass = "grid h-[26px] w-[26px] place-items-center rounded-full bg-muted text-[13px] font-semibold text-muted-foreground"; export default function EmptyFiles({ - keyid, + appId, appName, serverUrl, apiEndpoint, includeCliEndpoint, }: { - keyid: string; + appId: string; appName: string; serverUrl: string; apiEndpoint: string; @@ -54,7 +54,7 @@ export default function EmptyFiles({ setBusy(true); setError(""); setStatus(null); - const result = await uploadFilesToApp(keyid, files, setStatus); + const result = await uploadFilesToApp(appId, files, setStatus); setStatus(null); if (result.error) setError(result.error); setBusy(false); @@ -197,7 +197,7 @@ export default function EmptyFiles({ Or upload from your tools All options in Connect → diff --git a/apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx b/apps/web/app/app/[appId]/files/file-preview/file-interactivity.tsx similarity index 100% rename from apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx rename to apps/web/app/app/[appId]/files/file-preview/file-interactivity.tsx diff --git a/apps/web/app/app/[keyid]/files/file-preview/index.tsx b/apps/web/app/app/[appId]/files/file-preview/index.tsx similarity index 96% rename from apps/web/app/app/[keyid]/files/file-preview/index.tsx rename to apps/web/app/app/[appId]/files/file-preview/index.tsx index 6dabf01b..39a4321f 100644 --- a/apps/web/app/app/[keyid]/files/file-preview/index.tsx +++ b/apps/web/app/app/[appId]/files/file-preview/index.tsx @@ -129,12 +129,12 @@ function FilePreviewMedia({ fetched each time the details are opened. */ function FileDetailsContent({ media, - keyid, + appId, }: { media: MediaListItem; - keyid: string; + appId: string; }) { - const { url, error } = useFileUrl(keyid, media.mediaId); + const { url, error } = useFileUrl(appId, media.mediaId); const kind = kindLabels[media.kind]; const fileLabel = media.originalFileName.split(".").at(-1)?.toUpperCase() || kind; @@ -201,18 +201,18 @@ function FileDetailsContent({ /* The details dialog shared by grid cards and table rows. */ function FileDetailsDialog({ media, - keyid, + appId, children, }: { media: MediaListItem; - keyid: string; + appId: string; children: ReactNode; }) { return ( {children} - + ); @@ -220,13 +220,13 @@ function FileDetailsDialog({ export default function FilePreview({ media, - keyid, + appId, layout = "grid", selected, onSelectionChange, }: { media: MediaListItem; - keyid: string; + appId: string; layout?: "grid" | "list"; selected: boolean; onSelectionChange: () => void; @@ -253,7 +253,7 @@ export default function FilePreview({ return (
- +
- + {appSummary.count === 0 ? ( (null); const router = useRouter(); const [busy, setBusy] = useState(false); @@ -26,7 +26,7 @@ export default function UploadButton({ keyid }: { keyid: string }) { setError(""); setStatus(null); const { uploaded, error: uploadError } = await uploadFilesToApp( - keyid, + appId, files, setStatus, ); diff --git a/apps/web/app/app/[keyid]/files/upload-files.ts b/apps/web/app/app/[appId]/files/upload-files.ts similarity index 96% rename from apps/web/app/app/[keyid]/files/upload-files.ts rename to apps/web/app/app/[appId]/files/upload-files.ts index 182e2863..f8dd690f 100644 --- a/apps/web/app/app/[keyid]/files/upload-files.ts +++ b/apps/web/app/app/[appId]/files/upload-files.ts @@ -20,11 +20,11 @@ export function uploadLabel(status: UploadStatus | null) { Sealing makes the upload permanent; until then it stays temporary. The API signs and seals for the signed-in user, so the app's key never leaves it. */ export async function uploadFilesToApp( - keyid: string, + appId: string, files: File[], onStatus?: (status: UploadStatus) => void, ) { - const appPath = `/api/apps/${encodeURIComponent(keyid)}/media`; + const appPath = `/api/apps/${encodeURIComponent(appId)}/media`; let uploaded = 0; for (let position = 0; position < files.length; position++) { diff --git a/apps/web/app/app/[appId]/layout.tsx b/apps/web/app/app/[appId]/layout.tsx new file mode 100644 index 00000000..4c77fabf --- /dev/null +++ b/apps/web/app/app/[appId]/layout.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { redirect } from "next/navigation"; +import { getApp } from "@/app/actions"; + +export default async function AppLayout(props: { + params: Promise<{ appId: string }>; + children: React.ReactNode; +}) { + const { appId } = await props.params; + const app = await getApp(appId); + if (!app) redirect("/"); + + return <>{props.children}; +} diff --git a/apps/web/app/app/[keyid]/settings/actions.tsx b/apps/web/app/app/[appId]/settings/actions.tsx similarity index 68% rename from apps/web/app/app/[keyid]/settings/actions.tsx rename to apps/web/app/app/[appId]/settings/actions.tsx index 78f53123..55e9643a 100644 --- a/apps/web/app/app/[keyid]/settings/actions.tsx +++ b/apps/web/app/app/[appId]/settings/actions.tsx @@ -1,24 +1,23 @@ "use server"; import { serverApi } from "@/lib/server-api"; -type MediaStats = { storage: number; maxStorage: number }; export async function updateAppName( previousState: Record, formData: FormData, ) { const newName = formData.get("newName") as string; - const keyId = formData.get("keyId") as string; + const appId = formData.get("appId") as string; if (!newName) { return { success: false, error: "Name is required" }; } - if (!keyId) { + if (!appId) { return { success: false, error: "Bad request" }; } try { const response = await serverApi( - `/api/apps/${encodeURIComponent(keyId)}`, + `/api/apps/${encodeURIComponent(appId)}`, { method: "PATCH", body: JSON.stringify({ name: newName }), @@ -38,12 +37,12 @@ export async function makeDefaultApp( previousState: Record, formData: FormData, ) { - const keyId = formData.get("keyId") as string; - if (!keyId) return { success: false, error: "Bad request" }; + const appId = formData.get("appId") as string; + if (!appId) return { success: false, error: "Bad request" }; try { const response = await serverApi( - `/api/apps/${encodeURIComponent(keyId)}/default`, + `/api/apps/${encodeURIComponent(appId)}/default`, { method: "PATCH" }, ); if (!response.ok) { @@ -58,15 +57,3 @@ export async function makeDefaultApp( }; } } - -export async function getTotalSpaceByApikey( - keyid: string, -): Promise { - const response = await serverApi( - `/api/apps/${encodeURIComponent(keyid)}/stats`, - ); - if (!response.ok) { - return { storage: 0, maxStorage: 0 }; - } - return response.json(); -} diff --git a/apps/web/app/app/[appId]/settings/api-keys-section.tsx b/apps/web/app/app/[appId]/settings/api-keys-section.tsx new file mode 100644 index 00000000..2c1a5a2d --- /dev/null +++ b/apps/web/app/app/[appId]/settings/api-keys-section.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import type { ApiKey } from "@medialit/models"; +import { createApiKey, revokeApiKey } from "@/app/actions"; +import ApiKeyReveal from "@/components/api-key-reveal"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +/* A fixed locale and time zone, so the server and the browser render the + same text and hydration doesn't mismatch. */ +const dateFormat = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +function formatDate(value: string | null): string { + if (!value) return "Never"; + return dateFormat.format(new Date(value)); +} + +export default function ApiKeysSection({ + appId, + keys, + limit, +}: { + appId: string; + keys: ApiKey[]; + limit: number; +}) { + const router = useRouter(); + const [createOpen, setCreateOpen] = useState(false); + const [revoking, setRevoking] = useState(null); + const atLimit = keys.length >= limit; + + return ( + <> + {keys.length === 0 ? ( +

+ This app has no API keys yet. Create one to use the SDK or + REST API. +

+ ) : ( +
+ + + + + + + + + + + + {keys.map((key) => ( + + + + + + + + ))} + +
NameKey + Created + + Last used + + Actions +
{key.name} + {key.keyPrefix}… + + {formatDate(key.createdAt)} + + {formatDate(key.lastUsedAt)} + + {key.managedByConfiguration ? ( + + Managed by configuration + + ) : ( + + )} +
+
+ )} +
+ + {atLimit ? ( + + Apps can have {limit} keys. Revoke one you no longer use + to create another. + + ) : null} +
+ + { + setCreateOpen(open); + if (!open) router.refresh(); + }} + /> + { + setRevoking(null); + if (revoked) router.refresh(); + }} + /> + + ); +} + +function CreateKeyDialog({ + appId, + open, + onOpenChange, +}: { + appId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [name, setName] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [secret, setSecret] = useState(null); + + function handleOpenChange(next: boolean) { + if (!next) { + // The secret must not linger once the dialog closes. + setName(""); + setError(""); + setSecret(null); + } + onOpenChange(next); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(""); + const result = await createApiKey(appId, name.trim()); + setPending(false); + if (!result.success) { + setError(result.error); + return; + } + setSecret(result.key); + } + + return ( + + + + + {secret ? "Store your new key" : "Create API key"} + + + {secret + ? "Use it in the x-medialit-apikey header. Keep it out of client-side code." + : "Name the key after where you’ll use it, so you know which one to revoke later."} + + + {secret ? ( + <> + + + + + + ) : ( +
+
+ + + setName(event.target.value) + } + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+ )} +
+
+ ); +} + +function RevokeKeyDialog({ + appId, + apiKey, + onClose, +}: { + appId: string; + apiKey: ApiKey | null; + onClose: (revoked: boolean) => void; +}) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function revoke() { + if (!apiKey) return; + setPending(true); + setError(""); + const result = await revokeApiKey(appId, apiKey.keyId); + setPending(false); + if (!result.success) { + setError(result.error || "Could not revoke the key."); + return; + } + onClose(true); + } + + return ( + { + if (!open) { + setError(""); + onClose(false); + } + }} + > + + + Revoke {apiKey?.name}? + + Anything using {apiKey?.keyPrefix}… stops working right + away, along with upload signatures it created. This + can’t be undone. + + + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+ ); +} diff --git a/apps/web/app/app/[keyid]/settings/default-app-control.tsx b/apps/web/app/app/[appId]/settings/default-app-control.tsx similarity index 94% rename from apps/web/app/app/[keyid]/settings/default-app-control.tsx rename to apps/web/app/app/[appId]/settings/default-app-control.tsx index 168861b7..d6f95f2d 100644 --- a/apps/web/app/app/[keyid]/settings/default-app-control.tsx +++ b/apps/web/app/app/[appId]/settings/default-app-control.tsx @@ -9,10 +9,10 @@ import { makeDefaultApp } from "./actions"; type DefaultAppState = { success: boolean; error?: string }; export default function DefaultAppControl({ - keyId, + appId, isDefault, }: { - keyId: string; + appId: string; isDefault: boolean; }) { const [state, action] = useActionState(makeDefaultApp, { @@ -27,7 +27,7 @@ export default function DefaultAppControl({ return (
- + {state.error ? ( diff --git a/apps/web/app/app/[keyid]/settings/delete-app-button.tsx b/apps/web/app/app/[appId]/settings/delete-app-button.tsx similarity index 92% rename from apps/web/app/app/[keyid]/settings/delete-app-button.tsx rename to apps/web/app/app/[appId]/settings/delete-app-button.tsx index 55602f3e..eba084ab 100644 --- a/apps/web/app/app/[keyid]/settings/delete-app-button.tsx +++ b/apps/web/app/app/[appId]/settings/delete-app-button.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { deleteApiKeyOfUser } from "@/app/actions"; +import { deleteApp as deleteAppAction } from "@/app/actions"; import { Dialog, DialogContent, @@ -15,10 +15,10 @@ import { export default function DeleteAppButton({ appName, - keyId, + appId, }: { appName: string; - keyId: string; + appId: string; }) { const router = useRouter(); const [open, setOpen] = useState(false); @@ -28,7 +28,7 @@ export default function DeleteAppButton({ async function deleteApp() { setPending(true); setError(""); - const result = await deleteApiKeyOfUser(keyId); + const result = await deleteAppAction(appId); setPending(false); if (!result.success) { setError(result.error || "Could not delete this app."); @@ -51,7 +51,7 @@ export default function DeleteAppButton({ Delete {appName}? This removes the app and its files and revokes its API - key. This can’t be undone. + keys. This can’t be undone. {error ? ( diff --git a/apps/web/app/app/[keyid]/settings/loading.tsx b/apps/web/app/app/[appId]/settings/loading.tsx similarity index 100% rename from apps/web/app/app/[keyid]/settings/loading.tsx rename to apps/web/app/app/[appId]/settings/loading.tsx diff --git a/apps/web/app/app/[keyid]/settings/page.tsx b/apps/web/app/app/[appId]/settings/page.tsx similarity index 78% rename from apps/web/app/app/[keyid]/settings/page.tsx rename to apps/web/app/app/[appId]/settings/page.tsx index 6468d243..46aaab16 100644 --- a/apps/web/app/app/[keyid]/settings/page.tsx +++ b/apps/web/app/app/[appId]/settings/page.tsx @@ -1,29 +1,30 @@ import { redirect } from "next/navigation"; import { AlertTriangle, Database, KeyRound, Settings2 } from "lucide-react"; import { auth } from "@/auth"; -import { getApikeyUsingKeyId, getAppSummary } from "@/app/actions"; +import { getApp, getAppSummary, listApiKeys } from "@/app/actions"; import { formatAppStorage } from "@/lib/media-format"; -import ApiKeyControl from "./api-key-control"; +import ApiKeysSection from "./api-keys-section"; import DeleteAppButton from "./delete-app-button"; import UpdateSettingsForm from "./update-settings-form"; import DefaultAppControl from "./default-app-control"; export default async function Settings(props: { - params: Promise<{ keyid: string }>; + params: Promise<{ appId: string }>; }) { - const [{ keyid }, session] = await Promise.all([props.params, auth()]); + const [{ appId }, session] = await Promise.all([props.params, auth()]); if (!session) redirect("/login"); - const [apikey, appSummary] = await Promise.all([ - getApikeyUsingKeyId(keyid), - getAppSummary(keyid).catch(() => null), + const [app, appSummary, apiKeys] = await Promise.all([ + getApp(appId), + getAppSummary(appId).catch(() => null), + listApiKeys(appId).catch(() => null), ]); - if (!apikey) redirect("/"); + if (!app) redirect("/"); - if (!appSummary) { + if (!appSummary || !apiKeys) { return (
- Could not load app storage details. + Could not load this app’s settings.
); } @@ -34,8 +35,8 @@ export default async function Settings(props: {

App settings

- Storage, API key, and options for{" "} - {apikey.name || "this app"}. + Storage, API keys, and options for{" "} + {app.name || "this app"}.

@@ -84,17 +85,19 @@ export default async function Settings(props: {