From 00997f5ff1076346859a85046c3f12dd1295a919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 8 Aug 2026 10:10:14 +0000 Subject: [PATCH] per-user api keys, resolved at both identity doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a user can mint a long-lived key for an app or a device instead of carrying a 30-day session, so multiple logins on the mobile apps are per-device revocable rather than one shared token. identity was being decided independently in userMiddleware and originScopeMiddleware, each verifying the token itself. teaching only one of them a new credential format is how those two stop agreeing, so both now call resolveAuthToken and neither knows what a bearer string is. verified: a member's key returns the same status as their jwt on every route tried, 403s included. a key carries its holder's full authority — not an escalation, it equals what the password could already do. scoping wants a scopes column, not a change here. Co-Authored-By: Claude Opus 5 --- docs/chat-ui-walkthrough.md | 2 +- src/databases/officer_db/src/index.ts | 9 ++ .../officer_db/src/queries/api-keys.ts | 113 ++++++++++++++++++ .../officer_db/src/schema/api-keys.ts | 53 ++++++++ src/databases/officer_db/src/schema/index.ts | 1 + src/databases/officer_db/src/types.ts | 6 +- src/servers/_middlewares/origin-validation.ts | 15 +-- src/servers/_middlewares/user-middleware.ts | 34 +++--- src/servers/api/api-keys/router.ts | 62 ++++++++++ src/servers/api/auth/signout.ts | 7 +- src/servers/auth-token.ts | 109 +++++++++++++++++ src/servers/capabilities/registry.ts | 8 +- src/servers/hono.ts | 2 + 13 files changed, 391 insertions(+), 30 deletions(-) create mode 100644 src/databases/officer_db/src/queries/api-keys.ts create mode 100644 src/databases/officer_db/src/schema/api-keys.ts create mode 100644 src/servers/api/api-keys/router.ts create mode 100644 src/servers/auth-token.ts diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md index e6412abf..ab4fb480 100644 --- a/docs/chat-ui-walkthrough.md +++ b/docs/chat-ui-walkthrough.md @@ -608,4 +608,4 @@ a separate job. `border-box`, percentage heights resolve against the content box, so there is no overflow. I did not change it. - **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is - recorded in `nav-test-checklist.md` in the workspace root. + recorded under "Known and deliberately unfixed" in the workspace-root `CLAUDE.md`. diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 91675066..f99104b0 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -19,6 +19,15 @@ export { cleanupExpiredTokens, } from './queries/auth'; +export { + findLiveApiKeyByHash, + createApiKey, + listApiKeys, + revokeApiKey, + touchApiKey, + type ApiKeyIdentity, +} from './queries/api-keys'; + export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config'; export { diff --git a/src/databases/officer_db/src/queries/api-keys.ts b/src/databases/officer_db/src/queries/api-keys.ts new file mode 100644 index 00000000..f227301d --- /dev/null +++ b/src/databases/officer_db/src/queries/api-keys.ts @@ -0,0 +1,113 @@ +import { eq, and, isNull, sql } from 'drizzle-orm'; +import { db } from '../db'; +import { apiKeys, users } from '../schema'; +import type { ApiKeySelect } from '../types'; + +// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is +// the one call that does not yet know who is asking. That is what the unique index on key_hash is for. + +/** What the resolver needs to turn a key into a caller. Joined so authentication is a single round trip. */ +export type ApiKeyIdentity = { + keyId: number; + userId: number; + email: string; + username: string | null; + lastUsedAt: Date | null; +}; + +/** + * Look a key up by the hash of its secret, for authentication. + * + * Filters revoked and expired rows in SQL rather than returning them for the caller to check — a + * forgotten check at a call site is an accepted credential, so there is deliberately no way to ask this + * function for a dead key. + */ +export async function findLiveApiKeyByHash(keyHash: string): Promise { + const [row] = await db + .select({ + keyId: apiKeys.id, + userId: apiKeys.userId, + email: users.email, + username: users.username, + lastUsedAt: apiKeys.lastUsedAt, + }) + .from(apiKeys) + .innerJoin(users, eq(users.id, apiKeys.userId)) + .where( + and( + eq(apiKeys.keyHash, keyHash), + isNull(apiKeys.revokedAt), + sql`(${apiKeys.expiresAt} IS NULL OR ${apiKeys.expiresAt} > now())`, + ), + ) + .limit(1); + return row ?? null; +} + +type CreateApiKeyParams = { + userId: number; + name: string; + keyHash: string; + prefix: string; + expiresAt?: Date | null; +}; + +export async function createApiKey(params: CreateApiKeyParams): Promise { + const [row] = await db + .insert(apiKeys) + .values({ + userId: params.userId, + name: params.name, + keyHash: params.keyHash, + prefix: params.prefix, + expiresAt: params.expiresAt ?? null, + }) + .returning(); + return row!; +} + +/** The caller's own keys, newest first. Never returns key_hash — nothing outside authentication needs it. */ +export async function listApiKeys(userId: number): Promise[]> { + return db + .select({ + id: apiKeys.id, + userId: apiKeys.userId, + name: apiKeys.name, + prefix: apiKeys.prefix, + lastUsedAt: apiKeys.lastUsedAt, + expiresAt: apiKeys.expiresAt, + revokedAt: apiKeys.revokedAt, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userId)) + .orderBy(sql`${apiKeys.createdAt} desc`); +} + +/** + * Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller + * naming someone else's key id revokes nothing and is told the same "not found" as for an id that never + * existed — the row count, not a separate ownership read that could drift from this one. + */ +export async function revokeApiKey(id: number, userId: number): Promise { + const rows = await db + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt))) + .returning({ id: apiKeys.id }); + return rows.length > 0; +} + +/** How stale last_used_at is allowed to get. A write per request is not worth this column's value. */ +const TOUCH_DEBOUNCE_MS = 60_000; + +/** + * Record that a key was used, at most once a minute per key. + * + * Callers pass the `lastUsedAt` they already read during authentication, so the common case costs no + * extra query — and this deliberately does not await anything the request depends on. + */ +export async function touchApiKey(id: number, lastUsedAt: Date | null): Promise { + if (lastUsedAt && Date.now() - lastUsedAt.getTime() < TOUCH_DEBOUNCE_MS) return; + await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, id)); +} diff --git a/src/databases/officer_db/src/schema/api-keys.ts b/src/databases/officer_db/src/schema/api-keys.ts new file mode 100644 index 00000000..a14f6da7 --- /dev/null +++ b/src/databases/officer_db/src/schema/api-keys.ts @@ -0,0 +1,53 @@ +import { pgTable, serial, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; +import { users } from './auth'; + +// Long-lived credentials a user mints for themselves, so a native app can hold one instead of a password. +// +// The problem they solve: the mobile apps sign in and get a 30-day JWT, which means every app on every +// device is the same credential with the same expiry, and signing out of one is signing out of all of +// them. A key is per-app and per-device, revocable on its own, and does not expire underneath someone. +// +// ── What is stored ── +// +// `keyHash` is a SHA-256 of the secret and the secret itself is never written down — it is returned once, +// at creation, and is unrecoverable afterwards. SHA-256 rather than argon2 (which every password here +// uses) because the input is 32 bytes from a CSPRNG, not something a human chose: there is no dictionary +// to attack and nothing for a slow hash to buy, while a slow hash WOULD force a full table scan on every +// request, since we look a key up by its hash rather than by knowing whose it is. Same reasoning as +// GitHub's and Stripe's. +// +// `prefix` is the first few characters of the secret, kept in the clear purely so the UI can show which +// key is which. It is not enough to authenticate with. +// +// ── Authority ── +// +// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a +// hole: it is exactly what the password that made it could already do, so a key is never an escalation. +// A `scopes` column is the intended next step, and the resolver is written so adding one touches the +// resolver and this table, nothing else. +export const apiKeys = pgTable( + 'api_keys', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the user called it — "iPhone", "Music app on the iPad". Theirs to choose, shown back in lists. */ + name: text('name').notNull(), + /** SHA-256 of the secret, hex. The lookup key: unique, so a collision is a constraint error not a mix-up. */ + keyHash: text('key_hash').notNull(), + /** First characters of the secret, in the clear, for display only. */ + prefix: text('prefix').notNull(), + /** + * Updated on use, but at most once a minute — see touchApiKey. Without the debounce every + * authenticated request would carry a write, which is a lot of churn to answer "is this still in use". + */ + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + /** Null means it does not expire. Set at creation and never edited. */ + expiresAt: timestamp('expires_at', { withTimezone: true }), + /** Set instead of deleting the row, so a revoked key's name and last use survive for the audit. */ + revokedAt: timestamp('revoked_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('uq_api_keys_key_hash').on(table.keyHash), index('idx_api_keys_user').on(table.userId)], +); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 876a580e..24c46926 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,4 +1,5 @@ export * from './agent-panels'; +export * from './api-keys'; export * from './auth'; export * from './capabilities'; export * from './chat-events'; diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index de036698..7b52c469 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -36,7 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert; // ── Saved Sessions ── - // ── Dashboards ── export type DashboardSelect = typeof Schema.dashboards.$inferSelect; @@ -77,3 +76,8 @@ export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert; // Notifications export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect; export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert; + +// ── API keys ── + +export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect; +export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert; diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 65b3e534..73857b31 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -1,7 +1,7 @@ import type { MiddlewareHandler } from 'hono'; import * as errors from '../custom-errors'; import { IS_DEV_BUILD } from '../build-env'; -import { verify } from '../jwt'; +import { resolveAuthToken } from '../auth-token'; import { isSuperAdmin } from '../super-admin'; import { isApiRequestAllowed } from '../capabilities/authorize'; import { isExemptApiPath } from '../capabilities/totality'; @@ -185,17 +185,12 @@ export const originScopeMiddleware: MiddlewareHandler = async function (ctx, nex const path = ctx.req.path; const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer')); - // Verify the caller's token once (if any); a missing/invalid token stays null. + // Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through + // the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as + // it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts. const authorization = ctx.req.header('authorization'); const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined; - let payload: { id?: number } | null = null; - if (token) { - try { - payload = await verify(token); - } catch { - payload = null; - } - } + const payload = token ? await resolveAuthToken(token) : null; const isOwner = payload ? await isSuperAdmin(payload) : false; // 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index aa788b90..7107d523 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from 'hono'; -import { verify } from '@@/jwt'; +import { resolveAuthToken } from '@@/auth-token'; import * as errors from '@@/custom-errors'; import { isOriginAllowed, isMusicOriginExempt } from './origin-validation'; import { isLockdown, noteBlocked } from '../api/auth/panic'; @@ -36,22 +36,28 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) { } try { - const user = await verify(token); + // Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only: + // an API key's revocation is a column checked during that lookup, so there is no blacklist to consult + // and no issued-at to compare. A password change deliberately does NOT kill a user's API keys; they + // are the credential you rotate independently, which is the whole reason they exist. + const user = await resolveAuthToken(token); if (!user) throw errors.UNAUTHORIZED(); - // Check if token is blacklisted (explicit signout) - if (user.jti) { - if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED(); - } + if (user.via === 'jwt') { + // Check if token is blacklisted (explicit signout) + if (user.jti) { + if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED(); + } - // Check if token was issued before password change - if (user.iat && user.id) { - const dbUser = await getUserById(user.id); - if (dbUser?.passwordChangedAt) { - // iat is in seconds, passwordChangedAt is a Date - const tokenIssuedAt = user.iat * 1000; - if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) { - throw errors.UNAUTHORIZED(); + // Check if token was issued before password change + if (user.iat && user.id) { + const dbUser = await getUserById(user.id); + if (dbUser?.passwordChangedAt) { + // iat is in seconds, passwordChangedAt is a Date + const tokenIssuedAt = user.iat * 1000; + if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) { + throw errors.UNAUTHORIZED(); + } } } } diff --git a/src/servers/api/api-keys/router.ts b/src/servers/api/api-keys/router.ts new file mode 100644 index 00000000..d831b014 --- /dev/null +++ b/src/servers/api/api-keys/router.ts @@ -0,0 +1,62 @@ +import { createRouter } from '../../create-router'; +import { createApiKey, listApiKeys, revokeApiKey } from 'officerdb'; +import { mintApiKey } from '../../auth-token'; +import * as errors from '../../custom-errors'; + +// A user's own API keys: mint, list, revoke. Every route acts on `ctx.get('user').id` and nothing accepts +// a userId from the caller, so there is no shape of request here that reaches another account's keys. +// +// The plaintext secret exists for exactly one response. There is deliberately no "show it to me again" +// endpoint — if a key is lost, revoke it and mint another. Same reasoning as the DAV app passwords beside +// this, and the same reason the column holds a hash. +// +// A key currently carries its holder's FULL authority: it resolves to the user, and authorization then +// asks the same questions of that user it would have asked of a browser session. That is not an +// escalation — it can do exactly what the password could already do — but it does mean a leaked key is a +// leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a +// subset of its holder's capabilities is the next step and wants a `scopes` column, not a change here. + +export const apiKeysRouter = createRouter(); + +const MAX_NAME_LENGTH = 100; + +apiKeysRouter.get('/', async (ctx) => { + const user = ctx.get('user'); + return ctx.json({ keys: await listApiKeys(user.id) }); +}); + +apiKeysRouter.post('/', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as { name?: string; expiresInDays?: number } | undefined; + + const name = body?.name?.trim(); + if (!name) throw errors.BAD_REQUEST('name is required'); + if (name.length > MAX_NAME_LENGTH) throw errors.BAD_REQUEST(`name must be ${MAX_NAME_LENGTH} characters or fewer`); + + // Absent means "does not expire" — the common case is a key living in a phone the owner keeps. An + // expiry, when asked for, is enforced in SQL during lookup rather than by anything that has to run. + let expiresAt: Date | null = null; + const days = body?.expiresInDays; + if (days !== undefined && days !== null) { + if (!Number.isInteger(days) || days < 1) throw errors.BAD_REQUEST('expiresInDays must be a positive integer'); + expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + const { secret, keyHash, prefix } = mintApiKey(); + const entry = await createApiKey({ userId: user.id, name, keyHash, prefix, expiresAt }); + + const { keyHash: _hash, ...safe } = entry; + // `key` is returned here and nowhere else. Nothing in the system can produce it again. + return ctx.json({ entry: safe, key: secret }); +}); + +apiKeysRouter.delete('/:id', async (ctx) => { + const user = ctx.get('user'); + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id'); + + // Scoped by user inside the statement: naming someone else's key id revokes nothing and is answered + // with the same 404 as an id that never existed. + if (!(await revokeApiKey(id, user.id))) throw errors.NOT_FOUND('no such api key'); + return ctx.json({ ok: true }); +}); diff --git a/src/servers/api/auth/signout.ts b/src/servers/api/auth/signout.ts index bc13bc49..43a72cc0 100644 --- a/src/servers/api/auth/signout.ts +++ b/src/servers/api/auth/signout.ts @@ -1,10 +1,13 @@ import type { Handler } from 'hono'; +import type { AuthIdentity } from '@@/auth-token'; import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb'; export const signoutHandler: Handler = async (ctx) => { - const user = ctx.get('user') as { id: number; jti: string; exp: number }; + const user = ctx.get('user') as AuthIdentity; - await blacklistToken(user.jti, user.exp); + // Only a session has something to blacklist. An API key cannot be signed out — it is revoked by id in + // the keys API, which is the point of holding one instead of a session. + if (user.jti && user.exp) await blacklistToken(user.jti, user.exp); // Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless). clearVaultTokens(user.id).catch(() => {}); diff --git a/src/servers/auth-token.ts b/src/servers/auth-token.ts new file mode 100644 index 00000000..08d11dc5 --- /dev/null +++ b/src/servers/auth-token.ts @@ -0,0 +1,109 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { findLiveApiKeyByHash, touchApiKey } from 'officerdb'; +import { verify } from './jwt'; + +// How a bearer string becomes a caller. There are two credentials — the 30-day session JWT the web app +// signs in with, and an API key a user mints for an app or a device — and exactly one place that tells +// them apart. +// +// ── One resolver, two doors ── +// +// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router +// mounts, and `originScopeMiddleware`, which runs globally in hono.ts and re-verifies the token itself +// because it must also cover routes that never mount `userMiddleware`. They have to agree about who a +// caller is, and the way they stop agreeing is somebody teaching one of them a credential format the +// other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and +// wave the request through its account backstop. +// +// That is not a hypothetical failure here: the capability totality check exists because a Member was +// 403ing on GET /api/tasks and opening /api/tasks/pipeline/ws in the same minute. So `resolveAuthToken` +// is the only function that turns a bearer string into a caller, and both doors call it. + +/** + * Marks a bearer string as one of ours rather than a JWT. Not a secret and not a checksum — its job is to + * let the resolver pick a path without JWT-verifying something that was never a JWT, and to make a leaked + * key greppable in a log or a repository. + */ +export const API_KEY_PREFIX = 'ofk_'; + +/** Characters of the secret kept in the clear for display. Enough to tell two keys apart, far too few to use. */ +const PREFIX_DISPLAY_LENGTH = API_KEY_PREFIX.length + 6; + +/** 32 bytes from the CSPRNG. base64url so the whole key is copy-pasteable and safe in a query string. */ +const SECRET_BYTES = 32; + +export const hashApiKey = (secret: string): string => createHash('sha256').update(secret).digest('hex'); + +export const looksLikeApiKey = (token: string): boolean => token.startsWith(API_KEY_PREFIX); + +export type MintedApiKey = { + /** The full credential. Shown to the user exactly once and never stored. */ + secret: string; + keyHash: string; + prefix: string; +}; + +export function mintApiKey(): MintedApiKey { + const secret = `${API_KEY_PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`; + return { secret, keyHash: hashApiKey(secret), prefix: secret.slice(0, PREFIX_DISPLAY_LENGTH) }; +} + +/** + * Who a caller is, however they proved it. + * + * Shaped to match the JWT payload the app already puts in `ctx.get('user')` — handlers read `.id` and + * `.email` and must not care which credential arrived. `via` exists for the two checks that are only + * meaningful for a session: the signout blacklist and `passwordChangedAt`. A key's revocation is a + * column, not a blacklist row. + */ +export type AuthIdentity = { + id: number; + email: string; + username?: string | null; + name?: string | null; + via: 'jwt' | 'api-key'; + /** JWT only. */ + jti?: string; + /** JWT only, seconds since the epoch. */ + iat?: number; + /** JWT only, seconds since the epoch. */ + exp?: number; +}; + +/** + * Turn an API key into a caller, or null. + * + * Lookup is by SHA-256 of the whole secret against a unique index, so it is one index hit and an attacker + * cannot narrow the search by guessing at a prefix. SHA-256 rather than argon2 deliberately: the secret is + * 32 CSPRNG bytes with no human pattern to grind, and a slow hash would have to be recomputed per + * candidate row, turning every authenticated request into a table scan. + */ +export async function resolveApiKey(secret: string): Promise { + const row = await findLiveApiKeyByHash(hashApiKey(secret)); + if (!row) return null; + + // Fire and forget. "When was this last used" must never fail a request or add latency to one; the write + // is debounced to at most once a minute per key inside touchApiKey. + void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {}); + + return { id: row.userId, email: row.email, username: row.username, via: 'api-key' }; +} + +/** + * The single entry point both identity doors use. + * + * Returns null for anything it cannot vouch for — an unsigned or expired JWT, an unknown key, a revoked + * one. It answers "is this credential live, and whose", and nothing else: the blacklist and + * `passwordChangedAt` checks stay in `userMiddleware`, which is the door that owns session invalidation. + */ +export async function resolveAuthToken(token: string): Promise { + if (looksLikeApiKey(token)) return resolveApiKey(token); + + try { + const payload = await verify(token); + if (!payload || typeof payload.id !== 'number') return null; + return { ...payload, via: 'jwt' }; + } catch { + return null; + } +} diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 28908f07..3ef80692 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -86,9 +86,13 @@ export const CAPABILITIES: Capability[] = [ { key: 'account', label: 'Account', - description: 'Sign in, your own profile, password and preferences', + description: 'Sign in, your own profile, password, preferences and API keys', kind: 'core', - api: ['/user', '/dock'], + // `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way + // to present the authority the account already has, so denying it would only force the holder to keep + // using a password in places a password should not go. What a key can then DO is decided by the same + // capability checks as any other request from that user; nothing here widens them. + api: ['/user', '/dock', '/api-keys'], routes: ['/settings/profile'], }, { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 713591db..99a8e447 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -7,6 +7,7 @@ import { serverSettingsRouter } from './api/server-settings/server-settings'; import { landingPageDataRouter } from './api/landing-page-data/landing-page-data'; import { waitlistRouter } from './api/waitlist/waitlist'; import { usersRouter } from './api/users/users-router'; +import { apiKeysRouter } from './api/api-keys/router'; import { plansRouter } from './api/plans/plans'; import { skillsRouter } from './api/skills/skills'; import { tasksRouter } from './api/tasks/tasks'; @@ -176,6 +177,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/scrape', scrapeRouter], ['/upload', uploadRouter], ['/user', settingsRouter], + ['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it ['/dashboards', dashboardsRouter], ['/task-logs', taskLogsRouter], ['/file-browser', fileBrowserRouter],