Files
platform/src/servers/auth-token.ts
T
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00

110 lines
4.7 KiB
TypeScript

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 `permissionGateMiddleware`, 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 permission 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<AuthIdentity | null> {
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<AuthIdentity | null> {
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;
}
}