@haverstack/server 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.dockerignore +10 -0
  3. package/.env.example +90 -0
  4. package/.github/pull_request_template.md +23 -0
  5. package/.github/workflows/ci.yml +55 -0
  6. package/.github/workflows/release.yml +99 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc +7 -0
  9. package/AGENTS.md +91 -0
  10. package/CONTRIBUTING.md +234 -0
  11. package/Dockerfile +30 -0
  12. package/LICENSE +121 -0
  13. package/README.md +107 -0
  14. package/SECURITY.md +54 -0
  15. package/docs/api.md +277 -0
  16. package/docs/deployment.md +212 -0
  17. package/eslint.config.mjs +18 -0
  18. package/package.json +42 -0
  19. package/pnpm-workspace.yaml +13 -0
  20. package/src/app.ts +86 -0
  21. package/src/config.ts +176 -0
  22. package/src/index.ts +54 -0
  23. package/src/lib/changeStreams.ts +23 -0
  24. package/src/lib/frameGate.ts +42 -0
  25. package/src/lib/json.ts +27 -0
  26. package/src/lib/nonceStore.ts +103 -0
  27. package/src/lib/queryLimit.ts +11 -0
  28. package/src/lib/queryWorker/pool.ts +324 -0
  29. package/src/lib/queryWorker/protocol.ts +39 -0
  30. package/src/lib/queryWorker/worker.ts +51 -0
  31. package/src/lib/resumeBuffer.ts +268 -0
  32. package/src/lib/resumeCursor.ts +48 -0
  33. package/src/lib/tokenSweep.ts +43 -0
  34. package/src/middleware/auth.ts +77 -0
  35. package/src/middleware/errors.ts +73 -0
  36. package/src/routes/attachments.ts +126 -0
  37. package/src/routes/auth.ts +137 -0
  38. package/src/routes/changes.ts +279 -0
  39. package/src/routes/entity.ts +33 -0
  40. package/src/routes/health.ts +8 -0
  41. package/src/routes/records.ts +259 -0
  42. package/src/routes/tokens.ts +85 -0
  43. package/src/routes/types.ts +57 -0
  44. package/src/routes/wellknown.ts +55 -0
  45. package/src/shutdown.ts +56 -0
  46. package/src/stack.ts +96 -0
  47. package/src/types.ts +9 -0
  48. package/src/wireError.ts +21 -0
  49. package/tests/app.test.ts +86 -0
  50. package/tests/changeFeedClient.ts +156 -0
  51. package/tests/config.test.ts +112 -0
  52. package/tests/conformance.test.ts +2322 -0
  53. package/tests/fixtures/blockingWorker.ts +27 -0
  54. package/tests/fixtures/brokenWorker.ts +7 -0
  55. package/tests/lib/changeFeedClient.test.ts +126 -0
  56. package/tests/lib/frameGate.test.ts +93 -0
  57. package/tests/lib/queryLimit.test.ts +33 -0
  58. package/tests/lib/queryWorker.test.ts +60 -0
  59. package/tests/lib/queryWorkerPool.test.ts +129 -0
  60. package/tests/lib/resumeBuffer.test.ts +341 -0
  61. package/tests/lib/resumeCursor.test.ts +46 -0
  62. package/tests/middleware/errors.test.ts +176 -0
  63. package/tests/routes/associations.test.ts +170 -0
  64. package/tests/routes/attachments.test.ts +621 -0
  65. package/tests/routes/auth.test.ts +448 -0
  66. package/tests/routes/changes.test.ts +567 -0
  67. package/tests/routes/contentQuery.test.ts +68 -0
  68. package/tests/routes/entity.test.ts +170 -0
  69. package/tests/routes/groups.test.ts +69 -0
  70. package/tests/routes/records.test.ts +1965 -0
  71. package/tests/routes/relatedToQuery.test.ts +182 -0
  72. package/tests/routes/tokens.test.ts +157 -0
  73. package/tests/routes/tombstones.test.ts +93 -0
  74. package/tests/routes/types.test.ts +129 -0
  75. package/tests/routes/versions.test.ts +177 -0
  76. package/tests/routes/wellknown.test.ts +71 -0
  77. package/tests/setup.ts +154 -0
  78. package/tests/shutdown.test.ts +121 -0
  79. package/tests/stack.test.ts +198 -0
  80. package/tsconfig.build.json +9 -0
  81. package/tsconfig.json +19 -0
  82. package/vitest.config.ts +12 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Resume cursor codec. A cursor is opaque and base64url by wire contract
