From 1d84a62c4b29726dd4165eac29a6ee305d7f213d Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 17 Oct 2026 15:23:07 +0530 Subject: [PATCH 1/7] docs(prd): API key management plan Hashed, revocable, multi-key API keys per app, modelled on SendLit and FrontLit, plus the operator-supplied bootstrap key for bundled installs. --- docs/prds/apikey.md | 626 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 docs/prds/apikey.md diff --git a/docs/prds/apikey.md b/docs/prds/apikey.md new file mode 100644 index 00000000..c479ff63 --- /dev/null +++ b/docs/prds/apikey.md @@ -0,0 +1,626 @@ +# API Key Management + +**Status:** Approved +**Author:** Rajat Saxena +**Date:** 2026-10-17 + +## 1. Objective + +Bring MediaLit's API key handling in line with SendLit and FrontLit: + +- Store only hashes, never plaintext. +- Show the secret only once, at creation. +- Allow several revocable keys per app. +- Scope tenant data to the app, not to the key's secret. + +Existing integrations must keep working without changes. Today, the only +MediaLit consumers are our own products: + +- CourseLit sends the `x-medialit-apikey` header directly. +- FrontLit and SendLit use `medialit@0.2.0`, which also sends the header. + +That lets this change remove the legacy body `apikey` field outright (§7). + +The same change closes two adjacent gaps: + +- Uploads are written to disk before the caller is authenticated. +- Deactivated accounts keep full API access. + +It also has to keep **bundled self-hosting** working. SendLit, FrontLit and +CourseLit ship MediaLit inside their own `docker-compose.yml`, and each product +needs a working MediaLit key from its first start, before anyone can open a +dashboard (§6.4). + +## 2. Current state + +- **One plaintext key per app.** An "app" is a row in `api_keys` + (`apps/api/src/db/schema/domain.ts`). Its secret is a 21-character nanoid, + stored in plaintext in `api_keys.key`. `key_id` is the app's public ID, used in + `/app/[keyid]` URLs and in the OAuth `app_id` claim. +- **The secret doubles as the tenant ID.** `media.apikey`, + `media_settings.apikey` (globally unique), `signatures.apikey` and + `tus_uploads.apikey` store the raw secret, and every query filters on it. So + a key can't be rotated, revoked or hashed without moving the data. +- **Deleting an app does not revoke its key.** `getApiKeyBySecret` + (`apps/api/src/db/repository.ts`) doesn't filter `deleted`, and the separate + TUS resolver (`apps/api/src/tus/tus-server.ts`) doesn't check it either. A + test currently asserts this behaviour. +- **The secret leaks.** + - `GET /api/apps/:keyId` returns it. + - The settings page sends it to the browser on every visit. + - It travels through `req.apikey`, signature rows and the MCP auth context. +- **Dead columns.** `restriction`, `http_referrers` and `ip_addresses` are never + enforced. +- **Missing features.** There is no rotation, no second key, and no record of + when a key was last used. +- **Uploads hit disk before authentication.** `POST /media/create` runs + `fileUpload` before checking credentials (`apps/api/src/media/routes.ts`), + because the legacy `apikey` field can arrive inside the multipart body. An + unauthenticated caller can therefore write up to `MAX_UPLOAD_SIZE_SUBSCRIBED` + (2 GB by default) to the temp directory per request before being rejected. + The only brake is the per-IP limit of 120 requests per minute. +- **`profiles.active` is never enforced.** The importer and `/api/account` + carry the flag, but no authentication path reads it. A deactivated account + keeps working through API keys, OAuth, signatures, TUS and MCP. + +## 3. Reference model (SendLit / FrontLit) + +Both products keep their own copy of the same design; there is no shared +package. This plan follows SendLit's team keys, with two changes taken from +FrontLit: management is owner-only, and revoking a missing key returns 404. + +| Aspect | Design | +| --- | --- | +| Format | `_live_` + 32 CSPRNG bytes, base64url (256 bits) | +| Storage | `key_hash` (SHA-256 hex, unique) and `key_prefix` (the first 12 characters, for display) | +| Plaintext | Returned once, in the create response | +| Ownership | Keys belong to a tenant (a team). Tenant data references the tenant, never the key | +| Lookup | Hash the submitted value; then `key_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())` | +| Revocation | Soft, by setting `revoked_at` | +| IDs | `id` is the internal primary key, referenced by every foreign key and never exposed. `_id` is the public ID and the only one accepted from callers | + +Don't copy these SendLit bugs: + +- It never writes `last_used_at`. +- The settings list shows revoked keys. +- Revoking a key that doesn't exist returns 204. +- Any team member can manage keys. +- Team keys can mint more keys through MCP. + +In MediaLit, the **app** plays the role of SendLit's team. + +## 4. Data model + +### 4.1 `apps` (renamed from `api_keys`) + +| Column | Notes | +| --- | --- | +| `id` | Internal primary key; unchanged values | +| `app_id` | Public ID, renamed from `key_id`. Existing values stay as they are, without a prefix, so URLs and OAuth `app_id` claims keep working | +| `name`, `user_id`, `is_default`, `deleted`, `created_at`, `updated_at` | Unchanged | +| ~~`key`~~ | Dropped once the backfill is done | +| ~~`restriction`, `http_referrers`, `ip_addresses`~~ | Dropped (never enforced) | + +The existing partial unique indexes stay: `(user_id, name)` and the +one-default-per-user index. + +### 4.2 `api_keys` (new) + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | text, primary key | Internal | +| `api_key_id` | text, unique | Public ID, `ak_` | +| `app_id` | text, not null | Foreign key to `apps.id`, `ON DELETE CASCADE`, indexed | +| `name` | text, not null | | +| `key_hash` | text, not null, unique | SHA-256 hex of the full secret | +| `key_prefix` | text, not null | The first 12 characters of new keys; the first 4 characters of legacy keys | +| `expires_at` | timestamptz | Nullable | +| `last_used_at` | timestamptz | Nullable | +| `revoked_at` | timestamptz | Nullable | +| `created_by_type` | text, not null | `user`, `bootstrap` or `legacy` (checked by a constraint) | +| `created_by_id` | text | User ID, if any | +| `created_at` | timestamptz, not null | | + +### 4.3 Tenant tables + +`media`, `media_settings`, `signatures` and `tus_uploads` replace `apikey` with +`app_id`, a foreign key to `apps.id`. `media_settings.app_id` is unique. +`oauth_app_selections.api_key_id` is renamed to `app_id`. + +`signatures` also gets a nullable `api_key_id`, a foreign key to `api_keys.id` +with `ON DELETE CASCADE`, and an index on it. It records which key minted the +signature. It stays null for signatures minted through OAuth or the dashboard. +See §7.1. + +As in SendLit, the foreign key columns are named `app_id` but hold the internal +`apps.id`. Only the `apps.app_id` column holds the public ID. + +`media_owner_idx` is rebuilt on `(user_id, app_id, temp)` and declared in +`domain.ts`. The current index is in the SQL but missing from the schema. + +## 5. Migration (`apps/api/drizzle/0008_api_keys.sql`, hand-written) + +All of this runs in a single transaction. + +1. Rename `api_keys` to `apps`, `key_id` to `app_id`, and + `oauth_app_selections.api_key_id` to `app_id`. Renaming a table doesn't + rename its constraints or indexes, so also rename them to `apps_*`. + Otherwise the new table's default names would collide with the old ones: + - `api_keys_pkey`, `api_keys_key_id_key` and `api_keys_key_key`; + - `api_keys_user_name_uidx` and `api_keys_one_default_uidx`; + - the foreign key `api_keys_user_id_fkey`; + - `oauth_app_selections_api_key_id_api_keys_id_fk`. + + Check the actual names with `\d api_keys` first, because the constraints + in `0001_domain.sql` were created implicitly, without explicit names. +2. Create the new `api_keys` table. +3. **Backfill keys.** For each app, insert one key: + - `key_hash = encode(sha256(convert_to(key, 'UTF8')), 'hex')` + - `key_prefix = left(key, 4)` + - `name = 'Default key'` + - `created_by_type = 'legacy'` + - `revoked_at = now()` if the app is deleted + + Legacy secrets keep authenticating, because lookup simply hashes whatever is + submitted. No format check or separate legacy path is needed. +4. **Re-key the tenant tables.** For each of `media`, `media_settings`, + `signatures` and `tus_uploads`: + 1. Add a nullable `app_id` column. + 2. Fill it: `UPDATE t SET app_id = a.id FROM apps a WHERE a.key = t.apikey`. + 3. If any row still has a null `app_id`, **raise an exception**. Never drop + media silently. Rows like this point at a key that no longer exists; + imported Mongo data is the likely source. + 4. Set `NOT NULL` and add the foreign key and indexes. + 5. Drop `apikey`. + + For `signatures` only, also set `api_key_id` to the app's backfilled + legacy key. The old schema can't tell which signatures were minted by a key + and which by the dashboard, so revoking the legacy key conservatively + invalidates all of them. Signatures last 24 hours at most, so this is + harmless. +5. Drop `apps.key`, `restriction`, `http_referrers` and `ip_addresses`. +6. Update `domain.ts` and the Drizzle snapshot so that `db:generate` produces no + diff. +7. Confirm that PGlite supports `sha256()`. `repository.test.ts` runs the real + migrations against it. + +## 6. API: key primitives and repository + +### 6.1 `apps/api/src/apikey/secret.ts` + +- `API_KEY_PREFIX = "ml_live_"` +- `generateApiKeySecret()` returns `ml_live_` + `randomBytes(32).toString("base64url")`. +- `hashApiKeySecret(secret)` returns the SHA-256 hex digest. +- `displayPrefix(secret)` returns the first 12 characters, e.g. `ml_live_a1b2`. + +### 6.2 `apps/api/src/apikey/queries.ts` + +This replaces the current file. + +- `createApiKey({ appId, name, createdByType, createdById?, expiresAt? })` + returns `{ apiKey, secret }`. Only the hash and prefix are stored. +- `listApiKeys(appId)` excludes revoked keys. +- `revokeApiKey(appId, apiKeyId)` returns `false` if no active key matched. In + the same transaction, it deletes every signature whose `api_key_id` is that + key (§7.1). +- `getApiKeyBySecret(secret)`: + - joins `apps`; + - requires `apps.deleted = false`, `revoked_at IS NULL` and the key not to be + expired; + - returns the key row together with its app. +- `touchApiKeyLastUsed(id)` is fire-and-forget. It is throttled by + `WHERE last_used_at IS NULL OR last_used_at < now() - interval '5 minutes'`. + +### 6.3 `apps/api/src/db/repository.ts` + +- The current key functions become app functions: `createApp`, `listApps`, + `getAppByPublicId(userId, appId)`, `renameApp`, `setDefaultApp` and + `softDeleteApp`. +- `createApp({ withKey })`, when `withKey` is set, creates the app's first key in + the same transaction and returns the secret once. Only the dashboard's + **New app** flow sets it. +- `softDeleteApp`, in one transaction: + - marks the app deleted; + - revokes all of its keys; + - deletes all of its signatures. +- `ensureProfile` creates the default app **without a key**. A key created + automatically would be a live credential whose secret nobody ever sees. Users + create keys from the dashboard when they need one. OAuth and MCP don't need a + key. The one exception is the operator-supplied bootstrap key (§6.4). + +### 6.4 Bootstrap key (`apps/api/src/bootstrap.ts`) + +Bundled installs need a key that both MediaLit and the product know before +either starts. Today MediaLit logs a generated key at first boot +(`apps/api/src/index.ts`, `createAdminUser`), and someone has to copy it into +the product's environment. That breaks in a single compose file. It also +conflicts with hashing (the secret exists only at creation) and with the +platform rule "never log raw keys". Instead, **the operator supplies the key**. + +**Configuration.** Set `MEDIALIT_BOOTSTRAP_API_KEY` to the key value. + +- **Format:** at least 43 characters from `[A-Za-z0-9_-]`. The docs and + `.env.example` generate it with `ml_live_$(openssl rand -hex 32)`, which keeps + the `ml_live_` prefix for secret scanners. +- **Validation:** a value outside the format stops startup with a non-zero exit. + MediaLit can't check randomness, only shape. +- **`EMAIL`** is required whenever the bootstrap key is set, because the key + belongs to the admin's default app. + +**Startup.** `bootstrap()` replaces `createAdminUser()`. It runs after +migrations and before `app.listen`. Any failure is fatal: it logs and exits +non-zero, and the container's restart policy retries. + +1. If `EMAIL` is set, make sure the admin user, its profile and its default app + exist (`ensureProfile`, which creates no key). +2. If `MEDIALIT_BOOTSTRAP_API_KEY` is unset, stop here. Nothing secret is + logged, and keys are created from the dashboard. +3. Hash the value, then in one transaction that locks the default app: + - **Already the active bootstrap key** (the hash matches an active + `bootstrap` key on the admin's default app): do nothing. Restarts are + no-ops. + - **A revoked `bootstrap` key** has this hash (the operator rolled back to an + old value): un-revoke it, and revoke any other active `bootstrap` keys. + - **A non-bootstrap key** has this hash (someone pasted a dashboard key into + the variable): exit with an error. One key can't be owned by both the + dashboard and configuration. + - **Otherwise:** + - revoke every active `bootstrap` key on the app, deleting their + signatures (§7.1); + - insert a key named `Bootstrap (MEDIALIT_BOOTSTRAP_API_KEY)` with + `created_by_type = 'bootstrap'`. + + It skips the 10-key limit but counts toward it (§13). + +**Rotation.** Change the value in `.env` and restart both MediaLit and the +product. The old key is revoked as soon as MediaLit starts with the new value. + +**Removing the variable** leaves the existing bootstrap key active. Removing +config should not break a running product. Once removed, the key can be revoked +from the dashboard like any other. + +**Managed while configured.** While a key's hash equals the current +`MEDIALIT_BOOTSTRAP_API_KEY`: + +- `DELETE /api/apps/:appId/keys/:keyId` returns + **409 `managed_by_configuration`**; +- the dashboard shows a **Managed by configuration** badge instead of + **Revoke**. + +Otherwise the next restart would silently revive a key the user just revoked. + +**Readiness.** The API listens only after `bootstrap()` succeeds. A product that +waits for `medialit` to become `service_healthy` (via the existing `/health` +route) therefore always starts with a working key. + +**Bundled compose pattern**, for SendLit, FrontLit and CourseLit. One variable +in the product's `.env` feeds both services: + +```yaml +# .env: MEDIALIT_APIKEY=ml_live_ +services: + medialit-migrate: # same as MediaLit's own `init` service + image: codelit/medialit + restart: "no" + command: ["bun", "apps/api/src/db/migrate.ts"] + medialit: + image: codelit/medialit + depends_on: + medialit-migrate: { condition: service_completed_successfully } + environment: + EMAIL: ${ADMIN_EMAIL} + MEDIALIT_BOOTSTRAP_API_KEY: ${MEDIALIT_APIKEY:?MEDIALIT_APIKEY is required} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + frontlit: + environment: + MEDIALIT_APIKEY: ${MEDIALIT_APIKEY} + MEDIALIT_SERVER: http://medialit:8000 + depends_on: + medialit: { condition: service_healthy } +``` + +**Hosted** (medialit.cloud) never sets the variable. + +## 7. API: authentication + +Transport: + +- **Header only.** API keys are read only from the `x-medialit-apikey` header. + The legacy body `apikey` field is **removed on every route**, JSON and + multipart alike: + - `resolveAuth` drops its `bodyApiKey` input, along with the + header-versus-body ambiguity rule. + - `auth/middleware.ts` stops reading `req.body?.apikey`. + - The swagger spec and docs drop the field. + + A key in the body is ignored. Such a request is treated as having no + credential and gets a 401 naming the header to use. +- **Ambiguity.** A request carrying both Bearer and `x-medialit-apikey` is + still `ambiguous`. +- **Bearer stays OAuth-only.** + +Removing the body field also lets uploads authenticate before their body is +read (§7.2). + +- **`auth/resolve-auth.ts`.** The API-key result becomes + `{ kind: "apikey", user, userId, appId, apiKeyId }`: + - `appId` is the internal `apps.id`. + - `apiKeyId` is the internal `api_keys.id`. + + The resolver calls `touchApiKeyLastUsed` itself. `getEffectiveOAuthApiKey` + becomes `getEffectiveOAuthApp`, which maps the token's `app_id` claim (the + public ID) to the internal ID. +- **`auth/middleware.ts`.** `req.apikey` is replaced by: + - `req.appId`, for every kind of authentication; + - `req.apiKeyId`, for API-key authentication only. It is server-side only, + and its sole consumer is signature creation (§7.1), over REST or MCP. + + No secret is attached to the request. +- **`tus/tus-server.ts`.** Remove the TUS-only resolver and use the shared + `getApiKeyBySecret`. This closes the deleted-key hole on uploads. +- **`signature/*`.** See §7.1. +- **`mcp/auth-context.ts`.** `apikey: string` becomes `appId`, plus + `apiKeyId?: string` for API-key callers. The `signature` and `upload` tools + mint signatures, so they need it for §7.1. Update every tool: `media`, + `settings`, `signature`, `upload` and `whoami`. `whoami` + reports the public `apps.app_id`, as it does today. The `clientId` stays + `"apikey"`. +- **Downstream code.** Rename `apikey` to `appId`, which now holds the internal + ID, in: + - `media/{handlers,service,queries}.ts` + - `media-settings/*` + - `tus/finalize.ts` + - `dashboard/routes.ts`, where `owned.key.key` becomes `owned.app.id` and + media summaries group and join by `app_id` + +Billing and storage limits are per user and don't change. + +### 7.1 Signatures follow their key + +A signature is a one-upload credential, valid for 24 hours by default +(`SIGNATURE_VALIDITY_MINUTES`). Without a link back to its key, someone holding +a leaked key could mint thousands of signatures before the key is revoked. They +could then keep uploading for up to a day afterwards. SendLit and FrontLit have +no credentials derived from a key, so this is specific to MediaLit. + +- **Creating.** `generateSignature` stores `app_id` and, for API-key callers, + `api_key_id`: `req.apiKeyId` over REST, `getMcpAuth(extra).apiKeyId` over + MCP. Signatures minted through OAuth or the dashboard + leave it null. +- **Revoking a key.** Deleting the key's signatures happens in the same + transaction as the revocation (§6.2). +- **Deleting an app.** All of the app's signatures are deleted (§6.3). +- **Using a signature.** The lookup joins `apps` and requires + `apps.deleted = false`, so a signature is rejected after its app is deleted + even if the deletes above failed. It also joins `api_keys` when `api_key_id` + is set, and requires `revoked_at IS NULL`. +- **Request context.** `signatureMiddleware` and the TUS signature path set + `req.appId` from the signature row. + +### 7.2 Uploads authenticate before the body is read + +`POST /media/create` becomes this middleware chain: + +1. `cors()` and `authenticatedApiLimiter`, unchanged. +2. **`authenticateUpload`.** Reads credentials from the request headers and + query string only: + - a signature (`?signature=` or the `x-medialit-signature` header); + - otherwise, the `x-medialit-apikey` header or `Authorization: Bearer`. + + It runs the shared resolver and signature lookup, then applies + `requireScope(DATA_WRITE_SCOPE)` to non-signature callers. A request with no + credential in its headers or query string gets **401 before any body + bytes are read**. The error tells the caller to send `x-medialit-apikey`. +3. **`preflightUploadSize`.** When `Content-Length` is present, it rejects the + request before parsing if the length: + - exceeds the caller's plan upload limit (`maxUploadFor`): **400**, + `FILE_SIZE_EXCEEDED`; + - exceeds the caller's remaining storage (`hasEnoughStorage`): **403**, + `NOT_ENOUGH_STORAGE`. + + `Content-Length` includes multipart overhead, so allow a small margin + (64 KB). +4. **`fileUpload`**, built per request, with `limits.fileSize` set to the + caller's plan limit instead of the global subscribed maximum. Chunked + uploads without a `Content-Length` are still capped here. +5. `storage` (the existing `storageValidation`, which re-checks the real file + size) and `uploadMedia`, unchanged. + +Consequences: + +- **No body credentials.** Since the body field is gone (§7), every upload + credential is available before the body is read. There is no fallback path + that has to parse an upload to find its key. +- **TUS.** No change: `onIncomingRequest` already authenticates every TUS + request before any data is stored. + +### 7.3 Inactive accounts lose API access + +`profiles.active = false` blocks every **API** credential: + +- **API keys.** `getApiKeyBySecret` also returns `profiles.active` for the + key's owner. +- **OAuth.** `resolveAuth` loads the profile for OAuth callers. +- **Inactive result.** For either kind, an inactive owner gives a new result, + `{ status: "forbidden", error: "account_inactive" }`, sent as **403**. + It is a 403 rather than a 401 so integrations can tell "your key is wrong" + apart from "your account is disabled". +- **Signatures.** The signature lookup joins `profiles` and rejects inactive + owners with the same 403. This covers REST uploads and the TUS signature + path. +- **TUS and MCP.** Both use the shared resolver, so they inherit the check. + +**Dashboard sessions stay usable**, so the owner can still sign in, see the +account state (`/api/account` already returns `active`), manage billing and +delete media. Dashboard signatures are blocked by the signature check, so +deactivated accounts can't upload from the dashboard either. + +Nothing in the product sets `active = false` today. Operators set it in the +database, and the Mongo importer carries it over. Building an admin control is +out of scope. + +## 8. API: management routes + +The routes live in `apps/api/src/dashboard/routes.ts` and require a session. The +caller must own the app. `:appId` is always the public `apps.app_id`. + +| Method | Path | Body | Response | +| --- | --- | --- | --- | +| `GET` | `/api/apps/:appId/keys` | — | `200 { items: [{ keyId, name, keyPrefix, createdAt, lastUsedAt, expiresAt, managedByConfiguration }] }`, active keys only. `managedByConfiguration` is true for the key whose hash equals the current `MEDIALIT_BOOTSTRAP_API_KEY` (§6.4) | +| `POST` | `/api/apps/:appId/keys` | `{ name, expiresAt? }` | `201` with the item plus `key` (shown once); `409 { error: "api_key_limit_reached" }` when the app already has 10 active keys | +| `DELETE` | `/api/apps/:appId/keys/:keyId` | — | `204`; `404` if there is no active key with that ID; `409 { error: "managed_by_configuration" }` for the currently configured bootstrap key (§6.4) | + +`keyId` in responses is the public `api_keys.api_key_id`. + +Changes to existing routes: + +- `/api/apps/:keyId/*` becomes `/api/apps/:appId/*`. The values are the same, + so this is a rename only. +- `POST /api/apps` returns `{ appId, name, key }`, where `key` is the first + key's secret, shown once. +- `GET /api/apps/:appId` no longer returns a secret. +- `DELETE /api/apps/:appId` soft-deletes the app and revokes its keys. +- Create and revoke write a `log.info` audit line with the app, key ID, prefix + and user, but never the secret. + +MCP: no key-management tools in this change. If they're added later, deny +API-key callers, as FrontLit does. + +## 9. Web app + +- **Route.** Rename `app/app/[keyid]` to `app/app/[appId]`. The URL values are + unchanged. +- **Settings page.** Replace `ApiKeyControl` with an **API keys** section: + - A table with name, prefix, created and last used. + - A **Create key** dialog with two steps: first the name, then "Store this key + now. It won't be shown again", with a read-only field and a copy button. + - Revoke behind a confirmation dialog. +- **Delete.** `api-key-control.tsx` and the unused `copy-apikey-button.tsx`. +- **`app/actions.ts`.** `getApikeyUsingKeyId` becomes `getApp(appId)`, which + returns no secret. Add `listKeys`, `createKey` and `revokeKey`. Rename `keyId` + to `appId` throughout. +- **`components/new-app-button.tsx`.** After creating an app, show its first key + using the same "store this key now" step, then navigate to the app. + +## 10. Other changes + +- **Shared models (`packages/models/src/api-key.ts`).** Split `Apikey` into + `App` and `ApiKey`, and drop `restriction`, `httpReferrers` and `ipAddresses`. +- **SDK types (`packages/medialit/src/types.ts`).** Drop `Media.apikey`. The SDK, + CLI and uploader need no other changes, because they send the same header. +- **Admin bootstrap (`apps/api/src/index.ts`).** Replace `createAdminUser()` + with `bootstrap()` (§6.4), and remove the `"Admin user created"` log line that + printed the key. +- **Integration stack (`packages/integration-tests/scripts/run-with-local-stack.sh`).** + Generate `ml_live_$(openssl rand -hex 32)`, then pass it to the API as + `MEDIALIT_BOOTSTRAP_API_KEY` and to the suites as `MEDIALIT_APIKEY`. This + replaces scraping the key from the log. +- **MediaLit's own self-hosting files.** + - `docker-compose.yml` passes `MEDIALIT_BOOTSTRAP_API_KEY=${MEDIALIT_BOOTSTRAP_API_KEY:-}`. + - `.env.example` documents it, with the generation command. + - Standalone installs can leave it empty and create keys in the dashboard. +- **Bundled products.** Follow-up PRs in SendLit, FrontLit and CourseLit adopt + the compose pattern from §6.4 when they bundle MediaLit. +- **`packages/scripts/src/import-mongo.ts`.** Hash imported secrets into + `api_keys` as `legacy` keys. Map `doc.apikey` to `app_id` through an in-memory + map from secret to app. +- **Docs.** + - Add `apps/docs/content/docs/api-keys.mdx`. + - Update `concepts.mdx`, `dashboard.mdx`, `self-hosting.mdx` and + `quick-start.mdx`. `self-hosting.mdx` gains a "Bootstrap API key" section: + format, generation, rotation, and the bundled-compose pattern. + - Add an upgrade note covering four points: + - **Migrations run automatically with Docker Compose.** The `init` service + runs `apps/api/src/db/migrate.ts` before the API starts, so upgrading is + `docker compose pull && docker compose up -d`. Deployments that run the + image without Compose must run that command themselves before starting + the new version. `self-hosting.mdx` doesn't mention migrations today, so + add this there too. + - Existing keys keep working but can't be displayed again. Take a database + backup before upgrading: `0008` drops the plaintext column and can't be + rolled back. If `0008` fails, for example on a media row whose key no + longer exists, the transaction rolls back and the API doesn't start. + - The body `apikey` field is no longer accepted; send the + `x-medialit-apikey` header. + - Deactivated accounts now get 403 from the API. + +## 11. Tests + +| Area | What to test | +| --- | --- | +| `apikey/secret.test.ts` | Prefix, length and entropy, hash, display prefix | +| `apikey/queries.test.ts` (PGlite) | Only the hash is stored; lookup by secret works; revoked keys, expired keys and keys of deleted apps all return `null`; `last_used_at` is written and throttled | +| Migration test (PGlite) | The migration applies cleanly after `0000`–`0007`, with no index or constraint name collisions; a legacy plaintext secret still authenticates after the backfill; media, settings, signatures and TUS uploads map to the right `app_id`; an unmapped row fails the migration | +| `repository.test.ts` | Update the plaintext and deleted-key assertions; `softDeleteApp` revokes keys and deletes signatures; `ensureProfile` creates an app with no keys | +| `media/routes` upload tests | No credential in headers or query: 401 and no temp file written; a multipart `apikey` field alone: 401; `Content-Length` over the plan limit: 400 before parsing; over the remaining storage: 403 before parsing; header key and signature uploads still succeed | +| `bootstrap.test.ts` (PGlite) | Unset variable: admin app exists with no keys and nothing secret is logged; set: the key authenticates; restarting with the same value changes nothing; a new value revokes the old key and deletes its signatures; reverting to an old value un-revokes that key; a malformed value or a dashboard key's value exits non-zero; removing the variable leaves the key active and revocable | +| Bootstrap revoke guard | Revoking the configured bootstrap key gets 409 `managed_by_configuration`; after the variable is removed, it revokes normally | +| Inactive-account tests | An inactive owner gets 403 `account_inactive` via API key, OAuth bearer, signature (REST and TUS) and MCP; dashboard session routes still work | +| Signature tests | A key-minted signature records `api_key_id`, over both REST and the MCP `signature` and `upload` tools; revoking the key deletes its signatures; a signature is rejected after its key is revoked or its app deleted; OAuth- and dashboard-minted signatures survive revoking a key | +| `auth/__tests__/resolve-auth.test.ts`, `middleware.test.ts` | A body `apikey` alone gets 401, and the header-versus-body tests are removed; `req.appId` is set; `req.apiKeyId` is set only for API-key auth; no secret appears on the request or the auth result; OAuth maps the `app_id` claim to the internal ID | +| `__tests__/tus/tus-server.test.ts` | A revoked key and a deleted app's key are both rejected | +| `dashboard/routes.test.ts` | Key CRUD; another user's `appId` gets 404; the secret appears only in create responses; deleting an app revokes its keys; the 11th active key gets 409 `api_key_limit_reached`, and revoking one frees a slot | +| MCP tool tests | Fixtures use `appId`; `whoami` returns the public app ID and no secret | +| Integration suite | Passes unchanged against the local stack | + +## 12. Delivery + +The schema change and the switch from `apikey` to `appId` in the auth context +can't be split: the columns change underneath every query. They ship as one PR, +with one commit per section: §5 and §6, then §7, then §8. + +The web UI (§9) and docs (§10) can be a second PR, but must ship in the same +release, because `GET /api/apps/:appId` stops returning the key. + +## 13. Decisions + +None are open. + +### Resolved + +- **Hashing (2026-10-17): plain SHA-256, not HMAC with a pepper.** + - **Consistency.** SendLit and FrontLit hash API keys with plain SHA-256. The + org uses HMAC only where the input can be guessed: webhook signatures, and + SendLit's suppression-list fingerprint over email addresses. + - **No practical protection lost.** A pepper only helps when someone has the + database but not the server's environment. New keys carry 256 bits of + entropy, and legacy keys about 126 bits, so brute-forcing them from their + hashes is infeasible either way. + - **Bundled installs.** Without a pepper, each product's compose file needs no + extra permanent secret, and the bootstrap key (§6.4) can be any random + value. + - **The conflict.** The platform's decision record 0005, and its + `reference-product` and `saas-product` template, call for HMAC with a pepper. + Reconciling that is tracked in codelitdev/platform#9: either accept SHA-256 for + random secrets of at least 256 bits, or migrate all products together. + +- **The body `apikey` field (2026-10-17): removed on every route, with no + opt-in flag.** MediaLit has no external users yet, and every internal + consumer (CourseLit, FrontLit, SendLit) already sends the header. Keeping the + field on uploads would have left the pre-authentication disk-write gap open. + Keeping it on JSON routes would only preserve a second credential path and + its ambiguity rule. + +- **Drop the plaintext column in the same migration (2026-10-17): yes.** + `0008_api_keys.sql` drops `apps.key` once the tenant tables are re-keyed, so + no plaintext secret survives the upgrade. There is no rollback window. A + database backup taken before migrating is the recovery path, and the upgrade + note (§10) requires it. With no external users, a one-release rollback window + would only keep plaintext keys around longer. +- **Active keys per app (2026-10-17): at most 10.** + - `createApiKey` counts the app's active keys (not revoked, not expired) + inside the insert transaction, locking the app row with `FOR UPDATE` so two + concurrent creates can't both pass the check. + - Over the limit, `POST /api/apps/:appId/keys` returns 409 + `api_key_limit_reached`, and the dashboard disables **Create key** with a + hint to revoke an unused key. + - Keys created by the system (the bootstrap key and the first key of a new + app) skip the check but still count toward the 10. +- **Key expiry (2026-10-17): in the API, not in the UI.** + - `api_keys.expires_at` is stored, and lookups treat expired keys as + invalid. + - `POST /api/apps/:appId/keys` accepts an optional `expiresAt`, which must be + in the future; anything else gets 400. + - The dashboard's create dialog doesn't offer it yet, matching SendLit. + Adding a picker later needs no schema or API change. From 8f03532ffcea768dbee94500fee15ae42c5a128c Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 17 Oct 2026 15:41:58 +0530 Subject: [PATCH 2/7] feat(api): hashed, revocable API keys per app Ports the SendLit/FrontLit API key model (docs/prds/apikey.md). - Migration 0008 renames api_keys to apps (the tenant) and adds a new api_keys table that stores only SHA-256 hashes. Existing plaintext keys become hashed legacy keys, so integrations keep working. Media, settings, signatures and TUS uploads now reference apps.id instead of the secret. - Keys are ml_live_ + 32 random bytes, shown once. Apps can have up to 10 active keys, with optional expiry and last-used tracking. - Revoking a key or deleting an app stops its keys and the signatures they minted. Deleted apps' keys no longer authenticate, including over TUS. - API keys are read only from the x-medialit-apikey header; the body field is gone. Uploads authenticate and check their declared size before the body is read. - Deactivated accounts get 403 account_inactive on every API credential. - MEDIALIT_BOOTSTRAP_API_KEY seeds a key for bundled installs at startup; the admin key is no longer logged. - Dashboard API: list, create and revoke keys per app; routes use appId. --- apps/api/__tests__/mcp/media-tool.test.ts | 2 +- apps/api/__tests__/mcp/upload-tool.test.ts | 2 +- apps/api/__tests__/mcp/whoami-tool.test.ts | 20 +- .../media/utils/get-public-urls.test.ts | 2 +- apps/api/__tests__/tus/tus-server.test.ts | 2 +- apps/api/drizzle/0008_api_keys.sql | 191 + apps/api/drizzle/meta/0008_snapshot.json | 4048 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/apikey/bootstrap-config.ts | 30 + apps/api/src/apikey/queries.ts | 75 +- apps/api/src/apikey/secret.test.ts | 29 + apps/api/src/apikey/secret.ts | 28 + .../api/src/auth/__tests__/middleware.test.ts | 26 +- .../__tests__/oauth-app-selection.test.ts | 12 +- .../src/auth/__tests__/resolve-auth.test.ts | 212 +- apps/api/src/auth/middleware.ts | 6 +- apps/api/src/auth/oauth-app-pages.ts | 4 +- apps/api/src/auth/oauth-app-selection.ts | 33 +- apps/api/src/auth/resolve-auth.ts | 99 +- apps/api/src/bootstrap.test.ts | 139 + apps/api/src/bootstrap.ts | 79 + apps/api/src/config/constants.ts | 3 - apps/api/src/dashboard/routes.test.ts | 261 +- apps/api/src/dashboard/routes.ts | 281 +- apps/api/src/db/migration-0008.test.ts | 157 + apps/api/src/db/repository.test.ts | 294 +- apps/api/src/db/repository.ts | 608 ++- apps/api/src/db/schema/domain.ts | 167 +- apps/api/src/index.ts | 39 +- apps/api/src/mcp/auth-context.ts | 21 +- apps/api/src/mcp/tools/media.ts | 24 +- apps/api/src/mcp/tools/settings.ts | 8 +- apps/api/src/mcp/tools/signature.ts | 5 +- apps/api/src/mcp/tools/upload.ts | 6 +- apps/api/src/mcp/tools/whoami.ts | 18 +- apps/api/src/media-settings/handlers.ts | 4 +- apps/api/src/media-settings/model.ts | 2 +- apps/api/src/media-settings/queries.ts | 8 +- apps/api/src/media-settings/service.ts | 10 +- apps/api/src/media/GetPageProps.ts | 2 +- apps/api/src/media/handlers.ts | 26 +- apps/api/src/media/queries.ts | 30 +- apps/api/src/media/routes.ts | 41 +- apps/api/src/media/service.ts | 44 +- apps/api/src/media/upload-guards.test.ts | 223 + apps/api/src/media/upload-guards.ts | 80 + apps/api/src/signature/handlers.ts | 3 +- apps/api/src/signature/middleware.ts | 25 +- apps/api/src/signature/queries.ts | 27 +- apps/api/src/signature/service.ts | 54 +- apps/api/src/tus/finalize.ts | 6 +- apps/api/src/tus/queries.ts | 10 +- apps/api/src/tus/tus-server.ts | 91 +- packages/models/src/api-key-restriction.ts | 4 - packages/models/src/api-key.ts | 26 +- packages/models/src/constants.ts | 1 - packages/models/src/index.ts | 3 +- packages/models/src/media.ts | 3 +- packages/scripts/src/import-mongo.test.ts | 2 + packages/scripts/src/import-mongo.ts | 74 +- 60 files changed, 6918 insertions(+), 819 deletions(-) create mode 100644 apps/api/drizzle/0008_api_keys.sql create mode 100644 apps/api/drizzle/meta/0008_snapshot.json create mode 100644 apps/api/src/apikey/bootstrap-config.ts create mode 100644 apps/api/src/apikey/secret.test.ts create mode 100644 apps/api/src/apikey/secret.ts create mode 100644 apps/api/src/bootstrap.test.ts create mode 100644 apps/api/src/bootstrap.ts create mode 100644 apps/api/src/db/migration-0008.test.ts create mode 100644 apps/api/src/media/upload-guards.test.ts create mode 100644 apps/api/src/media/upload-guards.ts delete mode 100644 packages/models/src/api-key-restriction.ts 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..04abe400 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,176 @@ 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("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); + + 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..11772009 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 } from "../apikey/queries"; +import { configuredBootstrapKeyHash } from "../apikey/bootstrap-config"; import type { MedialitAuth } from "../auth/better-auth"; import { accountSubscription, @@ -34,15 +41,76 @@ 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 }; +} + +/* 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 +119,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 +168,7 @@ function withKind(media: T) { } type AppSummary = { - keyId: string; + appId: string; name: string; default: boolean; count: number; @@ -118,14 +186,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 +208,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 +299,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 +334,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 +349,58 @@ 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, - }); + app = await createApp({ userId: user.id, name }); } catch (error: any) { res.status(400).json({ error: error.message }); + 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 +410,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 +420,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 +443,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 +458,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 +486,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 +511,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 +521,82 @@ 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.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)), + }); + }); + + 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/:keyId", async (req, res) => { + 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,9 +605,9 @@ 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 }); @@ -477,22 +616,22 @@ export function createDashboardRouter(auth: MedialitAuth) { } }); - 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/packages/models/src/api-key-restriction.ts b/packages/models/src/api-key-restriction.ts deleted file mode 100644 index 9d1ce063..00000000 --- a/packages/models/src/api-key-restriction.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { apikeyRestriction } from "./constants"; - -const apikeyRes = [...apikeyRestriction] as const; -export type APIKEY_RESTRICTION = (typeof apikeyRes)[number]; diff --git a/packages/models/src/api-key.ts b/packages/models/src/api-key.ts index 41e60ec3..8f7a84ce 100644 --- a/packages/models/src/api-key.ts +++ b/packages/models/src/api-key.ts @@ -1,13 +1,21 @@ -import type { APIKEY_RESTRICTION } from "./api-key-restriction"; +/** An app as the dashboard sees it. `appId` is the public ID. */ +export interface App { + appId: string; + name: string; + default: boolean; +} -export interface Apikey { +/** An API key as the dashboard sees it. The secret is never part of it; it is + * returned once, when the key is created. `keyId` is the public ID. */ +export interface ApiKey { keyId: string; name: string; - key: string; - userId: string; - restriction?: APIKEY_RESTRICTION; - httpReferrers?: string[]; - ipAddresses?: string[]; - default: boolean; - deleted: boolean; + /** The first characters of the key, e.g. `ml_live_a1b2`. */ + keyPrefix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + /** True for the key set by `MEDIALIT_BOOTSTRAP_API_KEY`, which can't be + * revoked while it is configured. */ + managedByConfiguration: boolean; } diff --git a/packages/models/src/constants.ts b/packages/models/src/constants.ts index c23dc32a..f3c9a4ea 100644 --- a/packages/models/src/constants.ts +++ b/packages/models/src/constants.ts @@ -1,4 +1,3 @@ -export const apikeyRestriction = ["referrer", "ipaddress", "custom"]; export const AccessControl = { PRIVATE: "private", PUBLIC: "public", diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 2d3b40dd..a6bb401f 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,5 +1,4 @@ -export type { APIKEY_RESTRICTION } from "./api-key-restriction"; -export type { Apikey } from "./api-key"; +export type { ApiKey, App } from "./api-key"; export * as Constants from "./constants"; export type { Media } from "./media"; export type { MediaWithUserId } from "./media-schema"; diff --git a/packages/models/src/media.ts b/packages/models/src/media.ts index dc012438..5497d5e5 100644 --- a/packages/models/src/media.ts +++ b/packages/models/src/media.ts @@ -3,7 +3,8 @@ import { AccessControl } from "./access-control"; export interface Media { fileName: string; mediaId: string; - apikey: string; + /** Internal ID of the app the media belongs to. */ + appId: string; originalFileName: string; mimeType: string; size: number; diff --git a/packages/scripts/src/import-mongo.test.ts b/packages/scripts/src/import-mongo.test.ts index 875cc61d..82f04c58 100644 --- a/packages/scripts/src/import-mongo.test.ts +++ b/packages/scripts/src/import-mongo.test.ts @@ -18,6 +18,7 @@ describe("missingImportTables", () => { expect(missingImportTables([])).toEqual([ "user", "profiles", + "apps", "api_keys", "media", "media_settings", @@ -30,6 +31,7 @@ describe("missingImportTables", () => { missingImportTables([ "user", "profiles", + "apps", "api_keys", "media", "media_settings", diff --git a/packages/scripts/src/import-mongo.ts b/packages/scripts/src/import-mongo.ts index 87af9368..55dd5157 100644 --- a/packages/scripts/src/import-mongo.ts +++ b/packages/scripts/src/import-mongo.ts @@ -2,20 +2,24 @@ import { MongoClient, type Document } from "mongodb"; import pg from "pg"; import { closeDb, - createApiKey, + createApp, createMediaRecord, createSignature, ensureProfile, findUserByEmail, + insertApiKey, insertAuthUser, - listApiKeys, + listApps, + revokeApiKey, upsertMediaSettings, } from "../../../apps/api/src/db/index.js"; +import { hashApiKeySecret } from "../../../apps/api/src/apikey/secret.js"; // The API migrations create these tables. This script only writes rows. const IMPORT_TABLES = [ "user", "profiles", + "apps", "api_keys", "media", "media_settings", @@ -88,6 +92,9 @@ async function main() { const db = client.db(); const users = await db.collection("users").find({}).toArray(); const importedUserIds = new Map(); + // Mongo rows name their app by its plaintext key; Postgres stores + // only the key's hash, so map each key to its app while importing. + const importedKeys: ImportedKeys = new Map(); let importedUsers = 0; for (const doc of users) { const mongoId = idOf(doc._id); @@ -108,7 +115,7 @@ async function main() { emailVerified: true, }); importedUserIds.set(mongoId, userId); - await importKeys(db, doc._id, userId); + await importKeys(db, doc._id, userId, importedKeys); // Subscriptions are not imported: paid plans come only from the // billing tables, so v0.4.0 subscription fields are dropped. await ensureProfile({ @@ -123,6 +130,7 @@ async function main() { const media = await db.collection("media").find({}).toArray(); let importedMedia = 0; let skippedMedia = 0; + let unknownKeyMedia = 0; const orphanedMediaOwners = new Set(); for (const doc of media) { const mongoUserId = idOf(doc.userId); @@ -132,12 +140,17 @@ async function main() { orphanedMediaOwners.add(mongoUserId); continue; } + const key = importedKeys.get(String(doc.apikey)); + if (!key) { + unknownKeyMedia += 1; + continue; + } await createMediaRecord({ id: idOf(doc._id), fileName: String(doc.fileName), mediaId: String(doc.mediaId), userId, - apikey: String(doc.apikey), + appId: key.appId, originalFileName: String(doc.originalFileName), mimeType: String(doc.mimeType), size: Number(doc.size || 0), @@ -169,10 +182,16 @@ async function main() { orphanedSignatureOwners.add(mongoUserId); continue; } + const key = importedKeys.get(String(doc.apikey)); + if (!key) { + skippedSignatures += 1; + continue; + } await createSignature({ id: idOf(doc._id), userId, - apikey: String(doc.apikey), + appId: key.appId, + apiKeyId: key.apiKeyId, signature: String(doc.signature), validTill: asDate(doc.validTill) || new Date(), group: doc.group ? String(doc.group) : undefined, @@ -186,6 +205,11 @@ async function main() { `skipped ${skippedMedia} media records owned by missing Mongo users: ${[...orphanedMediaOwners].join(", ")}`, ); } + if (unknownKeyMedia > 0) { + console.warn( + `skipped ${unknownKeyMedia} media records whose API key was not imported`, + ); + } if (skippedSignatures > 0) { console.warn( `skipped ${skippedSignatures} signatures owned by missing Mongo users: ${[...orphanedSignatureOwners].join(", ")}`, @@ -198,6 +222,7 @@ async function main() { media: importedMedia, signatures: importedSignatures, skippedMedia, + unknownKeyMedia, skippedSignatures, }), ); @@ -218,12 +243,12 @@ async function importMediaSettings( .toArray(); const setting = settings[0]; if (!setting) return; - // Old settings were stored per user. The new table is per API key. - const keys = await listApiKeys(userId); - for (const key of keys) { + // Old settings were stored per user. The new table is per app. + const apps = await listApps(userId); + for (const app of apps) { await upsertMediaSettings({ userId, - apikey: key.key, + appId: app.id, useWebP: Boolean(setting.useWebP), webpOutputQuality: setting.webpOutputQuality == null @@ -241,10 +266,16 @@ async function importMediaSettings( } } +type ImportedKeys = Map; + +/* Each Mongo key was both an app and its key. It becomes an app with the + same public ID plus a hashed `legacy` key, so the old secret keeps + working. Keys of deleted apps are imported revoked. */ async function importKeys( db: ReturnType, mongoUserId: unknown, userId: string, + importedKeys: ImportedKeys, ) { const keys = await db .collection("apikeys") @@ -252,24 +283,27 @@ async function importKeys( .toArray(); for (const key of keys) { if (!key.key) continue; + const secret = String(key.key); const keyId = key.keyId ? String(key.keyId) : undefined; - await createApiKey({ + const deleted = Boolean(key.deleted); + const app = await createApp({ userId, name: key.name ? String(key.name) : `Imported key ${keyId || idOf(key._id)}`, - key: String(key.key), - keyId, + appId: keyId, isDefault: Boolean(key.default), - deleted: Boolean(key.deleted), - restriction: key.restriction ? String(key.restriction) : undefined, - httpReferrers: Array.isArray(key.httpReferrers) - ? key.httpReferrers.map(String) - : [], - ipAddresses: Array.isArray(key.ipAddresses) - ? key.ipAddresses.map(String) - : [], + deleted, + }); + const apiKey = await insertApiKey({ + appId: app.id, + name: "Default key", + keyHash: hashApiKeySecret(secret), + keyPrefix: secret.slice(0, 4), + createdByType: "legacy", }); + if (deleted) await revokeApiKey(app.id, apiKey.apiKeyId); + importedKeys.set(secret, { appId: app.id, apiKeyId: apiKey.id }); } } From 55a39d8bb8b0c347ee5177b7923fe1ad81abf688 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 17 Oct 2026 15:42:42 +0530 Subject: [PATCH 3/7] chore: seed bootstrap API key in self-hosting and the integration stack The local integration stack sets MEDIALIT_BOOTSTRAP_API_KEY instead of scraping the admin key from the API log, which no longer contains it. docker-compose.yml and the env examples document the setting. The SDK's Media type drops apikey, which the API never returned. --- .changeset/sdk-media-type-apikey.md | 5 +++++ .env.example | 5 +++++ apps/api/.env.example | 5 +++++ docker-compose.yml | 2 ++ .../scripts/run-with-local-stack.sh | 17 +++++++---------- packages/medialit/__tests__/index.test.ts | 3 --- packages/medialit/src/types.ts | 1 - 7 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 .changeset/sdk-media-type-apikey.md 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/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/docker-compose.yml b/docker-compose.yml index 5a7ec353..cea24dd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,8 @@ services: # in .env.example sets development so that it can use http://. - NODE_ENV=${NODE_ENV:-production} - EMAIL=${EMAIL?'Email is required to set up a user'} + # Optional key for EMAIL's default app, created at startup. See .env.example. + - MEDIALIT_BOOTSTRAP_API_KEY=${MEDIALIT_BOOTSTRAP_API_KEY:-} - DATABASE_URL=postgresql://medialit:medialit@postgres:5432/medialit - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET?'BETTER_AUTH_SECRET is required'} # MinIO. The endpoint is its public address because the API signs diff --git a/packages/integration-tests/scripts/run-with-local-stack.sh b/packages/integration-tests/scripts/run-with-local-stack.sh index b080aaed..34445608 100755 --- a/packages/integration-tests/scripts/run-with-local-stack.sh +++ b/packages/integration-tests/scripts/run-with-local-stack.sh @@ -66,6 +66,13 @@ export TEMP_FILE_DIR_FOR_UPLOADS="$workdir/uploads" export MEDIALIT_DEPLOYMENT_MODE=cloud export MAX_STORAGE_ALLOWED_NOT_SUBSCRIBED=1234567890 mkdir -p "$TEMP_FILE_DIR_FOR_UPLOADS" +# The API seeds this key for the EMAIL user at startup, the same way a bundled +# self-hosted install gets its key. It is never logged. +api_key="ml_live_$(openssl rand -hex 32)" +export MEDIALIT_BOOTSTRAP_API_KEY="$api_key" +if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::add-mask::$api_key" +fi echo "--- Migrating the database" (cd apps/api && bun src/db/migrate.ts) @@ -88,16 +95,6 @@ curl -sf "http://localhost:${api_port}/ready" >/dev/null || { exit 1 } -# On first start, the API creates the EMAIL user and logs its API key. -api_key="$(grep -o '"apiKey":"[^"]*"' "$api_log" | head -n 1 | cut -d '"' -f 4)" -if [[ -z "$api_key" ]]; then - echo "The API did not log an API key for the admin user" - exit 1 -fi -if [[ -n "${GITHUB_ACTIONS:-}" ]]; then - echo "::add-mask::$api_key" -fi - echo "--- Building the SDK, uploader and CLI" bun --filter medialit --filter @medialit/uploader --filter @medialit/cli build >/dev/null diff --git a/packages/medialit/__tests__/index.test.ts b/packages/medialit/__tests__/index.test.ts index 712cc198..0f97f6ae 100644 --- a/packages/medialit/__tests__/index.test.ts +++ b/packages/medialit/__tests__/index.test.ts @@ -88,7 +88,6 @@ describe("MediaLit", () => { size: 12, thumbnailGenerated: false, accessControl: "private", - apikey: mockApiKey, }; const fetchMock = mock.fn( @@ -159,7 +158,6 @@ describe("MediaLit", () => { size: 12, thumbnailGenerated: false, accessControl: "private", - apikey: mockApiKey, }; const fetchMock = mock.fn( @@ -191,7 +189,6 @@ describe("MediaLit", () => { size: 12, thumbnailGenerated: false, accessControl: "private", - apikey: mockApiKey, }; const fetchMock = mock.fn( diff --git a/packages/medialit/src/types.ts b/packages/medialit/src/types.ts index e51c428d..cd7e8163 100644 --- a/packages/medialit/src/types.ts +++ b/packages/medialit/src/types.ts @@ -3,7 +3,6 @@ export type AccessControl = "private" | "public"; export interface Media { fileName: string; mediaId: string; - apikey: string; originalFileName: string; mimeType: string; size: number; From 1d6b39d197564382b86de9f2e299f474dfe67f5d Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 17 Oct 2026 15:45:50 +0530 Subject: [PATCH 4/7] feat(web): manage an app's API keys The settings page lists an app's keys by name and prefix with created and last-used dates, creates keys in a dialog that shows the secret once, and revokes them after confirmation. The configured bootstrap key shows as managed by configuration. Creating an app shows its first key once. The app route segment is now [appId] (same URL values), and the dashboard no longer receives any key secret after creation. --- apps/web/app/actions.ts | 115 ++++--- .../connect/connect-guide.tsx | 4 +- .../app/{[keyid] => [appId]}/connect/page.tsx | 8 +- .../app/{[keyid] => [appId]}/files/actions.ts | 8 +- .../files/empty-files.tsx | 8 +- .../files/file-preview/file-interactivity.tsx | 0 .../files/file-preview/index.tsx | 24 +- .../files/file-preview/use-file-url.ts | 6 +- .../{[keyid] => [appId]}/files/loading.tsx | 0 .../files/media-library.tsx | 10 +- .../app/{[keyid] => [appId]}/files/page.tsx | 18 +- .../files/upload-button.tsx | 4 +- .../files/upload-files.ts | 4 +- apps/web/app/app/[appId]/layout.tsx | 14 + .../{[keyid] => [appId]}/settings/actions.tsx | 25 +- .../app/[appId]/settings/api-keys-section.tsx | 318 ++++++++++++++++++ .../settings/default-app-control.tsx | 6 +- .../settings/delete-app-button.tsx | 10 +- .../{[keyid] => [appId]}/settings/loading.tsx | 0 .../{[keyid] => [appId]}/settings/page.tsx | 55 +-- .../settings/update-settings-form.tsx | 6 +- .../web/app/app/{[keyid] => [appId]}/tabs.tsx | 6 +- apps/web/app/app/[keyid]/layout.tsx | 14 - .../[keyid]/settings/copy-apikey-button.tsx | 22 -- apps/web/app/apps-dashboard.tsx | 4 +- apps/web/app/layout.tsx | 4 +- .../api-key-reveal.tsx} | 36 +- apps/web/components/dashboard-shell.tsx | 18 +- apps/web/components/new-app-button.tsx | 153 ++++++--- 29 files changed, 629 insertions(+), 271 deletions(-) rename apps/web/app/app/{[keyid] => [appId]}/connect/connect-guide.tsx (98%) rename apps/web/app/app/{[keyid] => [appId]}/connect/page.tsx (83%) rename apps/web/app/app/{[keyid] => [appId]}/files/actions.ts (87%) rename apps/web/app/app/{[keyid] => [appId]}/files/empty-files.tsx (98%) rename apps/web/app/app/{[keyid] => [appId]}/files/file-preview/file-interactivity.tsx (100%) rename apps/web/app/app/{[keyid] => [appId]}/files/file-preview/index.tsx (96%) rename apps/web/app/app/{[keyid] => [appId]}/files/file-preview/use-file-url.ts (86%) rename apps/web/app/app/{[keyid] => [appId]}/files/loading.tsx (100%) rename apps/web/app/app/{[keyid] => [appId]}/files/media-library.tsx (98%) rename apps/web/app/app/{[keyid] => [appId]}/files/page.tsx (93%) rename apps/web/app/app/{[keyid] => [appId]}/files/upload-button.tsx (96%) rename apps/web/app/app/{[keyid] => [appId]}/files/upload-files.ts (96%) create mode 100644 apps/web/app/app/[appId]/layout.tsx rename apps/web/app/app/{[keyid] => [appId]}/settings/actions.tsx (68%) create mode 100644 apps/web/app/app/[appId]/settings/api-keys-section.tsx rename apps/web/app/app/{[keyid] => [appId]}/settings/default-app-control.tsx (94%) rename apps/web/app/app/{[keyid] => [appId]}/settings/delete-app-button.tsx (92%) rename apps/web/app/app/{[keyid] => [appId]}/settings/loading.tsx (100%) rename apps/web/app/app/{[keyid] => [appId]}/settings/page.tsx (78%) rename apps/web/app/app/{[keyid] => [appId]}/settings/update-settings-form.tsx (95%) rename apps/web/app/app/{[keyid] => [appId]}/tabs.tsx (80%) delete mode 100644 apps/web/app/app/[keyid]/layout.tsx delete mode 100644 apps/web/app/app/[keyid]/settings/copy-apikey-button.tsx rename apps/web/{app/app/[keyid]/settings/api-key-control.tsx => components/api-key-reveal.tsx} (54%) 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..1e40226c --- /dev/null +++ b/apps/web/app/app/[appId]/settings/api-keys-section.tsx @@ -0,0 +1,318 @@ +"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"; + +function formatDate(value: string | null): string { + if (!value) return "Never"; + return new Date(value).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +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: {