@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,279 @@
1
+ import { Hono } from 'hono';
2
+ import type { Context } from 'hono';
3
+ import { streamSSE } from 'hono/streaming';
4
+ import type { AppEnv } from '../types.js';
5
+ import type { StackContext } from '../stack.js';
6
+ import type { Config } from '../config.js';
7
+ import type { ScopedStack, TokenSession, RecordChange } from '@haverstack/core';
8
+ import { StackQueryError, StackPermissionError } from '@haverstack/core';
9
+ import { parseChangeParams } from '@haverstack/core/wire';
10
+ import { serializeChange, isValidSeq } from '@haverstack/wire-types';
11
+ import type { ChangeResetReason } from '@haverstack/wire-types';
12
+ import type { Logger } from 'pino';
13
+ import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js';
14
+ import { FrameGate } from '../lib/frameGate.js';
15
+ import { decodeCursor } from '../lib/resumeCursor.js';
16
+ import { ResumeBufferRegistry, resumeBufferKey, type ResumeEntry } from '../lib/resumeBuffer.js';
17
+
18
+ // Keepalive cadence, and how often an authenticated session is re-checked
19
+ // against the token store — a stream's authority is fixed at connect, so
20
+ // without the re-check a revoked token keeps delivering for as long as the
21
+ // connection lives. Neither value reaches a client, so neither is an env var.
22
+ const DEFAULT_KEEPALIVE_MS = 15_000;
23
+ const DEFAULT_SESSION_CHECK_MS = 30_000;
24
+ // A client whose in-flight frame count crosses this is closed rather than
25
+ // left to queue: one that can't tell it missed a frame can't repair it
26
+ // either, so dropping silently is worse than disconnecting.
27
+ const DEFAULT_MAX_PENDING_FRAMES = 1000;
28
+ // Per-buffer ring depth and how long a buffer keeps being fed after its
29
+ // last connection disconnects — see src/lib/resumeBuffer.ts. Not part of
30
+ // the wire contract either: a client never learns these numbers, only
31
+ // their consequence (`overflow` or `cursor_expired`).
32
+ const DEFAULT_RESUME_BUFFER_DEPTH = 1000;
33
+ const DEFAULT_RESUME_RETENTION_MS = 5 * 60 * 1000;
34
+
35
+ export type ChangeRouteOptions = {
36
+ keepaliveMs?: number;
37
+ sessionCheckMs?: number;
38
+ maxPendingFrames?: number;
39
+ /**
40
+ * Whether a presented cursor is honored at all. Default true and never
41
+ * false in production, since discovery advertises resume unconditionally.
42
+ * It exists because `resume: false` is real, spec-defined behavior
43
+ * (`ready` with no `seq`, then `reset` with reason `not_supported`) that
44
+ * a conformance fixture needs a way to reach.
45
+ */
46
+ resume?: boolean;
47
+ resumeBufferDepth?: number;
48
+ resumeRetentionMs?: number;
49
+ };
50
+
51
+ /**
52
+ * Raw cursor text presented on this connection, if any — `Last-Event-ID`
53
+ * takes priority over `?since=` (a browser EventSource-style reconnect
54
+ * sends the header; `?since=` exists for a client whose transport can't
55
+ * set one). Not yet validated or decoded — see isValidSeq()/decodeCursor().
56
+ */
57
+ function presentedCursorRaw(c: Context<AppEnv>, url: URL): string | undefined {
58
+ return c.req.header('Last-Event-ID') ?? url.searchParams.get('since') ?? undefined;
59
+ }
60
+
61
+ export function changeRoutes(
62
+ ctx: StackContext,
63
+ config: Config,
64
+ logger: Logger,
65
+ opts: ChangeRouteOptions = {},
66
+ ): Hono<AppEnv> {
67
+ const app = new Hono<AppEnv>();
68
+ const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS;
69
+ const sessionCheckMs = opts.sessionCheckMs ?? DEFAULT_SESSION_CHECK_MS;
70
+ const maxPendingFrames = opts.maxPendingFrames ?? DEFAULT_MAX_PENDING_FRAMES;
71
+ const resumeEnabled = opts.resume ?? true;
72
+ const resumeBuffers = new ResumeBufferRegistry({
73
+ depth: opts.resumeBufferDepth ?? DEFAULT_RESUME_BUFFER_DEPTH,
74
+ retentionMs: opts.resumeRetentionMs ?? DEFAULT_RESUME_RETENTION_MS,
75
+ });
76
+
77
+ /** Scope to a session if authenticated, else the anonymous (public-only) view. */
78
+ function scopeFor(auth: TokenSession | null): ScopedStack {
79
+ return auth ? ctx.stack.forSession(auth) : ctx.stack.asEntity(null);
80
+ }
81
+
82
+ // Single-process only: events exist only in the process owning the
83
+ // stack's storage (docs/spec/wire-format.md § Feed implementation
84
+ // checklist), and query workers only read. A second process subscribing
85
+ // to its own Stack over the same storage would see nothing and look fine
86
+ // in testing — worth knowing before scaling this horizontally.
87
+ app.get('/', async (c) => {
88
+ const url = new URL(c.req.url);
89
+ const auth = c.get('auth');
90
+ const { filter, includeRecords, includeUnlisted } = parseChangeParams(url);
91
+ const presentedRaw = presentedCursorRaw(c, url);
92
+
93
+ // Refused before the stream opens: a throw from subscribe() after
94
+ // streaming starts is caught below and merely closes the connection,
95
+ // silently, having already answered 200. includeUnlisted owes a real
96
+ // 403, the same one GET /records answers with.
97
+ if (includeUnlisted && !isOwnerActingAlone(auth, ctx.stack.ownerEntityId)) {
98
+ throw new StackPermissionError('includeUnlisted is owner-only');
99
+ }
100
+
101
+ // A charset-invalid cursor is refused rather than treated as a cache
102
+ // miss: no conformant server could have minted it, so there is nothing
103
+ // to reconcile. isValidSeq() is the rule minted cursors meet on the way
104
+ // out, applied here on the way in.
105
+ if (resumeEnabled && presentedRaw !== undefined && !isValidSeq(presentedRaw)) {
106
+ throw new StackQueryError(`Invalid cursor: "${presentedRaw}"`);
107
+ }
108
+
109
+ // Never accepted: a bearer token is read only from the Authorization
110
+ // header (authMiddleware), never from a query param — the feed is
111
+ // consumed via fetch with a streaming body precisely so a client never
112
+ // needs to put a credential somewhere a browser might log or cache it.
113
+ const bearerToken = c.req.header('Authorization')?.slice('Bearer '.length);
114
+
115
+ return streamSSE(c, async (stream) => {
116
+ let resolveDone!: () => void;
117
+ const done = new Promise<void>((resolve) => {
118
+ resolveDone = resolve;
119
+ });
120
+ stream.onAbort(() => resolveDone());
121
+ const unregister = ctx.changeStreams.add(() => resolveDone());
122
+
123
+ const gate = new FrameGate(maxPendingFrames, () => resolveDone());
124
+ /** Returns false once the gate has tripped — this connection is closing. */
125
+ function send(event: string, data: unknown, id?: string): boolean {
126
+ return gate.send(() =>
127
+ stream.writeSSE({ event, data: JSON.stringify(data), ...(id !== undefined && { id }) }),
128
+ );
129
+ }
130
+
131
+ // Assigned inside the try below; torn down in the finally whether the
132
+ // connection ended by abort, by a revoked session, or by a throw. A
133
+ // throw is caught rather than left to hono, which would answer it by
134
+ // writing the raw error message to the client as an `error` frame.
135
+ let unsubscribe: (() => void) | undefined;
136
+ let keepalive: NodeJS.Timeout | undefined;
137
+ let sessionCheck: NodeJS.Timeout | undefined;
138
+
139
+ try {
140
+ if (!resumeEnabled) {
141
+ // `resume: false` — a discovery-conformant server that mints no
142
+ // cursors at all, reachable only through WellknownRouteOptions'
143
+ // matching test toggle.
144
+ send('ready', {});
145
+ if (presentedRaw !== undefined) {
146
+ const reason: ChangeResetReason = 'not_supported';
147
+ send('reset', { reason });
148
+ }
149
+ unsubscribe = await scopeFor(auth).subscribe(
150
+ (change: RecordChange) => {
151
+ send('record', serializeChange(change));
152
+ },
153
+ { filter, includeRecords, includeUnlisted },
154
+ );
155
+ } else {
156
+ const key = resumeBufferKey({
157
+ principalId: auth?.principalId ?? null,
158
+ subjectId: auth?.subjectId ?? null,
159
+ filter,
160
+ includeRecords,
161
+ includeUnlisted,
162
+ });
163
+ const scoped = scopeFor(auth);
164
+ const buffer = await resumeBuffers.acquire(key, (onChange) =>
165
+ scoped.subscribe(onChange, { filter, includeRecords, includeUnlisted }),
166
+ );
167
+
168
+ // Attached before anything below awaits, so there is no gap
169
+ // between what the backlog snapshot covers and what the live
170
+ // buffer reports. `replaying` holds live arrivals until the
171
+ // permission-rechecking replay finishes, so a change appended
172
+ // mid-replay can't overtake an older, still-pending backlog frame.
173
+ let replaying = true;
174
+ const queued: ResumeEntry[] = [];
175
+ const detachLive = buffer.subscribeLive((entry) => {
176
+ if (replaying) queued.push(entry);
177
+ else send('record', entry.frame, entry.frame.seq);
178
+ });
179
+ // Registered before the first `await` below, so a subscription
180
+ // opened above is always released — including when that await
181
+ // throws. Everything after this point runs under the finally.
182
+ unsubscribe = () => {
183
+ detachLive();
184
+ resumeBuffers.release(key);
185
+ };
186
+
187
+ let resetReason: ChangeResetReason | undefined;
188
+ let backlog: ResumeEntry[] = [];
189
+ if (presentedRaw !== undefined) {
190
+ const decoded = decodeCursor(presentedRaw);
191
+ if (!decoded || decoded.bufferId !== buffer.id) {
192
+ // Unrecognized outright, or names a buffer instance that isn't
193
+ // this key's current one (evicted past its retention window,
194
+ // or minted for a filter this cursor doesn't actually match).
195
+ resetReason = 'cursor_expired';
196
+ } else {
197
+ const outcome = buffer.entriesAfter(decoded.n);
198
+ if (outcome.status === 'ok') backlog = outcome.entries;
199
+ else resetReason = outcome.status;
200
+ }
201
+ }
202
+
203
+ send('ready', { seq: buffer.headCursor() });
204
+
205
+ if (resetReason) {
206
+ send('reset', { reason: resetReason });
207
+ } else {
208
+ for (const entry of backlog) {
209
+ // A purge frame isn't re-checkable: the mutation-time
210
+ // decision is the only one there will ever be
211
+ // (docs/spec/events.md). Everything else still has its record
212
+ // ID, so a grant revoked during the gap is caught here.
213
+ if (!entry.isPurge) {
214
+ const stillReadable = await scoped.get(entry.recordId);
215
+ if (!stillReadable) continue;
216
+ }
217
+ // Once the gate trips this connection is closing, so the
218
+ // remaining entries are permission checks nobody will read.
219
+ if (!send('record', entry.frame, entry.frame.seq)) break;
220
+ }
221
+ }
222
+
223
+ replaying = false;
224
+ for (const entry of queued) {
225
+ if (!send('record', entry.frame, entry.frame.seq)) break;
226
+ }
227
+ }
228
+
229
+ // Through the gate like any other write: a client not draining its
230
+ // keepalives isn't draining anything, and counting them is what
231
+ // keeps "in-flight frames are bounded" true of the whole stream
232
+ // rather than of `record` frames alone.
233
+ keepalive = setInterval(() => {
234
+ gate.send(() => stream.write(': keepalive\n\n'));
235
+ }, keepaliveMs);
236
+
237
+ // A revoked or expired token must stop delivering. The owner's
238
+ // static bearer token never expires (authMiddleware compares it
239
+ // directly, not via the token store), so only a minted session needs
240
+ // re-checking; an anonymous connection has no token to revoke.
241
+ const isOwnerToken =
242
+ bearerToken !== undefined && safeCompare(bearerToken, config.ownerToken);
243
+ sessionCheck =
244
+ auth && !isOwnerToken && bearerToken
245
+ ? setInterval(() => {
246
+ void ctx.tokens.lookupToken(bearerToken).then(
247
+ (session) => {
248
+ if (!session) resolveDone();
249
+ },
250
+ // The token store is unreachable. Closing is the honest
251
+ // answer — the client reconnects and re-authenticates
252
+ // through the path that already handles a 401 — and it
253
+ // keeps a rejection here from going unhandled.
254
+ () => resolveDone(),
255
+ );
256
+ }, sessionCheckMs)
257
+ : undefined;
258
+
259
+ await done;
260
+ } catch (err) {
261
+ // Hono answers an uncaught throw here by writing the raw error
262
+ // message to the client as an `error` frame. Catching keeps internal
263
+ // detail off a stream any anonymous caller can open, and a closed
264
+ // connection is a repair the client already knows how to make.
265
+ logger.error(
266
+ { err, requestId: c.get('requestId') },
267
+ 'Change feed connection ended with an error',
268
+ );
269
+ } finally {
270
+ if (keepalive) clearInterval(keepalive);
271
+ if (sessionCheck) clearInterval(sessionCheck);
272
+ unsubscribe?.();
273
+ unregister();
274
+ }
275
+ });
276
+ });
277
+
278
+ return app;
279
+ }
@@ -0,0 +1,33 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import type { StackContext } from '../stack.js';
4
+ import { requireAuth, requireOwner } from '../middleware/auth.js';
5
+ import { readJson } from '../lib/json.js';
6
+ import { serializeRecord } from '@haverstack/wire-types';
7
+ import { StackNotFoundError } from '@haverstack/core';
8
+
9
+ export function entityRoutes(ctx: StackContext): Hono<AppEnv> {
10
+ const app = new Hono<AppEnv>();
11
+ const { stack } = ctx;
12
+ const ownerEntityId = stack.ownerEntityId;
13
+
14
+ app.get('/', requireAuth(), async (c) => {
15
+ const auth = c.get('auth')!;
16
+ const record = await stack.forSession(auth).getOwnerEntity();
17
+ if (!record) throw new StackNotFoundError('Entity record not found');
18
+ return c.json(serializeRecord(record));
19
+ });
20
+
21
+ app.patch('/', requireOwner(ownerEntityId), async (c) => {
22
+ const auth = c.get('auth')!;
23
+ const record = await stack.forSession(auth).getOwnerEntity();
24
+ if (!record) throw new StackNotFoundError('Entity record not found');
25
+ const body = await readJson<Record<string, unknown>>(c);
26
+ const updated = await stack
27
+ .forSession(auth)
28
+ .patchContent(record.id, (body.content ?? {}) as Record<string, unknown>);
29
+ return c.json(serializeRecord(updated));
30
+ });
31
+
32
+ return app;
33
+ }
@@ -0,0 +1,8 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+
4
+ export function healthRoutes(): Hono<AppEnv> {
5
+ const app = new Hono<AppEnv>();
6
+ app.get('/', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
7
+ return app;
8
+ }
@@ -0,0 +1,259 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import type { StackContext } from '../stack.js';
4
+ import type { ScopedStack, TokenSession } from '@haverstack/core';
5
+ import { requireAuth, requireOwner } from '../middleware/auth.js';
6
+ import { readJson } from '../lib/json.js';
7
+ import {
8
+ parseQueryBody,
9
+ parseQueryParams,
10
+ parsePositiveInt,
11
+ parseIfMatch,
12
+ createOptionsFromWireRecord,
13
+ changesFromWireBody,
14
+ } from '@haverstack/core/wire';
15
+ import { clampLimit } from '../lib/queryLimit.js';
16
+ import { serializeRecord, serializeVersion } from '@haverstack/wire-types';
17
+ import type { WireQueryResponse } from '@haverstack/wire-types';
18
+ import { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core';
19
+ import type { Association, TypeId } from '@haverstack/core';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Route factory
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // Only the routes that can search a large index (POST /query, GET /) run
26
+ // on the query worker pool with a deadline; everything else calls the main
27
+ // thread's ScopedStack directly, being index-bound already. Virtualizing
28
+ // the whole ScopedStack API across the worker boundary would buy no bound
29
+ // the unbounded-cost case needs. See src/lib/queryWorker/pool.ts and
30
+ // docs/spec/wire-format.md § Bounding query cost.
31
+ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<AppEnv> {
32
+ const app = new Hono<AppEnv>();
33
+ const { stack, queryWorker } = ctx;
34
+ const ownerEntityId = stack.ownerEntityId;
35
+
36
+ /** Scope to a session if authenticated, else the anonymous view. */
37
+ function scopeFor(auth: TokenSession | null): ScopedStack {
38
+ return auth ? stack.forSession(auth) : stack.asEntity(null);
39
+ }
40
+
41
+ // POST /records/query — full query with content-field filters. Optional
42
+ // auth, like GET /records: a superset of the same query surface, so an
43
+ // anonymous caller gets the same public-record subset either way.
44
+ // Registered ahead of the /:id patterns, which would otherwise capture
45
+ // the literal "query" segment.
46
+ app.post('/query', async (c) => {
47
+ const auth = c.get('auth');
48
+ const query = clampLimit(parseQueryBody(await readJson(c)));
49
+ const result = await queryWorker.query(auth, query, queryTimeoutMs);
50
+ const body: WireQueryResponse = {
51
+ records: result.records.map(serializeRecord),
52
+ cursor: result.cursor,
53
+ };
54
+ return c.json(body);
55
+ });
56
+
57
+ // GET /records — query by native fields via URL params
58
+ app.get('/', async (c) => {
59
+ const auth = c.get('auth');
60
+ const query = clampLimit(parseQueryParams(new URL(c.req.url)));
61
+ const result = await queryWorker.query(auth, query, queryTimeoutMs);
62
+ const body: WireQueryResponse = {
63
+ records: result.records.map(serializeRecord),
64
+ cursor: result.cursor,
65
+ };
66
+ return c.json(body);
67
+ });
68
+
69
+ // POST /records — a full record body, but version, entityId and
70
+ // principalId are stamped here and never trusted from it. Everything else
71
+ // a wire record body can carry — which fields are forwarded as-is, which
72
+ // are owner-acting-alone-only, and which reduce to a boolean rather than
73
+ // their literal value — is `createOptionsFromWireRecord()`'s disposition
74
+ // to make, not this route's: see `@haverstack/core/wire` and
75
+ // docs/spec/wire-format.md § Records.
76
+ app.post('/', requireAuth(), async (c) => {
77
+ const auth = c.get('auth')!;
78
+ const body = await readJson(c);
79
+ const { typeId, content, options } = createOptionsFromWireRecord(body, auth, ownerEntityId);
80
+ const created = await stack.forSession(auth).create(typeId, content, options);
81
+ return c.json(serializeRecord(created), 200);
82
+ });
83
+
84
+ // GET /records/:id
85
+ app.get('/:id', async (c) => {
86
+ const id = c.req.param('id');
87
+ const auth = c.get('auth');
88
+ const record = await scopeFor(auth).get(id);
89
+ if (!record) throw new StackNotFoundError('Record not found');
90
+ return c.json(serializeRecord(record));
91
+ });
92
+
93
+ // PATCH /records/:id — the body is a change set: an envelope naming any
94
+ // combination of `contentPatch`, `parentId`, `permissions`, `associations`
95
+ // and `unlisted`, applied as one atomic write that produces exactly one
96
+ // version. A conforming client sends { "contentPatch": { "title": "New" } },
97
+ // never the bare patch — content is one aspect among five here, so the
98
+ // envelope is what tells a `parentId` naming a container from a content
99
+ // field of that name. One If-Match fences the whole multi-aspect edit.
100
+ //
101
+ // `changesFromWireBody()` owns the entire read of that envelope: which
102
+ // keys it may carry, the 400 an unrecognized or wholly absent key earns,
103
+ // and the 422 a malformed value earns. Content keys stay Stack's to judge,
104
+ // so nothing here inspects the patch. See docs/spec/wire-format.md § Records.
105
+ //
106
+ // No route-level guard belongs here: ScopedStack owns the _attachment@1
107
+ // immutable-field and _grant@1 owner-only rules (docs/spec/attachments.md,
108
+ // docs/spec/access-control.md) and resolves each change-set key against its
109
+ // own gate, so a denial round-trips as the core error it is rather than a
110
+ // guess made before the permission check ran.
111
+ app.patch('/:id', requireAuth(), async (c) => {
112
+ const id = c.req.param('id');
113
+ const auth = c.get('auth')!;
114
+ const changes = changesFromWireBody(await readJson(c));
115
+
116
+ const updated = await stack
117
+ .forSession(auth)
118
+ .mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
119
+ return c.json(serializeRecord(updated));
120
+ });
121
+
122
+ // DELETE /records/:id (?hard=true for permanent). A soft delete bumps
123
+ // the version like any other mutation, so it answers with the record it
124
+ // produced (carrying deletedAt); a hard delete leaves nothing to answer
125
+ // with, so that one stays 204.
126
+ app.delete('/:id', requireAuth(), async (c) => {
127
+ const id = c.req.param('id');
128
+ const auth = c.get('auth')!;
129
+ const hard = new URL(c.req.url).searchParams.get('hard') === 'true';
130
+ const session = stack.forSession(auth);
131
+
132
+ await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) });
133
+ if (hard) return c.body(null, 204);
134
+ return c.json(serializeRecord((await session.get(id))!));
135
+ });
136
+
137
+ // POST /records/:id/undelete — reverses a soft delete; idempotent
138
+ app.post('/:id/undelete', requireAuth(), async (c) => {
139
+ const id = c.req.param('id');
140
+ const auth = c.get('auth')!;
141
+ const restored = await stack
142
+ .forSession(auth)
143
+ .undelete(id, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
144
+ return c.json(serializeRecord(restored));
145
+ });
146
+
147
+ // ------------------------------------------------------------------
148
+ // Permissions
149
+ // ------------------------------------------------------------------
150
+
151
+ app.get('/:id/permissions', async (c) => {
152
+ const id = c.req.param('id');
153
+ const auth = c.get('auth');
154
+ const record = await scopeFor(auth).get(id);
155
+ if (!record) throw new StackNotFoundError('Record not found');
156
+ return c.json({ permissions: record.permissions ?? [] });
157
+ });
158
+
159
+ // ------------------------------------------------------------------
160
+ // Associations
161
+ // ------------------------------------------------------------------
162
+
163
+ app.get('/:id/associations', async (c) => {
164
+ const id = c.req.param('id');
165
+ const auth = c.get('auth');
166
+ const record = await scopeFor(auth).get(id);
167
+ if (!record) throw new StackNotFoundError('Record not found');
168
+ let assocs = record.associations ?? [];
169
+ const kind = c.req.query('kind');
170
+ if (kind) assocs = assocs.filter((a) => a.kind === kind);
171
+ const label = c.req.query('label');
172
+ if (label) assocs = assocs.filter((a) => a.label === label);
173
+ return c.json({ associations: assocs });
174
+ });
175
+
176
+ app.post('/:id/associations', requireAuth(), async (c) => {
177
+ const id = c.req.param('id');
178
+ const auth = c.get('auth')!;
179
+ const body = await readJson<Association>(c);
180
+ if (!body.kind || !body.label) throw new StackQueryError('kind and label are required');
181
+ const updated = await stack
182
+ .forSession(auth)
183
+ .associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
184
+ return c.json(serializeRecord(updated));
185
+ });
186
+
187
+ // POST, not DELETE — a DELETE request body has no defined semantics
188
+ // (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways
189
+ // that drop or reject it.
190
+ app.post('/:id/associations/delete', requireAuth(), async (c) => {
191
+ const id = c.req.param('id');
192
+ const auth = c.get('auth')!;
193
+ const body = await readJson<Association>(c);
194
+ const updated = await stack
195
+ .forSession(auth)
196
+ .dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
197
+ return c.json(serializeRecord(updated));
198
+ });
199
+
200
+ // ------------------------------------------------------------------
201
+ // Versions
202
+ // ------------------------------------------------------------------
203
+
204
+ app.get('/:id/versions', async (c) => {
205
+ const id = c.req.param('id');
206
+ const auth = c.get('auth');
207
+ const versions = await scopeFor(auth).getVersions(id);
208
+ return c.json(versions.map(serializeVersion));
209
+ });
210
+
211
+ app.get('/:id/versions/:version', async (c) => {
212
+ const id = c.req.param('id');
213
+ const vNum = parsePositiveInt(c.req.param('version'), 'version number');
214
+ const auth = c.get('auth');
215
+ const version = await scopeFor(auth).getVersion(id, vNum);
216
+ if (!version) throw new StackNotFoundError('Version not found');
217
+ return c.json(serializeVersion(version));
218
+ });
219
+
220
+ // POST /records/:id/restore/:version — creates new version, does not rewrite history
221
+ app.post('/:id/restore/:version', requireAuth(), async (c) => {
222
+ const id = c.req.param('id');
223
+ const vNum = parsePositiveInt(c.req.param('version'), 'version number');
224
+ const auth = c.get('auth')!;
225
+ const restored = await stack
226
+ .forSession(auth)
227
+ .restoreVersion(id, vNum, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
228
+ return c.json(serializeRecord(restored));
229
+ });
230
+
231
+ // POST /records/:id/migrate — the only way typeId changes after creation.
232
+ // The body carries the full post-migration content, computed client-side
233
+ // by the type's owning app and validated against toTypeId's schema by
234
+ // ScopedStack.commitMigration(). Owner acting alone only: replacing
235
+ // content and typeId wholesale would have to re-derive every gate
236
+ // create() and update() apply at both ends.
237
+ app.post('/:id/migrate', requireOwner(ownerEntityId), async (c) => {
238
+ const id = c.req.param('id');
239
+ const auth = c.get('auth')!;
240
+ const body = await readJson<Record<string, unknown>>(c);
241
+ if (!body.toTypeId || typeof body.toTypeId !== 'string')
242
+ throw new StackQueryError('toTypeId is required');
243
+ if (!body.content || typeof body.content !== 'object')
244
+ throw new StackQueryError('content is required');
245
+ if (!(await stack.getType(body.toTypeId as TypeId)))
246
+ throw new StackValidationError([
247
+ { path: 'toTypeId', message: `Unknown type: "${body.toTypeId}"` },
248
+ ]);
249
+
250
+ const migrated = await stack
251
+ .forSession(auth)
252
+ .commitMigration(id, body.toTypeId as TypeId, body.content as Record<string, unknown>, {
253
+ ifVersion: parseIfMatch(c.req.header('If-Match')),
254
+ });
255
+ return c.json(serializeRecord(migrated));
256
+ });
257
+
258
+ return app;
259
+ }
@@ -0,0 +1,85 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import type { StackContext } from '../stack.js';
4
+ import { requireOwner } from '../middleware/auth.js';
5
+ import { readJson } from '../lib/json.js';
6
+ import { parseDate } from '@haverstack/wire-types';
7
+ import { StackValidationError } from '@haverstack/core';
8
+ import { isValidDid } from '@haverstack/core/did';
9
+ import type { TokenInfo } from '@haverstack/core/wire';
10
+
11
+ export function tokenRoutes(ctx: StackContext): Hono<AppEnv> {
12
+ const app = new Hono<AppEnv>();
13
+ const { tokens, stack } = ctx;
14
+ const ownerEntityId = stack.ownerEntityId;
15
+
16
+ // POST /tokens — issue a new token (owner only). `onBehalfOf` asserts a
17
+ // delegation out of band: the owner names the subject the issued
18
+ // principal acts for, per docs/spec/wire-format.md § The session a
19
+ // token names.
20
+ app.post('/', requireOwner(ownerEntityId), async (c) => {
21
+ const body = await readJson<{
22
+ entityId?: string;
23
+ onBehalfOf?: string;
24
+ label?: string;
25
+ expiresAt?: string;
26
+ }>(c);
27
+ if (body.entityId !== undefined && !isValidDid(body.entityId))
28
+ throw new StackValidationError([{ path: 'entityId', message: 'Must be a DID' }]);
29
+ if (body.onBehalfOf !== undefined && !isValidDid(body.onBehalfOf))
30
+ throw new StackValidationError([{ path: 'onBehalfOf', message: 'Must be a DID' }]);
31
+
32
+ const principalId = body.entityId ?? ownerEntityId;
33
+ const expiresAt = body.expiresAt ? parseDate(body.expiresAt) : undefined;
34
+ if (body.expiresAt && !expiresAt)
35
+ throw new StackValidationError([{ path: 'expiresAt', message: 'Invalid date' }]);
36
+
37
+ const { id, token } = await tokens.createToken(principalId, {
38
+ onBehalfOf: body.onBehalfOf,
39
+ label: body.label,
40
+ expiresAt,
41
+ });
42
+
43
+ // Read the row back rather than fabricating createdAt here — the store
44
+ // is the source of truth, and GET /tokens must report the same value.
45
+ const stored = (await tokens.listTokens()).find((t) => t.id === id)!;
46
+
47
+ return c.json(
48
+ {
49
+ id,
50
+ token,
51
+ principalId,
52
+ subjectId: body.onBehalfOf ?? principalId,
53
+ label: stored.label ?? null,
54
+ createdAt: stored.createdAt.toISOString(),
55
+ expiresAt: stored.expiresAt?.toISOString() ?? null,
56
+ },
57
+ 201,
58
+ );
59
+ });
60
+
61
+ // GET /tokens — list all DB-managed tokens; never returns token values
62
+ app.get('/', requireOwner(ownerEntityId), async (c) => {
63
+ const list = await tokens.listTokens();
64
+ return c.json({ tokens: list.map(serializeToken) });
65
+ });
66
+
67
+ // DELETE /tokens/:id — revoke a token by its ID
68
+ app.delete('/:id', requireOwner(ownerEntityId), async (c) => {
69
+ await tokens.revokeToken(c.req.param('id'));
70
+ return c.body(null, 204);
71
+ });
72
+
73
+ return app;
74
+ }
75
+
76
+ function serializeToken(t: TokenInfo) {
77
+ return {
78
+ id: t.id,
79
+ principalId: t.principalId,
80
+ subjectId: t.subjectId,
81
+ label: t.label ?? null,
82
+ createdAt: t.createdAt.toISOString(),
83
+ expiresAt: t.expiresAt?.toISOString() ?? null,
84
+ };
85
+ }