3
+ * (`isValidSeq()` in @haverstack/wire-types), but this server's own are
4
+ * self-describing: `base64url(bufferId + ":" + n)`. That makes a presented
5
+ * cursor's origin checkable — a reconnect naming a buffer this server
6
+ * doesn't hold (a different filter, a restart, a buffer past its retention
7
+ * window) is caught rather than resumed against the wrong stream.
8
+ *
9
+ * Real base64url encoding of the whole string is what satisfies the
10
+ * charset rule automatically — there's no separate escaping step for the
11
+ * ":" delimiter, since it never appears in the encoded output.
12
+ */
13
+ import { base64urlEncode, base64urlDecode } from '@haverstack/core/wire';
14
+
15
+ export type DecodedCursor = {
16
+ bufferId: string;
17
+ n: number;
18
+ };
19
+
20
+ /** Mint a cursor naming one buffer's identity and position. */
21
+ export function encodeCursor(bufferId: string, n: number): string {
22
+ return base64urlEncode(new TextEncoder().encode(`${bufferId}:${n}`));
23
+ }
24
+
25
+ /**
26
+ * Decode a presented cursor. Returns null for anything that doesn't parse
27
+ * as `bufferId + ":" + a non-negative integer` — never throws. A
28
+ * charset-valid cursor naming garbage is the same "cannot be honored" case
29
+ * an unrecognized buffer id is: answered with a `reset` frame, not a 400.
30
+ * (A charset-*invalid* cursor is refused before this is ever called — see
31
+ * `src/routes/changes.ts`.)
32
+ */
33
+ export function decodeCursor(seq: string): DecodedCursor | null {
34
+ let text: string;
35
+ try {
36
+ text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(seq));
37
+ } catch {
38
+ return null;
39
+ }
40
+ const colonIndex = text.lastIndexOf(':');
41
+ if (colonIndex === -1) return null;
42
+ const bufferId = text.slice(0, colonIndex);
43
+ const nText = text.slice(colonIndex + 1);
44
+ if (!/^\d+$/.test(nText)) return null;
45
+ const n = Number(nText);
46
+ if (!Number.isSafeInteger(n)) return null;
47
+ return { bufferId, n };
48
+ }
@@ -0,0 +1,43 @@
1
+ import type { Logger } from 'pino';
2
+ import type { StackTokenStore } from '@haverstack/core/wire';
3
+
4
+ // Once an hour at most. The sweep is O(n) in tokens ever issued — cheap
5
+ // relative to that interval, and never on the hot path twice in a row.
6
+ const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
7
+
8
+ /**
9
+ * Delete tokens past their `expiresAt`.
10
+ *
11
+ * `lookupToken()` already refuses an expired token, so this changes no
12
+ * authorization outcome — it reclaims rows, and nothing else does: the
13
+ * store deletes only on an explicit `revokeToken()`. Since `POST
14
+ * /auth/token` is unauthenticated, strangers drive the row count, and
15
+ * unbounded growth costs disk and slows every `listTokens()` caller.
16
+ *
17
+ * Returns a throttled sweep — call it freely; it runs at most once per
18
+ * interval and never rejects, since failing to reclaim rows must not fail
19
+ * the request that happened to trigger it.
20
+ */
21
+ export function createExpiredTokenSweeper(
22
+ tokens: StackTokenStore,
23
+ logger: Logger,
24
+ ): () => Promise<void> {
25
+ let lastSweptAt = 0;
26
+ return async () => {
27
+ const now = Date.now();
28
+ if (now - lastSweptAt < SWEEP_INTERVAL_MS) return;
29
+ // Claimed before the await, so two concurrent requests can't both start
30
+ // a sweep and race each other's revokeToken() calls.
31
+ lastSweptAt = now;
32
+ try {
33
+ const all = await tokens.listTokens();
34
+ const expired = all.filter((t) => t.expiresAt !== undefined && t.expiresAt.getTime() <= now);
35
+ for (const t of expired) await tokens.revokeToken(t.id);
36
+ if (expired.length > 0) {
37
+ logger.info({ revoked: expired.length }, 'Swept expired tokens');
38
+ }
39
+ } catch (err) {
40
+ logger.warn({ err }, 'Expired-token sweep failed');
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,77 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import type { MiddlewareHandler } from 'hono';
3
+ import { StackPermissionError } from '@haverstack/core';
4
+ import type { TokenSession } from '@haverstack/core';
5
+ import type { AppEnv } from '../types.js';
6
+ import type { StackContext } from '../stack.js';
7
+ import { wireError } from '../wireError.js';
8
+
9
+ /**
10
+ * Constant-time string equality, for comparing a presented credential
11
+ * against a configured secret. Exported so every comparison of the owner
12
+ * token uses the same one — `GET /changes` re-derives "is this the owner"
13
+ * to decide whether to arm its session re-check.
14
+ */
15
+ export function safeCompare(a: string, b: string): boolean {
16
+ const ba = Buffer.from(a);
17
+ const bb = Buffer.from(b);
18
+ return ba.length === bb.length && timingSafeEqual(ba, bb);
19
+ }
20
+
21
+ export function authMiddleware(ownerToken: string, ctx: StackContext): MiddlewareHandler<AppEnv> {
22
+ const ownerEntityId = ctx.stack.ownerEntityId;
23
+
24
+ return async (c, next) => {
25
+ const header = c.req.header('Authorization');
26
+ if (header === undefined) {
27
+ c.set('auth', null);
28
+ } else if (!header.startsWith('Bearer ')) {
29
+ // A credential that isn't a bearer token (`Basic ...`, a missing
30
+ // space) is a 401, never a silent downgrade to anonymous.
31
+ return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
32
+ } else {
33
+ const token = header.slice(7);
34
+ if (safeCompare(token, ownerToken)) {
35
+ c.set('auth', { principalId: ownerEntityId, subjectId: ownerEntityId });
36
+ } else {
37
+ const session = await ctx.tokens.lookupToken(token);
38
+ // Unknown, expired, or revoked is distinct from "none offered",
39
+ // and must not read as anonymous on optional-auth routes.
40
+ if (!session) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
41
+ c.set('auth', session);
42
+ }
43
+ }
44
+ await next();
45
+ };
46
+ }
47
+
48
+ export function requireAuth(): MiddlewareHandler<AppEnv> {
49
+ return async (c, next) => {
50
+ if (!c.get('auth')) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
51
+ await next();
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Whether a session is the stack owner acting alone: undelegated, and
57
+ * authenticated as the owner rather than merely delegated for it. Being the
58
+ * owner is never on its own sufficient under delegation — a delegated
59
+ * session with the owner as principal still fails this, matching
60
+ * `ScopedStack`'s own owner-only gates (e.g. hard delete). See
61
+ * docs/spec/access-control.md § Delegation.
62
+ */
63
+ export function isOwnerActingAlone(auth: TokenSession | null, ownerEntityId: string): boolean {
64
+ return auth?.principalId === ownerEntityId && auth?.subjectId === ownerEntityId;
65
+ }
66
+
67
+ /** Gates a route to the owner acting alone; see `isOwnerActingAlone()`. */
68
+ export function requireOwner(ownerEntityId: string): MiddlewareHandler<AppEnv> {
69
+ return async (c, next) => {
70
+ const auth = c.get('auth');
71
+ if (!auth) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
72
+ if (!isOwnerActingAlone(auth, ownerEntityId)) {
73
+ throw new StackPermissionError('Owner access required');
74
+ }
75
+ await next();
76
+ };
77
+ }
@@ -0,0 +1,73 @@
1
+ import type { ErrorHandler } from 'hono';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+ import type { Logger } from 'pino';
4
+ import { StackPermissionError, StackNotFoundError, type Stack } from '@haverstack/core';
5
+ import type { AppEnv } from '../types.js';
6
+ import { serializeError } from '@haverstack/wire-types';
7
+
8
+ /**
9
+ * Central error handler. `serializeError()` covers every core `StackError`
10
+ * (thrown directly by routes or surfaced from `Stack`/`ScopedStack`) with
11
+ * the wire-format's typed `{ error: { code, message } }` body. Anything
12
+ * else is an unanticipated bug: log it and return a bodyless 500 rather
13
+ * than guessing at a taxonomy it doesn't belong to.
14
+ *
15
+ * `stack` is the unscoped root Stack, used only to probe existence for the
16
+ * refusal log below — never to read content or bypass a permission check
17
+ * for the response itself.
18
+ */
19
+ export function errorMiddleware(logger: Logger, stack: Stack): ErrorHandler<AppEnv> {
20
+ return async (err, c) => {
21
+ const wire = serializeError(err);
22
+ if (wire) {
23
+ // A denial with a resolved auth is a verified DID that lacked the
24
+ // grant, which docs/spec/identity.md asks be logged. Anonymous
25
+ // denials are bodyless 401s from wireError() and never reach here.
26
+ const auth = c.get('auth');
27
+ if (err instanceof StackPermissionError && auth) {
28
+ logger.warn(
29
+ {
30
+ requestId: c.get('requestId'),
31
+ principalId: auth.principalId,
32
+ subjectId: auth.subjectId,
33
+ },
34
+ 'Denied a verified requester',
35
+ );
36
+ }
37
+ // On the wire a refused record is indistinguishable from a missing
38
+ // one (docs/spec/wire-format.md § Server implementation checklist),
39
+ // but the operator is not the adversary: an unscoped probe, on a path
40
+ // already failing, recovers the distinction for the log alone. Debug
41
+ // rather than warn — this is the sharing graph, which the spec asks
42
+ // stay out of a general-purpose aggregator by default.
43
+ if (err instanceof StackNotFoundError && auth) {
44
+ const recordId = c.req.param('id');
45
+ if (recordId) {
46
+ const existing = await stack.get(recordId);
47
+ logger.debug(
48
+ {
49
+ requestId: c.get('requestId'),
50
+ principalId: auth.principalId,
51
+ subjectId: auth.subjectId,
52
+ recordId,
53
+ existed: existing !== null,
54
+ // Always 'read': ScopedStack collapses to StackNotFoundError
55
+ // exactly when canRead() is false, whatever verb was asked.
56
+ check: 'read',
57
+ },
58
+ 'Refused a verified requester a 404',
59
+ );
60
+ }
61
+ }
62
+ // A 401 or 403 to an anonymous caller would confirm the record
63
+ // exists to someone who presented no credential, so a private record
64
+ // and a missing one answer alike. `Bearer` (RFC 6750 §3) — not the
65
+ // `did-challenge` exchange discovery advertises for obtaining a
66
+ // token — keeps the login prompt reachable without reopening that.
67
+ if (wire.status === 404 && !auth) c.header('WWW-Authenticate', 'Bearer');
68
+ return c.json(wire.body, wire.status as ContentfulStatusCode);
69
+ }
70
+ logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error');
71
+ return c.json({ error: { code: 'internal', message: 'Internal server error' } }, 500);
72
+ };
73
+ }
@@ -0,0 +1,126 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { StackPermissionError, StackPayloadTooLargeError } from '@haverstack/core';
4
+ import {
5
+ resolveAttachmentDownloadContentType,
6
+ firstRecordedAttachment,
7
+ parseUploadFilename,
8
+ NOSNIFF_HEADER_NAME,
9
+ NOSNIFF_HEADER_VALUE,
10
+ } from '@haverstack/core/wire';
11
+ import { serializeRecord } from '@haverstack/wire-types';
12
+ import type { AppEnv } from '../types.js';
13
+ import type { StackContext } from '../stack.js';
14
+ import { requireAuth, requireOwner } from '../middleware/auth.js';
15
+
16
+ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono<AppEnv> {
17
+ const app = new Hono<AppEnv>();
18
+ const { stack } = ctx;
19
+ const { ownerEntityId } = stack;
20
+
21
+ // Aborts while reading the body, the moment the byte count passes the
22
+ // limit — no full-body buffer for an oversized upload, chunked or not.
23
+ const attachmentBodyLimit = bodyLimit({
24
+ maxSize: maxAttachmentBytes,
25
+ onError: () => {
26
+ throw new StackPayloadTooLargeError(
27
+ `Attachment exceeds the ${maxAttachmentBytes}-byte limit`,
28
+ );
29
+ },
30
+ });
31
+
32
+ // POST /attachments — stores the bytes and creates the _attachment@1
33
+ // metadata record in the same request, returning that record. Routed
34
+ // through ScopedStack.putAttachment() so the create-grant check on
35
+ // _attachment@1 runs before a single byte is written: an authenticated
36
+ // requester with no grant is refused, not merely denied a metadata
37
+ // record afterward. See docs/spec/wire-format.md § Upload.
38
+ app.post('/', attachmentBodyLimit, requireAuth(), async (c) => {
39
+ const auth = c.get('auth')!;
40
+ const mimeType = c.req.header('Content-Type') || 'application/octet-stream';
41
+ const filename = parseUploadFilename(c.req.header('Content-Disposition'));
42
+ const appId = c.req.query('appId') || undefined;
43
+
44
+ const data = new Uint8Array(await c.req.arrayBuffer());
45
+ const record = await stack.forSession(auth).putAttachment(data, mimeType, filename, appId);
46
+ return c.json(serializeRecord(record), 200);
47
+ });
48
+
49
+ // GET /attachments/:fileId — download
50
+ app.get('/:fileId', async (c) => {
51
+ const fileId = c.req.param('fileId');
52
+ const auth = c.get('auth');
53
+
54
+ let data: Uint8Array;
55
+ try {
56
+ data = await (auth ? stack.forSession(auth) : stack.asEntity(null)).getAttachment(fileId);
57
+ } catch (e) {
58
+ // Anonymous and denied is a transport-auth failure, distinct from an
59
+ // authenticated requester lacking access; everything else belongs to
60
+ // errorMiddleware. ScopedStack.getAttachment() already throws alike
61
+ // for missing and forbidden, so this handler owes no anti-oracle
62
+ // logic of its own — only to leave that answer intact.
63
+ if (!auth && e instanceof StackPermissionError) {
64
+ return c.json({ error: 'Unauthorized' }, 401);
65
+ }
66
+ throw e;
67
+ }
68
+
69
+ const metaRecords = await stack.getAttachmentRecords(fileId);
70
+ const firstRecord = firstRecordedAttachment(metaRecords);
71
+ const ownRecord = auth
72
+ ? firstRecordedAttachment(metaRecords.filter((r) => r.entityId === auth.subjectId))
73
+ : undefined;
74
+
75
+ const contentTypeParam = c.req.query('contentType');
76
+ const filenameParam = c.req.query('filename');
77
+
78
+ const { contentType, forced } = resolveAttachmentDownloadContentType({
79
+ contentTypeParam,
80
+ filenameParam,
81
+ storedMimeType: firstRecord?.content.mimeType,
82
+ });
83
+ // When the dangerous-type policy overrides the candidate, the filename
84
+ // goes too: forcing Content-Type alone won't stop a browser sniffing
85
+ // the body back into the original type without both nosniff and a
86
+ // non-inline disposition.
87
+ const filename = forced
88
+ ? undefined
89
+ : (filenameParam ?? ownRecord?.content.filename ?? firstRecord?.content.filename);
90
+
91
+ const disposition = filename
92
+ ? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
93
+ : 'attachment';
94
+
95
+ return c.newResponse(data as unknown as Uint8Array<ArrayBuffer>, 200, {
96
+ 'Content-Type': contentType,
97
+ 'Content-Length': String(data.byteLength),
98
+ 'Content-Disposition': disposition,
99
+ [NOSNIFF_HEADER_NAME]: NOSNIFF_HEADER_VALUE,
100
+ });
101
+ });
102
+
103
+ // DELETE /attachments/:fileId
104
+ app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => {
105
+ const fileId = c.req.param('fileId');
106
+ await stack.deleteAttachment(fileId);
107
+ return c.body(null, 204);
108
+ });
109
+
110
+ // POST /attachments/gc — sweep and delete orphaned attachment bytes.
111
+ // Owner-only, invoke-only (no built-in scheduling): dryRun makes a
112
+ // cron-from-outside workflow safe. Body is entirely optional — every
113
+ // field defaults inside ScopedStack.collectAttachmentGarbage().
114
+ app.post('/gc', requireOwner(ownerEntityId), async (c) => {
115
+ const auth = c.get('auth')!;
116
+ const raw = await c.req.text();
117
+ const body = raw ? (JSON.parse(raw) as { graceMs?: number; dryRun?: boolean }) : {};
118
+ const result = await stack.forSession(auth).collectAttachmentGarbage({
119
+ ...(typeof body.graceMs === 'number' && { graceMs: body.graceMs }),
120
+ ...(body.dryRun === true && { dryRun: true }),
121
+ });
122
+ return c.json(result, 200);
123
+ });
124
+
125
+ return app;
126
+ }
@@ -0,0 +1,137 @@
1
+ import { Hono } from 'hono';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+ import { verifyAuthChallenge, base64urlDecode } from '@haverstack/core/wire';
4
+ import { isValidDidKey } from '@haverstack/core/did';
5
+ import { WIRE_AUTH_ERROR_STATUS } from '@haverstack/wire-types';
6
+ import type {
7
+ AuthChallengeRequest,
8
+ AuthChallengeResponse,
9
+ AuthTokenRequest,
10
+ AuthTokenResponse,
11
+ WireAuthErrorCode,
12
+ } from '@haverstack/wire-types';
13
+ import type { Context } from 'hono';
14
+ import type { Logger } from 'pino';
15
+ import type { AppEnv } from '../types.js';
16
+ import type { StackContext } from '../stack.js';
17
+ import { readJson } from '../lib/json.js';
18
+ import { createExpiredTokenSweeper } from '../lib/tokenSweep.js';
19
+
20
+ // The handshake never issues a delegated token: proving key possession
21
+ // proves the principal and nothing about whom it may act for. It gets a
22
+ // default TTL rather than the owner's choice of one, because a token
23
+ // anyone can mint for themselves owes a bounded lifetime.
24
+ const AUTH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
25
+
26
+ // Stamped on every handshake-issued token. GET /tokens is the owner's only
27
+ // view of what exists, and without this a self-service token is
28
+ // indistinguishable from one the owner minted deliberately — so "revoke
29
+ // everything the handshake handed out" would be an unanswerable question.
30
+ const AUTH_TOKEN_LABEL = 'did-challenge';
31
+
32
+ /**
33
+ * `{ error: { code, message } }` for the four handshake-specific codes,
34
+ * deliberately outside WireErrorCode — no Stack operation has begun at
35
+ * this point, so none of these maps to a StackError. See
36
+ * docs/spec/wire-format.md § Authentication.
37
+ */
38
+ function authError(c: Context<AppEnv>, code: WireAuthErrorCode, message: string): Response {
39
+ return c.json({ error: { code, message } }, WIRE_AUTH_ERROR_STATUS[code] as ContentfulStatusCode);
40
+ }
41
+
42
+ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger): Hono<AppEnv> {
43
+ const app = new Hono<AppEnv>();
44
+ const sweepExpiredTokens = createExpiredTokenSweeper(ctx.tokens, logger);
45
+
46
+ // POST /auth/challenge — issues a nonce bound to the requested DID.
47
+ // Unauthenticated: this is how a token is earned in the first place.
48
+ app.post('/challenge', async (c) => {
49
+ const body = await readJson<Partial<AuthChallengeRequest>>(c);
50
+ if (typeof body.did !== 'string' || !isValidDidKey(body.did)) {
51
+ return authError(c, 'invalid_did', 'Not a valid did:key');
52
+ }
53
+ const { nonce, expiresAt } = ctx.nonces.issue(body.did);
54
+ const response: AuthChallengeResponse = { nonce, expiresAt: expiresAt.toISOString() };
55
+ return c.json(response);
56
+ });
57
+
58
+ // POST /auth/token — redeems a signed nonce for a bearer token.
59
+ // Unauthenticated for the same reason.
60
+ app.post('/token', async (c) => {
61
+ const body = await readJson<Partial<AuthTokenRequest>>(c);
62
+ if (typeof body.did !== 'string' || !isValidDidKey(body.did)) {
63
+ return authError(c, 'invalid_did', 'Not a valid did:key');
64
+ }
65
+ // Checked ahead of the signature so a nonce issued to a different DID
66
+ // is refused as unknown_nonce even when the caller's own signature over
67
+ // it is perfectly valid — the nonce belongs to the DID it was issued
68
+ // for, not to whoever can produce a valid signature.
69
+ if (typeof body.nonce !== 'string') {
70
+ return authError(c, 'unknown_nonce', 'Unknown or already-used nonce');
71
+ }
72
+ if (typeof body.signature !== 'string') {
73
+ return authError(c, 'invalid_signature', 'Signature does not verify');
74
+ }
75
+
76
+ // Spent on this call regardless of what happens next — single-use
77
+ // means a redemption attempt consumes the nonce, not merely a
78
+ // successful one.
79
+ const result = ctx.nonces.redeem(body.did, body.nonce);
80
+ if (result === 'unknown') return authError(c, 'unknown_nonce', 'Unknown or already-used nonce');
81
+ if (result === 'expired') return authError(c, 'expired_nonce', 'Challenge has expired');
82
+
83
+ let signatureBytes: Uint8Array;
84
+ try {
85
+ signatureBytes = base64urlDecode(body.signature);
86
+ } catch {
87
+ return authError(c, 'invalid_signature', 'Signature does not verify');
88
+ }
89
+
90
+ // Built from our own configured public origin (authOrigin), never from
91
+ // a request header — Host/X-Forwarded-Host are client-controlled, and
92
+ // deriving the signed origin from either would let a client choose
93
+ // which origin it signs for, silently reopening the relay attack the
94
+ // origin binding exists to prevent.
95
+ const verified = await verifyAuthChallenge(
96
+ { origin: authOrigin, did: body.did, nonce: body.nonce },
97
+ signatureBytes,
98
+ ).catch(() => false);
99
+ if (!verified) return authError(c, 'invalid_signature', 'Signature does not verify');
100
+
101
+ // Reclaim expired rows before adding one. Throttled internally, so this
102
+ // is a no-op on all but the first handshake of each interval.
103
+ await sweepExpiredTokens();
104
+
105
+ // Undelegated by construction: principalId and subjectId are both the
106
+ // proven DID. Never let a client name its own subject.
107
+ const expiresAt = new Date(Date.now() + AUTH_TOKEN_TTL_MS);
108
+ const { token } = await ctx.tokens.createToken(body.did, {
109
+ expiresAt,
110
+ label: AUTH_TOKEN_LABEL,
111
+ });
112
+
113
+ // Reported from the value just written rather than read back through
114
+ // listTokens(), an unpaginated scan of every token ever issued — on an
115
+ // unauthenticated route that makes each handshake cost more than the
116
+ // last. The store persists the Date unchanged, so a read-back could
117
+ // only return what is already here.
118
+ const response: AuthTokenResponse = {
119
+ token,
120
+ expiresAt: expiresAt.toISOString(),
121
+ principalId: body.did,
122
+ subjectId: body.did,
123
+ };
124
+
125
+ // The one place a bearer token comes into existence without the owner
126
+ // doing anything, so it is the one place a log line is the only record
127
+ // that it happened. errorMiddleware already logs denied-but-verified
128
+ // requests; this is its counterpart on the granting side.
129
+ logger.info(
130
+ { requestId: c.get('requestId'), did: body.did, expiresAt: expiresAt.toISOString() },
131
+ 'Issued a token via the DID challenge-response handshake',
132
+ );
133
+ return c.json(response);
134
+ });
135
+
136
+ return app;
137
+ }