@haverstack/server 0.8.0 → 0.9.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 (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +104 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +37 -12
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
@@ -1,77 +0,0 @@
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
- }
@@ -1,73 +0,0 @@
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
- }
@@ -1,126 +0,0 @@
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
- }
@@ -1,137 +0,0 @@
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
- }