@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,2322 @@
1
+ /**
2
+ * Runs the shared @haverstack/conformance-fixtures suite against this
3
+ * server's real HTTP surface. A companion to the hand-written route tests
4
+ * under tests/routes/, not a replacement: each fixture pins one documented
5
+ * wire-contract point (a status code, an error code, a merge/versioning
6
+ * rule), so a fixture that starts failing here means this server and the
7
+ * documented contract have diverged.
8
+ *
9
+ * Server-generated values (ids left unspecified, timestamps, opaque
10
+ * pagination cursors) are never compared for literal equality against a
11
+ * fixture's illustrative placeholders — @haverstack/adapter-api's own
12
+ * consumption of these same fixtures (packages/adapter-api/tests/
13
+ * conformance.test.ts in haverstack/core) takes the same targeted-field
14
+ * approach rather than deep-equating whole response bodies.
15
+ *
16
+ * Every fixture in each imported array is either dispatched below or
17
+ * named in that describe block's SKIPPED set with a reason. Each block's
18
+ * final "coverage" test fails loudly if core adds, removes, or renames a
19
+ * fixture this file hasn't been told about — that's what makes this an
20
+ * acceptance gate rather than a snapshot of one day's fixture list.
21
+ *
22
+ * Only two fixtures are skipped, and neither is a gap: each states in its
23
+ * own description that it pins a *client* obligation rather than a server
24
+ * one, and neither can be dispatched against a conformant server without
25
+ * forging the response it checks. Everything else — including the cases
26
+ * that need a second principal, a readable attachment reference, an
27
+ * adapter-level write, or a server configured with a deadline no query can
28
+ * meet — is dispatched.
29
+ */
30
+ import { Hono } from 'hono';
31
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
32
+ import {
33
+ discoveryFixtures,
34
+ createRecordFixtures,
35
+ queryRecordsFixtures,
36
+ patchContentFixtures,
37
+ deleteRecordFixtures,
38
+ undeleteRecordFixtures,
39
+ associateFixtures,
40
+ dissociateFixtures,
41
+ permissionsChangeFixtures,
42
+ getVersionsFixtures,
43
+ getVersionFixtures,
44
+ getVersionsAfterMutateFixtures,
45
+ restoreVersionFixtures,
46
+ commitMigrationFixtures,
47
+ errorResponseFixtures,
48
+ authChallengeFixtures,
49
+ authTokenFixtures,
50
+ authSequenceFixtures,
51
+ changeFeedFixtures,
52
+ changeFeedSequenceFixtures,
53
+ unlistedChangeFixtures,
54
+ parentChangeFixtures,
55
+ attachmentDownloadFixtures,
56
+ attachmentUploadFixtures,
57
+ AUTH_FIXTURE_DID,
58
+ AUTH_FIXTURE_NONCE,
59
+ } from '@haverstack/conformance-fixtures';
60
+ import type { WireRecord } from '@haverstack/wire-types';
61
+ import { generateId, hashSchema } from '@haverstack/core';
62
+ import type { Association } from '@haverstack/core';
63
+ import {
64
+ buildTestApp,
65
+ req,
66
+ testConfig,
67
+ TEST_TOKEN,
68
+ TEST_ENTITY_ID,
69
+ logger,
70
+ type TestApp,
71
+ } from './setup.js';
72
+ import { openChangeFeed, type DecodedFrame } from './changeFeedClient.js';
73
+ import { changeRoutes } from '../src/routes/changes.js';
74
+ import { authMiddleware } from '../src/middleware/auth.js';
75
+ import { createApp } from '../src/app.js';
76
+ import type { AppEnv } from '../src/types.js';
77
+
78
+ /**
79
+ * Fixture ids embed a fixed, long-past timestamp (they were authored once
80
+ * and never touched again); this server's ScopedStack.create() rejects any
81
+ * client-supplied id whose embedded timestamp is outside a clock-skew
82
+ * tolerance of "now" (default 24h — see validateIdTimestampSkew() in
83
+ * @haverstack/core). A literal fixture id would fail that freshness check
84
+ * before ever reaching the behavior the fixture is actually pinning, so
85
+ * every dispatched create swaps in a freshly generated id instead.
86
+ *
87
+ * Fixture bodies carry the same long-past `createdAt`/`updatedAt` (a
88
+ * WireRecord is a whole record). Those are stripped too: an owner-token
89
+ * dispatch backdates for real, so a stale `createdAt` beside the fresh id
90
+ * above would fail the skew check these fixtures aren't testing. A fixture
91
+ * that *is* testing backdating passes its own matching pair instead.
92
+ */
93
+ function withFreshId<T extends { id?: string; createdAt?: unknown; updatedAt?: unknown }>(
94
+ body: T,
95
+ ): Omit<T, 'createdAt' | 'updatedAt'> & { id: string } {
96
+ const rest = { ...body };
97
+ delete rest.createdAt;
98
+ delete rest.updatedAt;
99
+ return { ...rest, id: generateId() };
100
+ }
101
+
102
+ const NOTE_TYPE = 'com.example/note@1';
103
+ const NOTE_TYPE_V2 = 'com.example/note@2';
104
+ const COMMENT_TYPE = 'com.example/comment@1';
105
+ const ATTACHMENT_TYPE = '_attachment@1';
106
+
107
+ // Reusing the fixtures' own placeholder identities as real test principals
108
+ // where possible — nothing requires DID-shaped entityIds for a record-level
109
+ // permission entry or a minted token's principal, so fixture and harness
110
+ // agree on more than just shape.
111
+ const CONTRIBUTOR_ID = 'entity-contributor-789';
112
+ const WRITER_ID = 'entity-writer-101112';
113
+ const BLOG_APP_ID = 'com.example.blog';
114
+ const BLOG_SUBJECT_ID = 'entity-blog-subject-131415';
115
+
116
+ async function seedTypes(ctx: TestApp['ctx']) {
117
+ await ctx.stack.defineType(NOTE_TYPE, 'Note', {
118
+ title: { kind: 'string' },
119
+ body: { kind: 'text' },
120
+ pinned: { kind: 'boolean' },
121
+ });
122
+ await ctx.stack.defineType(NOTE_TYPE_V2, 'Note', {
123
+ title: { kind: 'string' },
124
+ pinned: { kind: 'boolean' },
125
+ });
126
+ await ctx.stack.defineType(COMMENT_TYPE, 'Comment', {
127
+ body: { kind: 'text', required: true },
128
+ });
129
+ }
130
+
131
+ let t: TestApp;
132
+ beforeEach(async () => {
133
+ t = await buildTestApp();
134
+ await seedTypes(t.ctx);
135
+ });
136
+ afterEach(async () => {
137
+ await t.cleanup();
138
+ });
139
+
140
+ function assertCoverage(names: string[], handled: Set<string>, skipped: Set<string>) {
141
+ for (const name of names) {
142
+ expect(handled.has(name) || skipped.has(name)).toBe(true);
143
+ }
144
+ expect(names.length).toBe(handled.size + skipped.size);
145
+ }
146
+
147
+ // -------------------------------------------------------
148
+ // Discovery
149
+ // -------------------------------------------------------
150
+
151
+ describe('discovery fixtures', () => {
152
+ const SKIPPED = new Set([
153
+ // Not a requirement this server can fail: the fixture describes a
154
+ // *different* conformant server, one that neither resumes nor includes
155
+ // records. This one does both — src/routes/wellknown.ts writes both
156
+ // flags as literals precisely so discovery can't contradict the route
157
+ // beside it, on a field clients act on without asking again — so the
158
+ // only way to produce this response would be an override that lets it.
159
+ // The obligation the fixture exists to pin is the client's.
160
+ 'discovery-advertises-a-feed-that-neither-resumes-nor-includes-records',
161
+ ]);
162
+ const handled = new Set<string>();
163
+
164
+ test('discovery-declares-protocol-version-and-capabilities', async () => {
165
+ const fixture = discoveryFixtures.find(
166
+ (f) => f.name === 'discovery-declares-protocol-version-and-capabilities',
167
+ )!;
168
+ handled.add(fixture.name);
169
+ const { status, data } = await req(t.app, fixture.method, fixture.path);
170
+ expect(status).toBe(fixture.responseStatus);
171
+ const d = data as Record<string, unknown>;
172
+ expect(typeof d.version).toBe('string');
173
+ expect((d.version as string).split('.')[0]).toBe(
174
+ (fixture.responseBody!.version as string).split('.')[0],
175
+ );
176
+ expect(typeof d.entityId).toBe('string');
177
+ const capabilities = d.capabilities as { sort: { fields: unknown } };
178
+ expect(Array.isArray(capabilities.sort.fields)).toBe(true);
179
+ for (const field of fixture.responseBody!.capabilities!.sort!.fields!) {
180
+ expect(capabilities.sort.fields).toContain(field);
181
+ }
182
+ });
183
+
184
+ test('discovery-omits-absent-timezone', async () => {
185
+ const fixture = discoveryFixtures.find((f) => f.name === 'discovery-omits-absent-timezone')!;
186
+ handled.add(fixture.name);
187
+ const noTzApp = await buildTestApp({ timezone: undefined });
188
+ try {
189
+ const { status, data } = await req(noTzApp.app, fixture.method, fixture.path);
190
+ expect(status).toBe(fixture.responseStatus);
191
+ expect('timezone' in (data as Record<string, unknown>)).toBe(false);
192
+ } finally {
193
+ await noTzApp.cleanup();
194
+ }
195
+ });
196
+
197
+ test('discovery-advertises-did-challenge-auth', async () => {
198
+ const fixture = discoveryFixtures.find(
199
+ (f) => f.name === 'discovery-advertises-did-challenge-auth',
200
+ )!;
201
+ handled.add(fixture.name);
202
+ const { status, data } = await req(t.app, fixture.method, fixture.path);
203
+ expect(status).toBe(fixture.responseStatus);
204
+ expect((data as { auth?: { methods: string[] } }).auth).toEqual(fixture.responseBody!.auth);
205
+ });
206
+
207
+ test('discovery-advertises-a-change-feed', async () => {
208
+ const fixture = discoveryFixtures.find((f) => f.name === 'discovery-advertises-a-change-feed')!;
209
+ handled.add(fixture.name);
210
+ const { status, data } = await req(t.app, fixture.method, fixture.path);
211
+ expect(status).toBe(fixture.responseStatus);
212
+ const changes = (data as { changes?: Record<string, unknown> }).changes;
213
+ expect(changes).toBeDefined();
214
+ expect(changes!.transports).toEqual(['sse']);
215
+ // Both mirror what GET /changes actually does: it mints cursors and
216
+ // honors Last-Event-ID/?since=, and it honors `?include=record`.
217
+ expect(changes!.resume).toBe(true);
218
+ expect(changes!.records).toBe(true);
219
+ });
220
+
221
+ test('coverage', () => {
222
+ assertCoverage(
223
+ discoveryFixtures.map((f) => f.name),
224
+ handled,
225
+ SKIPPED,
226
+ );
227
+ });
228
+ });
229
+
230
+ // -------------------------------------------------------
231
+ // Records: create
232
+ // -------------------------------------------------------
233
+
234
+ describe('createRecord fixtures', () => {
235
+ // Every fixture in this block is dispatched — nothing here is skipped.
236
+ const SKIPPED = new Set<string>();
237
+ const handled = new Set<string>();
238
+
239
+ test('create-record — owner-authored, echoed back verbatim', async () => {
240
+ const fixture = createRecordFixtures.find((f) => f.name === 'create-record')!;
241
+ handled.add(fixture.name);
242
+ const body = withFreshId(fixture.requestBody as WireRecord);
243
+ const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
244
+ expect(status).toBe(fixture.responseStatus);
245
+ const d = data as Record<string, unknown>;
246
+ expect(d.id).toBe(body.id);
247
+ expect(d.typeId).toBe(body.typeId);
248
+ expect(d.content).toEqual(body.content);
249
+ expect(d.version).toBe(1);
250
+ // The owner token acts as the owner entity itself, so entityId is
251
+ // stamped to it — undelegated, so principalId stays absent.
252
+ expect(d.entityId).toBe(TEST_ENTITY_ID);
253
+ expect(d.principalId).toBeUndefined();
254
+ });
255
+
256
+ test('create-record-ignores-client-supplied-entity-and-principal', async () => {
257
+ const fixture = createRecordFixtures.find(
258
+ (f) => f.name === 'create-record-ignores-client-supplied-entity-and-principal',
259
+ )!;
260
+ handled.add(fixture.name);
261
+ const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string });
262
+ await t.ctx.stack.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: body.typeId }]);
263
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
264
+ const { status, data } = await req(t.app, 'POST', fixture.path, { token, body });
265
+ expect(status).toBe(fixture.responseStatus);
266
+ const d = data as Record<string, unknown>;
267
+ expect(d.content).toEqual(body.content);
268
+ // entityId/principalId are stamped from the session, never the body.
269
+ expect(d.entityId).toBe(CONTRIBUTOR_ID);
270
+ expect(d.principalId).toBeUndefined();
271
+ // appId is the deliberate exception: self-reported, honored verbatim.
272
+ expect(d.appId).toBe(body.appId);
273
+ });
274
+
275
+ test('create-record-response-carries-principal-under-delegation', async () => {
276
+ const fixture = createRecordFixtures.find(
277
+ (f) => f.name === 'create-record-response-carries-principal-under-delegation',
278
+ )!;
279
+ handled.add(fixture.name);
280
+ const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string });
281
+ await t.ctx.stack.grant(BLOG_SUBJECT_ID, [{ actions: ['create'], typeId: body.typeId }]);
282
+ await t.ctx.stack.grant(BLOG_APP_ID, [{ actions: ['create'], typeId: body.typeId }]);
283
+ const { token } = await t.ctx.adapter.createToken(BLOG_APP_ID, { onBehalfOf: BLOG_SUBJECT_ID });
284
+ const { status, data } = await req(t.app, 'POST', fixture.path, { token, body });
285
+ expect(status).toBe(fixture.responseStatus);
286
+ const d = data as Record<string, unknown>;
287
+ expect(d.content).toEqual(body.content);
288
+ expect(d.entityId).toBe(BLOG_SUBJECT_ID);
289
+ expect(d.principalId).toBe(BLOG_APP_ID);
290
+ expect(d.appId).toBe(body.appId);
291
+ });
292
+
293
+ test('create-attachment-record-matching-mimetype-succeeds', async () => {
294
+ const fixture = createRecordFixtures.find(
295
+ (f) => f.name === 'create-attachment-record-matching-mimetype-succeeds',
296
+ )!;
297
+ handled.add(fixture.name);
298
+ const body = withFreshId(
299
+ fixture.requestBody as WireRecord & {
300
+ content: { fileId: string; mimeType: string };
301
+ },
302
+ );
303
+ await t.ctx.stack.create(ATTACHMENT_TYPE, {
304
+ fileId: body.content.fileId,
305
+ mimeType: body.content.mimeType,
306
+ size: 1,
307
+ });
308
+ const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
309
+ expect(status).toBe(fixture.responseStatus);
310
+ expect((data as Record<string, unknown>).content).toEqual(body.content);
311
+ });
312
+
313
+ test('create-attachment-record-non-owner-carve-out-succeeds', async () => {
314
+ const fixture = createRecordFixtures.find(
315
+ (f) => f.name === 'create-attachment-record-non-owner-carve-out-succeeds',
316
+ )!;
317
+ handled.add(fixture.name);
318
+ const body = withFreshId(
319
+ fixture.requestBody as WireRecord & { content: { fileId: string; mimeType: string } },
320
+ );
321
+ // The readable reference the carve-out turns on: an owner-authored Note
322
+ // the contributor may read, carrying an attachment association for the
323
+ // same file. The bytes stay the owner's — the contributor never uploads
324
+ // and never proves possession, which is the whole point of the carve-out.
325
+ const uploaded = await t.ctx.stack.putAttachment(
326
+ new Uint8Array([1, 2, 3]),
327
+ body.content.mimeType,
328
+ 'owner.png',
329
+ );
330
+ const fileId = (uploaded.content as { fileId: string }).fileId;
331
+ const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'has a cover' });
332
+ await t.ctx.stack.associate(note.id, { kind: 'attachment', label: 'cover', fileId });
333
+ await t.ctx.stack.grant(CONTRIBUTOR_ID, [
334
+ { actions: ['read-any'], typeId: NOTE_TYPE },
335
+ { actions: ['create'], typeId: ATTACHMENT_TYPE },
336
+ ]);
337
+ const content = { ...body.content, fileId };
338
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
339
+ const { status, data } = await req(t.app, 'POST', fixture.path, {
340
+ token,
341
+ body: { ...body, content },
342
+ });
343
+ expect(status).toBe(fixture.responseStatus);
344
+ const d = data as Record<string, unknown>;
345
+ // Their own record — own id, own filename — not a dedup of the owner's.
346
+ expect(d.id).toBe(body.id);
347
+ expect(d.content).toEqual(content);
348
+ expect(d.entityId).toBe(CONTRIBUTOR_ID);
349
+ });
350
+
351
+ test('create-record-unlisted — unlistedAt in the create body suppresses enumeration from create time', async () => {
352
+ const fixture = createRecordFixtures.find((f) => f.name === 'create-record-unlisted')!;
353
+ handled.add(fixture.name);
354
+ const body = withFreshId(fixture.requestBody as WireRecord);
355
+ const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
356
+ expect(status).toBe(fixture.responseStatus);
357
+ const d = data as Record<string, unknown>;
358
+ expect(d.id).toBe(body.id);
359
+ expect(d.content).toEqual(body.content);
360
+ expect(d.version).toBe(1);
361
+ // Core stamps unlistedAt to the real create time rather than honoring a
362
+ // backdated value from the body it's echoed alongside — presence is
363
+ // what this fixture pins, not the literal value.
364
+ expect(typeof d.unlistedAt).toBe('string');
365
+ });
366
+
367
+ test('coverage', () => {
368
+ assertCoverage(
369
+ createRecordFixtures.map((f) => f.name),
370
+ handled,
371
+ SKIPPED,
372
+ );
373
+ });
374
+ });
375
+
376
+ // -------------------------------------------------------
377
+ // Records: query — envelope invariants
378
+ // -------------------------------------------------------
379
+
380
+ describe('queryRecords fixtures', () => {
381
+ // These pin the query *envelope* (records and cursor, and nothing else;
382
+ // cursor, not records.length, signals exhaustion), not the fixtures'
383
+ // literal opaque cursor strings or record ids, which are this server's own
384
+ // pagination implementation detail. Exercised with real seeded data and
385
+ // real returned cursors rather than replayed fixture cursors.
386
+
387
+ test('query-envelope-is-records-and-cursor — POST /records/query and GET /records agree', async () => {
388
+ await t.ctx.stack.create(NOTE_TYPE, { title: 'Readable' });
389
+ const post = await req(t.app, 'POST', '/records/query', {
390
+ token: TEST_TOKEN,
391
+ body: { filter: { typeId: NOTE_TYPE } },
392
+ });
393
+ expect(post.status).toBe(200);
394
+ expect(Object.keys(post.data as object).sort()).toEqual(['cursor', 'records']);
395
+
396
+ const get = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE)}`, {
397
+ token: TEST_TOKEN,
398
+ });
399
+ expect(get.status).toBe(200);
400
+ expect(Object.keys(get.data as object).sort()).toEqual(['cursor', 'records']);
401
+ });
402
+
403
+ test('query-final-page-closes-the-cursor — paging with limit exhausts to cursor: null', async () => {
404
+ for (let i = 0; i < 3; i++) await t.ctx.stack.create(NOTE_TYPE, { title: `note-${i}` });
405
+
406
+ let cursor: string | null | undefined;
407
+ let seen = 0;
408
+ let pages = 0;
409
+ do {
410
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
411
+ token: TEST_TOKEN,
412
+ body: { filter: { typeId: NOTE_TYPE }, limit: 1, ...(cursor && { cursor }) },
413
+ });
414
+ expect(status).toBe(200);
415
+ const page = data as { records: unknown[]; cursor: string | null };
416
+ seen += page.records.length;
417
+ cursor = page.cursor;
418
+ pages++;
419
+ } while (cursor && pages < 10);
420
+
421
+ expect(cursor).toBeNull();
422
+ expect(seen).toBe(3);
423
+ });
424
+
425
+ test('query-related-to-record-target — relatedTo (+ relatedToLabel) accepts a record-target filter', async () => {
426
+ const fixture = queryRecordsFixtures.find((f) => f.name === 'query-related-to-record-target')!;
427
+ const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
428
+ expect(status).toBe(fixture.responseStatus);
429
+ const page = data as { records: unknown[]; cursor: unknown };
430
+ expect(page.records).toEqual([]);
431
+ expect(page.cursor).toBeNull();
432
+ });
433
+
434
+ test('query-related-to-entity-target — relatedToEntity accepts an entity-target filter', async () => {
435
+ const fixture = queryRecordsFixtures.find((f) => f.name === 'query-related-to-entity-target')!;
436
+ const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
437
+ expect(status).toBe(fixture.responseStatus);
438
+ const page = data as { records: unknown[]; cursor: unknown };
439
+ expect(page.records).toEqual([]);
440
+ expect(page.cursor).toBeNull();
441
+ });
442
+
443
+ test('query-related-to-external-namespace — relatedToNs alone matches the whole namespace', async () => {
444
+ const fixture = queryRecordsFixtures.find(
445
+ (f) => f.name === 'query-related-to-external-namespace',
446
+ )!;
447
+ const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
448
+ expect(status).toBe(fixture.responseStatus);
449
+ const page = data as { records: unknown[]; cursor: unknown };
450
+ expect(page.records).toEqual([]);
451
+ expect(page.cursor).toBeNull();
452
+ });
453
+
454
+ test('query-filters-by-content-presence — filter.contentPresent is accepted', async () => {
455
+ const fixture = queryRecordsFixtures.find(
456
+ (f) => f.name === 'query-filters-by-content-presence',
457
+ )!;
458
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
459
+ token: TEST_TOKEN,
460
+ body: fixture.requestBody,
461
+ });
462
+ expect(status).toBe(fixture.responseStatus);
463
+ const page = data as { records: unknown[]; cursor: unknown };
464
+ expect(page.records).toEqual([]);
465
+ expect(page.cursor).toBeNull();
466
+ });
467
+
468
+ test('query-sorts-by-a-content-field — sort.contentField is accepted on POST', async () => {
469
+ const fixture = queryRecordsFixtures.find((f) => f.name === 'query-sorts-by-a-content-field')!;
470
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
471
+ token: TEST_TOKEN,
472
+ body: fixture.requestBody,
473
+ });
474
+ expect(status).toBe(fixture.responseStatus);
475
+ const page = data as { records: unknown[]; cursor: unknown };
476
+ expect(page.records).toEqual([]);
477
+ expect(page.cursor).toBeNull();
478
+ });
479
+
480
+ test('query-content-sort-folds-case-and-accents — a content-field sort request is accepted', async () => {
481
+ const fixture = queryRecordsFixtures.find(
482
+ (f) => f.name === 'query-content-sort-folds-case-and-accents',
483
+ )!;
484
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
485
+ token: TEST_TOKEN,
486
+ body: fixture.requestBody,
487
+ });
488
+ expect(status).toBe(fixture.responseStatus);
489
+ const page = data as { records: unknown[]; cursor: unknown };
490
+ expect(page.records).toEqual([]);
491
+ expect(page.cursor).toBeNull();
492
+ });
493
+
494
+ test('query-get-sorts-by-a-content-field — ?sortContent= is accepted on GET', async () => {
495
+ const fixture = queryRecordsFixtures.find(
496
+ (f) => f.name === 'query-get-sorts-by-a-content-field',
497
+ )!;
498
+ const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
499
+ expect(status).toBe(fixture.responseStatus);
500
+ const page = data as { records: unknown[]; cursor: unknown };
501
+ expect(page.records).toEqual([]);
502
+ expect(page.cursor).toBeNull();
503
+ });
504
+
505
+ test('coverage', () => {
506
+ // query-empty-page-with-live-cursor and query-get-records-uses-the-same-
507
+ // envelope pin narrower edge cases of the same two invariants (a
508
+ // partial-visibility empty page that isn't exhaustion; GET/POST sharing
509
+ // an envelope) already covered above by construction.
510
+ assertCoverage(
511
+ queryRecordsFixtures.map((f) => f.name),
512
+ new Set([
513
+ 'query-envelope-is-records-and-cursor',
514
+ 'query-empty-page-with-live-cursor',
515
+ 'query-final-page-closes-the-cursor',
516
+ 'query-get-records-uses-the-same-envelope',
517
+ 'query-related-to-record-target',
518
+ 'query-related-to-entity-target',
519
+ 'query-related-to-external-namespace',
520
+ 'query-filters-by-content-presence',
521
+ 'query-sorts-by-a-content-field',
522
+ 'query-content-sort-folds-case-and-accents',
523
+ 'query-get-sorts-by-a-content-field',
524
+ ]),
525
+ new Set(),
526
+ );
527
+ });
528
+ });
529
+
530
+ // -------------------------------------------------------
531
+ // Records: patchContent
532
+ // -------------------------------------------------------
533
+
534
+ describe('patchContent fixtures', () => {
535
+ const handled = new Set<string>();
536
+
537
+ test('patch-content-merges-and-adds-fields', async () => {
538
+ const fixture = patchContentFixtures.find(
539
+ (f) => f.name === 'patch-content-merges-and-adds-fields',
540
+ )!;
541
+ handled.add(fixture.name);
542
+ const record = await t.ctx.stack.create(NOTE_TYPE, {
543
+ title: 'original title',
544
+ body: 'original body',
545
+ });
546
+ const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
547
+ token: TEST_TOKEN,
548
+ body: fixture.requestBody,
549
+ });
550
+ expect(status).toBe(fixture.responseStatus);
551
+ const d = data as Record<string, unknown>;
552
+ expect(d.content).toEqual(fixture.responseBody!.content);
553
+ expect(d.version).toBe(fixture.responseBody!.version);
554
+ });
555
+
556
+ test('patch-content-null-deletes-a-field', async () => {
557
+ const fixture = patchContentFixtures.find(
558
+ (f) => f.name === 'patch-content-null-deletes-a-field',
559
+ )!;
560
+ handled.add(fixture.name);
561
+ const record = await t.ctx.stack.create(NOTE_TYPE, {
562
+ title: 'to be removed',
563
+ body: 'kept',
564
+ });
565
+ const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
566
+ token: TEST_TOKEN,
567
+ body: fixture.requestBody,
568
+ });
569
+ expect(status).toBe(fixture.responseStatus);
570
+ const d = data as Record<string, unknown>;
571
+ expect(d.content).toEqual(fixture.responseBody!.content);
572
+ expect(d.version).toBe(fixture.responseBody!.version);
573
+ });
574
+
575
+ test('patch-record-restamps-the-actor', async () => {
576
+ const fixture = patchContentFixtures.find((f) => f.name === 'patch-record-restamps-the-actor')!;
577
+ handled.add(fixture.name);
578
+ const record = await t.ctx.stack.create(
579
+ NOTE_TYPE,
580
+ { title: 'original' },
581
+ {
582
+ entityId: TEST_ENTITY_ID,
583
+ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }],
584
+ },
585
+ );
586
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
587
+ const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
588
+ token,
589
+ body: fixture.requestBody,
590
+ });
591
+ expect(status).toBe(fixture.responseStatus);
592
+ const d = data as Record<string, unknown>;
593
+ expect(d.content).toEqual(fixture.responseBody!.content);
594
+ // Authorship (entityId) is untouched by a non-author write; updatedBy
595
+ // moves to the requester who made this edit.
596
+ expect(d.entityId).toBe(TEST_ENTITY_ID);
597
+ expect(d.updatedBy).toBe(CONTRIBUTOR_ID);
598
+ });
599
+
600
+ test('coverage', () => {
601
+ assertCoverage(
602
+ patchContentFixtures.map((f) => f.name),
603
+ handled,
604
+ new Set(),
605
+ );
606
+ });
607
+ });
608
+
609
+ // -------------------------------------------------------
610
+ // Records: delete / undelete
611
+ // -------------------------------------------------------
612
+
613
+ describe('deleteRecord fixtures', () => {
614
+ const handled = new Set<string>();
615
+
616
+ test('delete-record-soft', async () => {
617
+ const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-soft')!;
618
+ handled.add(fixture.name);
619
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
620
+ const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
621
+ expect(status).toBe(fixture.responseStatus);
622
+ // Soft-deleted: still fetchable directly, history retained.
623
+ const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
624
+ expect(after.status).toBe(200);
625
+ });
626
+
627
+ test('delete-record-hard', async () => {
628
+ const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-hard')!;
629
+ handled.add(fixture.name);
630
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
631
+ const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, {
632
+ token: TEST_TOKEN,
633
+ });
634
+ expect(status).toBe(fixture.responseStatus);
635
+ const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
636
+ expect(after.status).toBe(404);
637
+ });
638
+
639
+ test('coverage', () => {
640
+ assertCoverage(
641
+ deleteRecordFixtures.map((f) => f.name),
642
+ handled,
643
+ new Set(),
644
+ );
645
+ });
646
+ });
647
+
648
+ describe('undeleteRecord fixtures', () => {
649
+ const handled = new Set<string>();
650
+
651
+ test('undelete-record — reverses a soft delete and is idempotent', async () => {
652
+ const fixture = undeleteRecordFixtures.find((f) => f.name === 'undelete-record')!;
653
+ handled.add(fixture.name);
654
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
655
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
656
+
657
+ const first = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), {
658
+ token: TEST_TOKEN,
659
+ });
660
+ expect(first.status).toBe(fixture.responseStatus);
661
+ const firstBody = first.data as Record<string, unknown>;
662
+ expect(firstBody.deletedAt).toBeUndefined();
663
+
664
+ const second = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), {
665
+ token: TEST_TOKEN,
666
+ });
667
+ expect(second.status).toBe(200);
668
+ expect((second.data as Record<string, unknown>).deletedAt).toBeUndefined();
669
+ expect((second.data as Record<string, unknown>).content).toEqual(firstBody.content);
670
+ });
671
+
672
+ test('coverage', () => {
673
+ assertCoverage(
674
+ undeleteRecordFixtures.map((f) => f.name),
675
+ handled,
676
+ new Set(),
677
+ );
678
+ });
679
+ });
680
+
681
+ // -------------------------------------------------------
682
+ // Associations
683
+ // -------------------------------------------------------
684
+
685
+ describe('associate fixtures', () => {
686
+ const handled = new Set<string>();
687
+
688
+ test('associate-tag', async () => {
689
+ const fixture = associateFixtures.find((f) => f.name === 'associate-tag')!;
690
+ handled.add(fixture.name);
691
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
692
+ const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
693
+ token: TEST_TOKEN,
694
+ body: fixture.requestBody,
695
+ });
696
+ expect(status).toBe(fixture.responseStatus);
697
+ const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
698
+ token: TEST_TOKEN,
699
+ });
700
+ expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]);
701
+ });
702
+
703
+ test('associate-relationship-external-target', async () => {
704
+ const fixture = associateFixtures.find(
705
+ (f) => f.name === 'associate-relationship-external-target',
706
+ )!;
707
+ handled.add(fixture.name);
708
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
709
+ const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
710
+ token: TEST_TOKEN,
711
+ body: fixture.requestBody,
712
+ });
713
+ expect(status).toBe(fixture.responseStatus);
714
+ const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
715
+ token: TEST_TOKEN,
716
+ });
717
+ // The discriminated target travels verbatim — core validates the union,
718
+ // this server neither flattens nor reshapes it.
719
+ expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]);
720
+ });
721
+
722
+ test('coverage', () => {
723
+ assertCoverage(
724
+ associateFixtures.map((f) => f.name),
725
+ handled,
726
+ new Set(),
727
+ );
728
+ });
729
+ });
730
+
731
+ describe('dissociate fixtures', () => {
732
+ const handled = new Set<string>();
733
+
734
+ test('dissociate-tag', async () => {
735
+ const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-tag')!;
736
+ handled.add(fixture.name);
737
+ const record = await t.ctx.stack.create(
738
+ NOTE_TYPE,
739
+ { title: 'x' },
740
+ { associations: [fixture.requestBody as Association] },
741
+ );
742
+ const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
743
+ token: TEST_TOKEN,
744
+ body: fixture.requestBody,
745
+ });
746
+ expect(status).toBe(fixture.responseStatus);
747
+ const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
748
+ token: TEST_TOKEN,
749
+ });
750
+ expect((data as { associations: unknown[] }).associations).toEqual([]);
751
+ });
752
+
753
+ test('coverage', () => {
754
+ assertCoverage(
755
+ dissociateFixtures.map((f) => f.name),
756
+ handled,
757
+ new Set(),
758
+ );
759
+ });
760
+ });
761
+
762
+ // -------------------------------------------------------
763
+ // Permissions
764
+ // -------------------------------------------------------
765
+
766
+ describe('permissions change fixtures', () => {
767
+ const handled = new Set<string>();
768
+
769
+ test('set-permissions-public', async () => {
770
+ const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-public')!;
771
+ handled.add(fixture.name);
772
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
773
+ const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
774
+ token: TEST_TOKEN,
775
+ body: fixture.requestBody,
776
+ });
777
+ expect(status).toBe(fixture.responseStatus);
778
+ const anon = await req(t.app, 'GET', `/records/${record.id}`);
779
+ expect(anon.status).toBe(200);
780
+ });
781
+
782
+ test('set-permissions-empty-is-private', async () => {
783
+ const fixture = permissionsChangeFixtures.find(
784
+ (f) => f.name === 'set-permissions-empty-is-private',
785
+ )!;
786
+ handled.add(fixture.name);
787
+ const record = await t.ctx.stack.create(
788
+ NOTE_TYPE,
789
+ { title: 'x' },
790
+ { permissions: [{ access: 'public' }] },
791
+ );
792
+ const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
793
+ token: TEST_TOKEN,
794
+ body: fixture.requestBody,
795
+ });
796
+ expect(status).toBe(fixture.responseStatus);
797
+ // Anonymous can't tell "made private" from "never existed" — the
798
+ // anti-oracle rule, so 404 + WWW-Authenticate, not 403.
799
+ const anon = await t.app.request(`/records/${record.id}`);
800
+ expect(anon.status).toBe(404);
801
+ expect(anon.headers.get('WWW-Authenticate')).toBe('Bearer');
802
+ });
803
+
804
+ test('coverage', () => {
805
+ assertCoverage(
806
+ permissionsChangeFixtures.map((f) => f.name),
807
+ handled,
808
+ new Set(),
809
+ );
810
+ });
811
+ });
812
+
813
+ // -------------------------------------------------------
814
+ // Versions: a coordinated lifecycle matching the fixtures' own designed
815
+ // story — get-versions-after-restore/migrate explicitly assume the paired
816
+ // mutating fixture (restore-version / commit-migration) already ran, so
817
+ // this block runs them together against one seeded record rather than in
818
+ // independent isolation.
819
+ // -------------------------------------------------------
820
+
821
+ describe('version lifecycle fixtures', () => {
822
+ const handled = new Set<string>();
823
+
824
+ test('owner sees permissions; non-owner write-holder has them stripped', async () => {
825
+ const ownerFixture = getVersionsFixtures.find(
826
+ (f) => f.name === 'get-versions-owner-includes-permissions',
827
+ )!;
828
+ const writerFixture = getVersionsFixtures.find(
829
+ (f) => f.name === 'get-versions-non-owner-write-holder-strips-permissions',
830
+ )!;
831
+ const singleFixture = getVersionFixtures.find(
832
+ (f) => f.name === 'get-version-single-strips-permissions-for-non-owner',
833
+ )!;
834
+ handled.add(ownerFixture.name);
835
+ handled.add(writerFixture.name);
836
+ handled.add(singleFixture.name);
837
+
838
+ const memberPermission = (ownerFixture.responseBody![0] as { permissions: unknown[] })
839
+ .permissions[0];
840
+ const record = await t.ctx.stack.create(
841
+ NOTE_TYPE,
842
+ { title: 'original title' },
843
+ {
844
+ permissions: [
845
+ memberPermission as never,
846
+ { access: 'entity', entityId: WRITER_ID, read: true, write: true },
847
+ ],
848
+ },
849
+ );
850
+ // /versions returns historical snapshots, not the current live state —
851
+ // a record still at its original version has no history yet, so bump
852
+ // it once to give version 1 a snapshot to appear in.
853
+ await t.ctx.stack.patchContent(record.id, { title: 'updated title' });
854
+
855
+ const owner = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN });
856
+ expect(owner.status).toBe(ownerFixture.responseStatus);
857
+ const ownerVersions = owner.data as Array<{ permissions?: unknown[] }>;
858
+ expect(ownerVersions[0]!.permissions).toEqual([
859
+ memberPermission,
860
+ { access: 'entity', entityId: WRITER_ID, read: true, write: true },
861
+ ]);
862
+
863
+ const { token: writerToken } = await t.ctx.adapter.createToken(WRITER_ID);
864
+ const writer = await req(t.app, 'GET', `/records/${record.id}/versions`, {
865
+ token: writerToken,
866
+ });
867
+ expect(writer.status).toBe(writerFixture.responseStatus);
868
+ const writerVersions = writer.data as Array<{ permissions?: unknown[] }>;
869
+ expect(writerVersions[0]!.permissions).toBeUndefined();
870
+
871
+ const single = await req(t.app, 'GET', `/records/${record.id}/versions/1`, {
872
+ token: writerToken,
873
+ });
874
+ expect(single.status).toBe(singleFixture.responseStatus);
875
+ expect((single.data as { permissions?: unknown[] }).permissions).toBeUndefined();
876
+ });
877
+
878
+ test('restore auto-snapshots the pre-restore state; migrate auto-snapshots the pre-migration state', async () => {
879
+ const restoreFixture = restoreVersionFixtures.find((f) => f.name === 'restore-version')!;
880
+ const migrateFixture = commitMigrationFixtures.find((f) => f.name === 'commit-migration')!;
881
+ const afterRestoreFixture = getVersionsAfterMutateFixtures.find(
882
+ (f) => f.name === 'get-versions-after-restore-includes-pre-restore-snapshot',
883
+ )!;
884
+ const afterMigrateFixture = getVersionsAfterMutateFixtures.find(
885
+ (f) => f.name === 'get-versions-after-migrate-includes-pre-migration-snapshot',
886
+ )!;
887
+ handled.add(restoreFixture.name);
888
+ handled.add(migrateFixture.name);
889
+ handled.add(afterRestoreFixture.name);
890
+ handled.add(afterMigrateFixture.name);
891
+
892
+ // v1
893
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' });
894
+ // v2 — an intermediate edit, so v3 (below) isn't restore-version's own
895
+ // pre-restore auto-snapshot colliding with the create itself.
896
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
897
+ token: TEST_TOKEN,
898
+ body: { contentPatch: { title: 'intermediate title' } },
899
+ });
900
+ // v3 — the state restore-version will move away from
901
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
902
+ token: TEST_TOKEN,
903
+ body: { contentPatch: { title: 'title before restore' } },
904
+ });
905
+
906
+ // v3 (auto pre-restore snapshot of v2) + v4 (restored content, from v1)
907
+ const restore = await req(t.app, 'POST', `/records/${record.id}/restore/1`, {
908
+ token: TEST_TOKEN,
909
+ });
910
+ expect(restore.status).toBe(restoreFixture.responseStatus);
911
+ const restored = restore.data as Record<string, unknown>;
912
+ expect(restored.content).toEqual(restoreFixture.responseBody!.content);
913
+ expect(restored.version).toBe(4);
914
+
915
+ // v5 (auto pre-migration snapshot of v4 is v4 itself) + new typeId/content
916
+ const migrateBody = migrateFixture.requestBody!;
917
+ const migrate = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
918
+ token: TEST_TOKEN,
919
+ body: migrateBody,
920
+ });
921
+ expect(migrate.status).toBe(migrateFixture.responseStatus);
922
+ const migrated = migrate.data as Record<string, unknown>;
923
+ expect(migrated.typeId).toBe(migrateBody.toTypeId);
924
+ expect(migrated.content).toEqual(migrateBody.content);
925
+ expect(migrated.version).toBe(5);
926
+
927
+ const versions = await req(t.app, 'GET', `/records/${record.id}/versions`, {
928
+ token: TEST_TOKEN,
929
+ });
930
+ expect(versions.status).toBe(afterMigrateFixture.responseStatus);
931
+ const versionList = versions.data as Array<{ version: number; typeId: string }>;
932
+ const numbers = versionList.map((v) => v.version);
933
+ // v4 (pre-migration snapshot), v3 (pre-restore snapshot), v1 (original) —
934
+ // matches afterMigrateFixture/afterRestoreFixture's own version lists.
935
+ expect(numbers).toEqual(
936
+ expect.arrayContaining(afterMigrateFixture.responseBody!.map((v) => v.version)),
937
+ );
938
+ expect(numbers).toEqual(
939
+ expect.arrayContaining(afterRestoreFixture.responseBody!.map((v) => v.version)),
940
+ );
941
+ const v4Snapshot = versionList.find((v) => v.version === 4)!;
942
+ expect(v4Snapshot.typeId).toBe(NOTE_TYPE);
943
+ });
944
+
945
+ test('a snapshot carries its container, and a restore puts the record back in it', async () => {
946
+ const snapshotFixture = getVersionFixtures.find(
947
+ (f) => f.name === 'get-version-carries-the-container-the-snapshot-was-taken-in',
948
+ )!;
949
+ const restoreFixture = restoreVersionFixtures.find(
950
+ (f) => f.name === 'restore-version-puts-the-record-back-in-its-old-container',
951
+ )!;
952
+ handled.add(snapshotFixture.name);
953
+ handled.add(restoreFixture.name);
954
+
955
+ const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' });
956
+ // v1, in the container
957
+ const record = await t.ctx.stack.create(
958
+ NOTE_TYPE,
959
+ { title: 'original title' },
960
+ { parentId: container.id },
961
+ );
962
+ // v2 — the state the restore below will return to, still in the container
963
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
964
+ token: TEST_TOKEN,
965
+ body: { contentPatch: { title: 'title before restore' } },
966
+ });
967
+ // v3 — moved to the root, which is what gives v2 a container to have been in
968
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
969
+ token: TEST_TOKEN,
970
+ body: { parentId: null },
971
+ });
972
+
973
+ const snapshot = await req(t.app, 'GET', `/records/${record.id}/versions/2`, {
974
+ token: TEST_TOKEN,
975
+ });
976
+ expect(snapshot.status).toBe(snapshotFixture.responseStatus);
977
+ const v2 = snapshot.data as { parentId?: string; content: unknown };
978
+ expect(v2.parentId).toBe(container.id);
979
+ expect(v2.content).toEqual(snapshotFixture.responseBody!.content);
980
+
981
+ const restore = await req(t.app, 'POST', `/records/${record.id}/restore/2`, {
982
+ token: TEST_TOKEN,
983
+ });
984
+ expect(restore.status).toBe(restoreFixture.responseStatus);
985
+ const restored = restore.data as { parentId?: string; content: unknown; version: number };
986
+ // A restore settles containment rather than leaving the record where it
987
+ // sits: the snapshot names the container, so the record goes back to it.
988
+ expect(restored.parentId).toBe(container.id);
989
+ expect(restored.content).toEqual(restoreFixture.responseBody!.content);
990
+ expect(restored.version).toBe(4);
991
+ });
992
+
993
+ test('coverage', () => {
994
+ const names = [
995
+ ...getVersionsFixtures.map((f) => f.name),
996
+ ...getVersionFixtures.map((f) => f.name),
997
+ ...getVersionsAfterMutateFixtures.map((f) => f.name),
998
+ ...restoreVersionFixtures.map((f) => f.name),
999
+ ...commitMigrationFixtures.map((f) => f.name),
1000
+ ];
1001
+ assertCoverage(names, handled, new Set());
1002
+ });
1003
+ });
1004
+
1005
+ // -------------------------------------------------------
1006
+ // Error responses
1007
+ // -------------------------------------------------------
1008
+
1009
+ describe('error response fixtures', () => {
1010
+ // Every fixture in this block is dispatched — nothing here is skipped.
1011
+ const SKIPPED = new Set<string>();
1012
+ const handled = new Set<string>();
1013
+
1014
+ function find(name: string) {
1015
+ const fixture = errorResponseFixtures.find((f) => f.name === name)!;
1016
+ handled.add(name);
1017
+ return fixture;
1018
+ }
1019
+
1020
+ async function dispatch(
1021
+ fixture: (typeof errorResponseFixtures)[number],
1022
+ token: string | undefined,
1023
+ pathOverride?: string,
1024
+ bodyOverride?: unknown,
1025
+ ) {
1026
+ const body = bodyOverride !== undefined ? bodyOverride : fixture.requestBody;
1027
+ return req(t.app, fixture.method, pathOverride ?? fixture.path, {
1028
+ token,
1029
+ ...(body !== undefined && { body }),
1030
+ ...(fixture.requestHeaders && { headers: fixture.requestHeaders }),
1031
+ });
1032
+ }
1033
+
1034
+ function expectError(
1035
+ status: number,
1036
+ data: unknown,
1037
+ fixture: (typeof errorResponseFixtures)[number],
1038
+ ) {
1039
+ expect(status).toBe(fixture.responseStatus);
1040
+ if (fixture.responseBody) {
1041
+ expect((data as { error: { code: string } }).error.code).toBe(
1042
+ (fixture.responseBody as { error: { code: string } }).error.code,
1043
+ );
1044
+ }
1045
+ }
1046
+
1047
+ test('error-permission-denied — can read, no write grant', async () => {
1048
+ const fixture = find('error-permission-denied');
1049
+ // Readability is what earns the 403 (see error-not-found-record-the-
1050
+ // requester-cannot-read below) — a write-only-denied requester still
1051
+ // needs an explicit read grant, or this would hit the anti-oracle 404
1052
+ // instead of the permission check this fixture pins.
1053
+ const record = await t.ctx.stack.create(
1054
+ NOTE_TYPE,
1055
+ { title: 'x' },
1056
+ { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
1057
+ );
1058
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1059
+ const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
1060
+ expectError(status, data, fixture);
1061
+ });
1062
+
1063
+ test('error-not-found-record-the-requester-cannot-read — the anti-oracle rule', async () => {
1064
+ const fixture = find('error-not-found-record-the-requester-cannot-read');
1065
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
1066
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1067
+ const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
1068
+ expectError(status, data, fixture);
1069
+ });
1070
+
1071
+ test('error-validation-permission-write-without-read', async () => {
1072
+ const fixture = find('error-validation-permission-write-without-read');
1073
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
1074
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
1075
+ expectError(status, data, fixture);
1076
+ });
1077
+
1078
+ test('error-bad-request-malformed-parent-id', async () => {
1079
+ const fixture = find('error-bad-request-malformed-parent-id');
1080
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
1081
+ // Format is checked before existence, so the empty string answers for
1082
+ // being malformed rather than for naming nothing. It is not a spelling
1083
+ // of the root here — `null` is.
1084
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
1085
+ expectError(status, data, fixture);
1086
+ });
1087
+
1088
+ test('error-conflict-parent-does-not-exist', async () => {
1089
+ const fixture = find('error-conflict-parent-does-not-exist');
1090
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
1091
+ // Well-formed and simply names nothing, which is the conflict. Only the
1092
+ // owner sees it: the reference gate exempts them, so the existence check
1093
+ // behind it is theirs to reach. Anyone else gets one indistinguishable
1094
+ // 403 for a destination they cannot read and one that is not there.
1095
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
1096
+ expectError(status, data, fixture);
1097
+ });
1098
+
1099
+ test('error-permission-denied-versions-read-only — can read, cannot write', async () => {
1100
+ const fixture = find('error-permission-denied-versions-read-only');
1101
+ const record = await t.ctx.stack.create(
1102
+ NOTE_TYPE,
1103
+ { title: 'x' },
1104
+ { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
1105
+ );
1106
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1107
+ const { status, data } = await dispatch(fixture, token, `/records/${record.id}/versions`);
1108
+ expectError(status, data, fixture);
1109
+ });
1110
+
1111
+ test('error-permission-denied-attachment-non-owner-create', async () => {
1112
+ const fixture = find('error-permission-denied-attachment-non-owner-create');
1113
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1114
+ const { status, data } = await dispatch(fixture, token);
1115
+ expectError(status, data, fixture);
1116
+ });
1117
+
1118
+ test('create-attachment-record-non-owner-without-carve-out-refused', async () => {
1119
+ const fixture = find('create-attachment-record-non-owner-without-carve-out-refused');
1120
+ await t.ctx.stack.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: ATTACHMENT_TYPE }]);
1121
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1122
+ // The contributor uploads the bytes themselves, so they end up holding
1123
+ // an _attachment@1 record for this fileId — the "uploaded it themselves"
1124
+ // clause of the getAttachment() access rule. That clause is exactly what
1125
+ // the create() carve-out excludes: nothing else references the file, so
1126
+ // a second metadata record for it is still refused.
1127
+ const upload = await t.app.request('/attachments', {
1128
+ method: 'POST',
1129
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'image/png' },
1130
+ body: new Uint8Array([9, 9, 9]),
1131
+ });
1132
+ expect(upload.status).toBe(200);
1133
+ const own = (await upload.json()) as { content: { fileId: string } };
1134
+ const body = withFreshId(fixture.requestBody as WireRecord & { content: { fileId: string } });
1135
+ const { status, data } = await dispatch(fixture, token, undefined, {
1136
+ ...body,
1137
+ content: { ...body.content, fileId: own.content.fileId },
1138
+ });
1139
+ expectError(status, data, fixture);
1140
+ });
1141
+
1142
+ test('error-permission-denied-restore-reference-reconveyance', async () => {
1143
+ const fixture = find('error-permission-denied-restore-reference-reconveyance');
1144
+ // v1 carries an attachment association; v2 drops it. Nothing else
1145
+ // references the file afterward — an _attachment@1 record holds its
1146
+ // fileId in a plain string field, not a file-ref one, so the owner's
1147
+ // own upload record is not a reference the attachmentFileId filter
1148
+ // sees. The writer therefore cannot attach the file fresh today, and
1149
+ // restoring v1 would hand it back to them.
1150
+ const uploaded = await t.ctx.stack.putAttachment(
1151
+ new Uint8Array([4, 5, 6]),
1152
+ 'image/png',
1153
+ 'cover.png',
1154
+ );
1155
+ const fileId = (uploaded.content as { fileId: string }).fileId;
1156
+ const record = await t.ctx.stack.create(
1157
+ NOTE_TYPE,
1158
+ { title: 'has a cover' },
1159
+ {
1160
+ permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }],
1161
+ associations: [{ kind: 'attachment', label: 'cover', fileId }],
1162
+ },
1163
+ );
1164
+ await t.ctx.stack.dissociate(record.id, { kind: 'attachment', label: 'cover', fileId });
1165
+
1166
+ const { token } = await t.ctx.adapter.createToken(WRITER_ID);
1167
+ const { status, data } = await dispatch(fixture, token, `/records/${record.id}/restore/1`);
1168
+ expectError(status, data, fixture);
1169
+ // The refusal is about the reference, not about write access: give the
1170
+ // writer a readable record that carries the same file today and the
1171
+ // identical restore goes through.
1172
+ const cover = await t.ctx.stack.create(
1173
+ NOTE_TYPE,
1174
+ { title: 'still has the cover' },
1175
+ {
1176
+ permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }],
1177
+ associations: [{ kind: 'attachment', label: 'cover', fileId }],
1178
+ },
1179
+ );
1180
+ expect(cover.id).toBeDefined();
1181
+ const retry = await req(t.app, 'POST', `/records/${record.id}/restore/1`, { token });
1182
+ expect(retry.status).toBe(200);
1183
+ });
1184
+
1185
+ test('error-validation-failed-restore', async () => {
1186
+ const fixture = find('error-validation-failed-restore');
1187
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
1188
+ // Drift can't be reached through the write API — defineType() refuses
1189
+ // any in-place schema change that would invalidate stored content, and
1190
+ // create()/update() validate everything they write. So the corrupted
1191
+ // snapshot is written straight to the adapter, the way core's own
1192
+ // restoreVersion test produces one, and the assertion is that restore
1193
+ // validates the snapshot against its own stored typeId rather than
1194
+ // trusting it. A create leaves no v1 snapshot of its own, so this adds
1195
+ // one rather than overwriting — the SQLite adapter refuses to replace
1196
+ // an existing version row.
1197
+ await t.ctx.adapter.saveVersion(record.id, {
1198
+ version: 1,
1199
+ typeId: NOTE_TYPE,
1200
+ content: { title: 42 },
1201
+ updatedAt: new Date(),
1202
+ });
1203
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/restore/1`);
1204
+ expectError(status, data, fixture);
1205
+ // Nothing was applied: the record still holds its pre-restore content.
1206
+ const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
1207
+ expect((after.data as { content: unknown }).content).toEqual({ title: 'original' });
1208
+ });
1209
+
1210
+ test('error-timeout-search-exceeds-server-bound', async () => {
1211
+ const fixture = find('error-timeout-search-exceeds-server-bound');
1212
+ // The fixture's own search isn't costly — per its description it stands
1213
+ // in for one that outruns whatever bound a server sets, pinning the
1214
+ // error shape rather than a particular cost. This server's bound is
1215
+ // config.queryTimeoutMs, enforced at the query worker (see
1216
+ // src/lib/queryWorker/pool.ts), so this dispatches against a deadline
1217
+ // no query can meet — the same substitution
1218
+ // attachment-upload-payload-too-large makes with maxAttachmentBytes.
1219
+ const impatientApp = createApp(t.ctx, { ...testConfig(t.dbPath), queryTimeoutMs: 1 }, logger);
1220
+ const { status, data } = await req(impatientApp, fixture.method, fixture.path, {
1221
+ token: TEST_TOKEN,
1222
+ body: fixture.requestBody,
1223
+ });
1224
+ expectError(status, data, fixture);
1225
+ });
1226
+
1227
+ test('error-permission-denied-includeUnlisted-non-owner', async () => {
1228
+ const fixture = find('error-permission-denied-includeUnlisted-non-owner');
1229
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1230
+ const { status, data } = await dispatch(fixture, token);
1231
+ expectError(status, data, fixture);
1232
+ // Same refusal on GET /records — includeUnlisted is owner-only on every
1233
+ // route that accepts it, not just the fixture's own POST /records/query.
1234
+ const get = await req(t.app, 'GET', '/records?includeUnlisted=true', { token });
1235
+ expect(get.status).toBe(403);
1236
+ });
1237
+
1238
+ test('error-not-found — write against a nonexistent id', async () => {
1239
+ const fixture = find('error-not-found');
1240
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1241
+ expectError(status, data, fixture);
1242
+ });
1243
+
1244
+ test('error-conflict-duplicate-id', async () => {
1245
+ const fixture = find('error-conflict-duplicate-id');
1246
+ const body = withFreshId(fixture.requestBody as WireRecord);
1247
+ await t.ctx.stack.create(NOTE_TYPE, { title: 'first' }, { id: body.id });
1248
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body);
1249
+ expectError(status, data, fixture);
1250
+ });
1251
+
1252
+ test('error-validation-failed — PATCH content fails the type schema', async () => {
1253
+ const fixture = find('error-validation-failed');
1254
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
1255
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
1256
+ expectError(status, data, fixture);
1257
+ });
1258
+
1259
+ test('error-validation-attachment-mimetype-conflict-on-create', async () => {
1260
+ const fixture = find('error-validation-attachment-mimetype-conflict-on-create');
1261
+ const body = withFreshId(fixture.requestBody as WireRecord & { content: { fileId: string } });
1262
+ await t.ctx.stack.create(ATTACHMENT_TYPE, {
1263
+ fileId: body.content.fileId,
1264
+ mimeType: 'image/png',
1265
+ size: 1,
1266
+ });
1267
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body);
1268
+ expectError(status, data, fixture);
1269
+ });
1270
+
1271
+ test('error-validation-attachment-mimetype-immutable-on-update', async () => {
1272
+ const fixture = find('error-validation-attachment-mimetype-immutable-on-update');
1273
+ const id = fixture.path.split('/')[2]!;
1274
+ await t.ctx.stack.create(
1275
+ ATTACHMENT_TYPE,
1276
+ { fileId: 'a'.repeat(64), mimeType: 'image/png', size: 1 },
1277
+ { id },
1278
+ );
1279
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1280
+ expectError(status, data, fixture);
1281
+ });
1282
+
1283
+ test('error-bad-request-malformed-cursor', async () => {
1284
+ const fixture = find('error-bad-request-malformed-cursor');
1285
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1286
+ expectError(status, data, fixture);
1287
+ });
1288
+
1289
+ test('error-bad-request-unknown-sort-field-cursor', async () => {
1290
+ const fixture = find('error-bad-request-unknown-sort-field-cursor');
1291
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1292
+ expectError(status, data, fixture);
1293
+ });
1294
+
1295
+ test('error-bad-request-both-sort-parameters', async () => {
1296
+ const fixture = find('error-bad-request-both-sort-parameters');
1297
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1298
+ expectError(status, data, fixture);
1299
+ });
1300
+
1301
+ test('error-payload-too-large-record-body', async () => {
1302
+ const fixture = find('error-payload-too-large-record-body');
1303
+ const oversized = {
1304
+ ...(fixture.requestBody as WireRecord),
1305
+ content: { body: 'x'.repeat(2 * 1024 * 1024) },
1306
+ };
1307
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1308
+ token: TEST_TOKEN,
1309
+ body: oversized,
1310
+ });
1311
+ expectError(status, data, fixture);
1312
+ });
1313
+
1314
+ test('error-reserved-content-key', async () => {
1315
+ const fixture = find('error-reserved-content-key');
1316
+ const id = fixture.path.split('/')[2]!;
1317
+ await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { id });
1318
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1319
+ expectError(status, data, fixture);
1320
+ });
1321
+
1322
+ test('error-version-conflict-if-match-mismatch', async () => {
1323
+ const fixture = find('error-version-conflict-if-match-mismatch');
1324
+ const id = fixture.path.split('/')[2]!;
1325
+ await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { id });
1326
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1327
+ expectError(status, data, fixture);
1328
+ });
1329
+
1330
+ test('error-schema-drift-non-additive-redefinition', async () => {
1331
+ const fixture = find('error-schema-drift-non-additive-redefinition');
1332
+ // schemaHash is verified server-side against the submitted schema — the
1333
+ // fixture's own value is illustrative, not a real hash of its schema.
1334
+ const body = fixture.requestBody as { schema: Record<string, unknown> };
1335
+ const schemaHash = await hashSchema(body.schema as never);
1336
+ const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, {
1337
+ ...body,
1338
+ schemaHash,
1339
+ });
1340
+ expectError(status, data, fixture);
1341
+ });
1342
+
1343
+ test.each([
1344
+ 'error-bad-request-id-invalid-charset',
1345
+ 'error-bad-request-id-invalid-length',
1346
+ 'error-bad-request-id-reserved-prefix',
1347
+ ])('%s', async (name) => {
1348
+ const fixture = find(name);
1349
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1350
+ expectError(status, data, fixture);
1351
+ });
1352
+
1353
+ test.each(['error-conflict-delete-config', 'error-conflict-config-entityid-change'])(
1354
+ '%s',
1355
+ async (name) => {
1356
+ const fixture = find(name);
1357
+ const { status, data } = await dispatch(fixture, TEST_TOKEN);
1358
+ expectError(status, data, fixture);
1359
+ },
1360
+ );
1361
+
1362
+ test('error-unauthorized-anonymous', async () => {
1363
+ const fixture = find('error-unauthorized-anonymous');
1364
+ const { status } = await dispatch(fixture, undefined);
1365
+ expect(status).toBe(fixture.responseStatus);
1366
+ });
1367
+
1368
+ test('coverage', () => {
1369
+ assertCoverage(
1370
+ errorResponseFixtures.map((f) => f.name),
1371
+ handled,
1372
+ SKIPPED,
1373
+ );
1374
+ });
1375
+ });
1376
+
1377
+ // -------------------------------------------------------
1378
+ // Change feed: a change-feed fixture isn't a request/response pair like
1379
+ // every other block here — it pins an ordered stream of frames a
1380
+ // connection sees, optionally across mutations made while it's open.
1381
+ // tests/changeFeedClient.ts dispatches those against the real GET /changes.
1382
+ //
1383
+ // Same targeted-field discipline as every other block: a fixture's `seq`
1384
+ // and record ids/timestamps are illustrative, not literal. This server
1385
+ // mints real cursors (`resume: true`), so `ready.data.seq` and
1386
+ // every `record` frame's SSE `id:` are asserted structurally (base64url
1387
+ // shaped) below rather than deep-equated against a fixture's placeholder.
1388
+ // -------------------------------------------------------
1389
+
1390
+ function frameData(frame: DecodedFrame): Record<string, unknown> {
1391
+ return frame.data as Record<string, unknown>;
1392
+ }
1393
+
1394
+ /** base64url charset — the shape every minted cursor is held to (isValidSeq). */
1395
+ const SEQ_PATTERN = /^[A-Za-z0-9_-]+$/;
1396
+
1397
+ describe('changeFeed fixtures', () => {
1398
+ const handled = new Set<string>();
1399
+ const SKIPPED = new Set([
1400
+ // Pins a client obligation, in the fixture's own words — ignore an
1401
+ // unrecognized SSE event name — by injecting a synthetic `type` frame
1402
+ // between `ready` and the `record` frame it expects. A server
1403
+ // implementing only this version emits no such frame, so there is
1404
+ // nothing server-side to exercise until a later minor adds one; the
1405
+ // frame would have to be forged to dispatch this at all.
1406
+ 'change-feed-unknown-frame-names-are-ignored',
1407
+ ]);
1408
+
1409
+ test('change-feed-ready-leads-every-connection', async () => {
1410
+ const fixture = changeFeedFixtures.find(
1411
+ (f) => f.name === 'change-feed-ready-leads-every-connection',
1412
+ )!;
1413
+ handled.add(fixture.name);
1414
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1415
+ try {
1416
+ expect(conn.status).toBe(fixture.responseStatus);
1417
+ const [ready] = await conn.waitForFrames(1);
1418
+ expect(ready.event).toBe('ready');
1419
+ // ready never carries an SSE `id:` line — the cursor it reports rides
1420
+ // in the JSON body, as `data.seq`.
1421
+ expect(ready.id).toBeUndefined();
1422
+ expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
1423
+ } finally {
1424
+ await conn.close();
1425
+ }
1426
+ });
1427
+
1428
+ test('change-feed-created-frame', async () => {
1429
+ const fixture = changeFeedFixtures.find((f) => f.name === 'change-feed-created-frame')!;
1430
+ handled.add(fixture.name);
1431
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1432
+ try {
1433
+ await conn.waitForFrames(1); // ready
1434
+ const { data: created } = await req(t.app, 'POST', '/records', {
1435
+ token: TEST_TOKEN,
1436
+ body: { typeId: NOTE_TYPE, content: { title: 'Hello' } },
1437
+ });
1438
+ const recordId = (created as { id: string }).id;
1439
+ const [, frame] = await conn.waitForFrames(2);
1440
+ expect(frame.event).toBe('record');
1441
+ // A record frame's SSE `id:` is its resume cursor.
1442
+ expect(frame.id).toMatch(SEQ_PATTERN);
1443
+ expect(frame.id).toBe((frameData(frame) as { seq?: string }).seq);
1444
+ const data = frameData(frame);
1445
+ expect(data.kind).toBe('created');
1446
+ expect(data.ops).toEqual(['create']);
1447
+ expect(data.recordId).toBe(recordId);
1448
+ expect(data.typeId).toBe(NOTE_TYPE);
1449
+ expect(data.version).toBe(1);
1450
+ expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID);
1451
+ } finally {
1452
+ await conn.close();
1453
+ }
1454
+ });
1455
+
1456
+ test('change-feed-changed-frame-names-the-verb', async () => {
1457
+ const fixture = changeFeedFixtures.find(
1458
+ (f) => f.name === 'change-feed-changed-frame-names-the-verb',
1459
+ )!;
1460
+ handled.add(fixture.name);
1461
+ const record = await t.ctx.stack.create(
1462
+ NOTE_TYPE,
1463
+ { title: 'original' },
1464
+ { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }] },
1465
+ );
1466
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1467
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1468
+ try {
1469
+ await conn.waitForFrames(1); // ready
1470
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1471
+ token,
1472
+ body: { contentPatch: { title: 'edited by a contributor' } },
1473
+ });
1474
+ const [, frame] = await conn.waitForFrames(2);
1475
+ const data = frameData(frame);
1476
+ expect(data.kind).toBe('changed');
1477
+ expect(data.ops).toEqual(['patch']);
1478
+ expect(data.recordId).toBe(record.id);
1479
+ expect((data.actor as { entityId: string }).entityId).toBe(CONTRIBUTOR_ID);
1480
+ } finally {
1481
+ await conn.close();
1482
+ }
1483
+ });
1484
+
1485
+ test('change-feed-deleted-frame-is-not-terminal', async () => {
1486
+ const fixture = changeFeedFixtures.find(
1487
+ (f) => f.name === 'change-feed-deleted-frame-is-not-terminal',
1488
+ )!;
1489
+ handled.add(fixture.name);
1490
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1491
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1492
+ try {
1493
+ await conn.waitForFrames(1); // ready
1494
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
1495
+ const [, frame] = await conn.waitForFrames(2);
1496
+ const data = frameData(frame);
1497
+ expect(data.kind).toBe('deleted');
1498
+ expect(data.ops).toEqual(['delete']);
1499
+ expect(data.recordId).toBe(record.id);
1500
+ } finally {
1501
+ await conn.close();
1502
+ }
1503
+ });
1504
+
1505
+ test('change-feed-purged-frame-carries-nothing-about-the-record', async () => {
1506
+ const fixture = changeFeedFixtures.find(
1507
+ (f) => f.name === 'change-feed-purged-frame-carries-nothing-about-the-record',
1508
+ )!;
1509
+ handled.add(fixture.name);
1510
+ const parent = await t.ctx.stack.create(NOTE_TYPE, { title: 'parent' });
1511
+ const record = await t.ctx.stack.create(
1512
+ NOTE_TYPE,
1513
+ { title: 'to be purged' },
1514
+ { parentId: parent.id, entityId: CONTRIBUTOR_ID },
1515
+ );
1516
+ const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN });
1517
+ try {
1518
+ await conn.waitForFrames(1); // ready
1519
+ await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN });
1520
+ const [, frame] = await conn.waitForFrames(2);
1521
+ const data = frameData(frame);
1522
+ expect(data.kind).toBe('purged');
1523
+ expect(data.ops).toEqual(['hard-delete']);
1524
+ expect(data.recordId).toBe(record.id);
1525
+ expect(data.typeId).toBe(NOTE_TYPE);
1526
+ expect('record' in data).toBe(false);
1527
+ expect('parentId' in data).toBe(false);
1528
+ // Owner-acting-alone is the only way to reach hard delete, and a
1529
+ // purge stamps nothing on a record that no longer exists — the actor
1530
+ // is the requester, never the record's own author (CONTRIBUTOR_ID).
1531
+ expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID);
1532
+ } finally {
1533
+ await conn.close();
1534
+ }
1535
+ });
1536
+
1537
+ test('change-feed-include-record-carries-the-body', async () => {
1538
+ const fixture = changeFeedFixtures.find(
1539
+ (f) => f.name === 'change-feed-include-record-carries-the-body',
1540
+ )!;
1541
+ handled.add(fixture.name);
1542
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
1543
+ const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN });
1544
+ try {
1545
+ await conn.waitForFrames(1); // ready
1546
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1547
+ token: TEST_TOKEN,
1548
+ body: { contentPatch: { title: 'Updated title' } },
1549
+ });
1550
+ const [, frame] = await conn.waitForFrames(2);
1551
+ const data = frameData(frame);
1552
+ const wireRecord = data.record as { id: string; content: { title: string } };
1553
+ expect(wireRecord.id).toBe(record.id);
1554
+ expect(wireRecord.content).toEqual({ title: 'Updated title' });
1555
+ } finally {
1556
+ await conn.close();
1557
+ }
1558
+ });
1559
+
1560
+ test('change-feed-unreadable-record-produces-no-frame', async () => {
1561
+ const fixture = changeFeedFixtures.find(
1562
+ (f) => f.name === 'change-feed-unreadable-record-produces-no-frame',
1563
+ )!;
1564
+ handled.add(fixture.name);
1565
+ const privateRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' }); // owner-only
1566
+ const sharedRecord = await t.ctx.stack.create(
1567
+ NOTE_TYPE,
1568
+ { title: 'shared' },
1569
+ { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
1570
+ );
1571
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
1572
+ const conn = await openChangeFeed(t.app, '/changes', { token });
1573
+ try {
1574
+ await conn.waitForFrames(1); // ready
1575
+ await req(t.app, 'PATCH', `/records/${privateRecord.id}`, {
1576
+ token: TEST_TOKEN,
1577
+ body: { contentPatch: { title: 'still private' } },
1578
+ });
1579
+ await req(t.app, 'PATCH', `/records/${sharedRecord.id}`, {
1580
+ token: TEST_TOKEN,
1581
+ body: { contentPatch: { title: 'shared, updated' } },
1582
+ });
1583
+ // Exactly one more frame — the readable record's — proves the
1584
+ // private edit produced none rather than merely arriving later.
1585
+ const [, frame] = await conn.waitForFrames(2);
1586
+ const data = frameData(frame);
1587
+ expect(data.recordId).toBe(sharedRecord.id);
1588
+ } finally {
1589
+ await conn.close();
1590
+ }
1591
+ });
1592
+
1593
+ test('change-feed-typeid-filter-matches-by-baseid', async () => {
1594
+ const fixture = changeFeedFixtures.find(
1595
+ (f) => f.name === 'change-feed-typeid-filter-matches-by-baseid',
1596
+ )!;
1597
+ handled.add(fixture.name);
1598
+ const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1599
+ const comment = await t.ctx.stack.create(COMMENT_TYPE, { body: 'unrelated' });
1600
+ const conn = await openChangeFeed(t.app, `/changes?typeId=${encodeURIComponent(NOTE_TYPE)}`, {
1601
+ token: TEST_TOKEN,
1602
+ });
1603
+ try {
1604
+ await conn.waitForFrames(1); // ready
1605
+ await req(t.app, 'PATCH', `/records/${comment.id}`, {
1606
+ token: TEST_TOKEN,
1607
+ body: { contentPatch: { body: 'still unrelated' } },
1608
+ });
1609
+ await req(t.app, 'POST', `/records/${note.id}/migrate`, {
1610
+ token: TEST_TOKEN,
1611
+ body: { toTypeId: NOTE_TYPE_V2, content: { title: 'Hello', pinned: false } },
1612
+ });
1613
+ // Exactly one more frame — the migration — proves the unrelated
1614
+ // type's edit was filtered out rather than merely arriving later.
1615
+ const [, frame] = await conn.waitForFrames(2);
1616
+ const data = frameData(frame);
1617
+ expect(data.recordId).toBe(note.id);
1618
+ expect(data.typeId).toBe(NOTE_TYPE_V2);
1619
+ expect(data.ops).toEqual(['migrate']);
1620
+ } finally {
1621
+ await conn.close();
1622
+ }
1623
+ });
1624
+
1625
+ test('change-feed-reset-when-no-cursor-is-honored', async () => {
1626
+ const fixture = changeFeedFixtures.find(
1627
+ (f) => f.name === 'change-feed-reset-when-no-cursor-is-honored',
1628
+ )!;
1629
+ handled.add(fixture.name);
1630
+ // This fixture pins a server advertising `resume: false`, and this
1631
+ // server defaults to true, so it runs against a standalone app built
1632
+ // with the matching test-only override.
1633
+ const resumeDisabledApp = new Hono<AppEnv>();
1634
+ resumeDisabledApp.use(authMiddleware(testConfig(t.dbPath).ownerToken, t.ctx));
1635
+ resumeDisabledApp.route(
1636
+ '/',
1637
+ changeRoutes(t.ctx, testConfig(t.dbPath), logger, { resume: false }),
1638
+ );
1639
+ const conn = await openChangeFeed(resumeDisabledApp, '/', {
1640
+ token: TEST_TOKEN,
1641
+ headers: { 'Last-Event-ID': 'AA3f1R' },
1642
+ });
1643
+ try {
1644
+ expect(conn.status).toBe(fixture.responseStatus);
1645
+ const [ready, reset] = await conn.waitForFrames(2);
1646
+ expect(ready.event).toBe('ready');
1647
+ expect(reset.event).toBe('reset');
1648
+ expect(frameData(reset).reason).toBe('not_supported');
1649
+ } finally {
1650
+ await conn.close();
1651
+ }
1652
+ });
1653
+
1654
+ test('change-feed-unlist-frame-is-a-deleted-kind', async () => {
1655
+ const fixture = changeFeedFixtures.find(
1656
+ (f) => f.name === 'change-feed-unlist-frame-is-a-deleted-kind',
1657
+ )!;
1658
+ handled.add(fixture.name);
1659
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1660
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1661
+ try {
1662
+ await conn.waitForFrames(1); // ready
1663
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1664
+ token: TEST_TOKEN,
1665
+ body: { unlisted: true },
1666
+ });
1667
+ const [, frame] = await conn.waitForFrames(2);
1668
+ const data = frameData(frame);
1669
+ expect(data.kind).toBe('deleted');
1670
+ expect(data.ops).toEqual(['unlist']);
1671
+ expect(data.recordId).toBe(record.id);
1672
+ } finally {
1673
+ await conn.close();
1674
+ }
1675
+ });
1676
+
1677
+ test('change-feed-list-frame-is-a-changed-kind', async () => {
1678
+ const fixture = changeFeedFixtures.find(
1679
+ (f) => f.name === 'change-feed-list-frame-is-a-changed-kind',
1680
+ )!;
1681
+ handled.add(fixture.name);
1682
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1683
+ await t.ctx.stack.mutate(record.id, { unlisted: true });
1684
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1685
+ try {
1686
+ await conn.waitForFrames(1); // ready
1687
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1688
+ token: TEST_TOKEN,
1689
+ body: { unlisted: false },
1690
+ });
1691
+ const [, frame] = await conn.waitForFrames(2);
1692
+ const data = frameData(frame);
1693
+ expect(data.kind).toBe('changed');
1694
+ expect(data.ops).toEqual(['list']);
1695
+ expect(data.recordId).toBe(record.id);
1696
+ } finally {
1697
+ await conn.close();
1698
+ }
1699
+ });
1700
+
1701
+ test('change-feed-unlisted-record-produces-no-frame-by-default', async () => {
1702
+ const fixture = changeFeedFixtures.find(
1703
+ (f) => f.name === 'change-feed-unlisted-record-produces-no-frame-by-default',
1704
+ )!;
1705
+ handled.add(fixture.name);
1706
+ const unlistedRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Unlisted' });
1707
+ await t.ctx.stack.mutate(unlistedRecord.id, { unlisted: true });
1708
+ const visibleRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Visible' });
1709
+ const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1710
+ try {
1711
+ await conn.waitForFrames(1); // ready
1712
+ await req(t.app, 'PATCH', `/records/${unlistedRecord.id}`, {
1713
+ token: TEST_TOKEN,
1714
+ body: { contentPatch: { title: 'Edited while unlisted' } },
1715
+ });
1716
+ await req(t.app, 'PATCH', `/records/${visibleRecord.id}`, {
1717
+ token: TEST_TOKEN,
1718
+ body: { contentPatch: { title: 'Edited, visible' } },
1719
+ });
1720
+ // Exactly one more frame — the visible record's — proves the
1721
+ // unlisted edit produced none rather than merely arriving later.
1722
+ const [, frame] = await conn.waitForFrames(2);
1723
+ const data = frameData(frame);
1724
+ expect(data.recordId).toBe(visibleRecord.id);
1725
+ } finally {
1726
+ await conn.close();
1727
+ }
1728
+ });
1729
+
1730
+ test('change-feed-reparent-reaches-the-container-a-record-left', async () => {
1731
+ const fixture = changeFeedFixtures.find(
1732
+ (f) => f.name === 'change-feed-reparent-reaches-the-container-a-record-left',
1733
+ )!;
1734
+ handled.add(fixture.name);
1735
+
1736
+ const origin = await t.ctx.stack.create(NOTE_TYPE, { title: 'Origin' });
1737
+ const destination = await t.ctx.stack.create(NOTE_TYPE, { title: 'Destination' });
1738
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }, { parentId: origin.id });
1739
+
1740
+ // Filtered on the container the record is about to leave: the record's
1741
+ // post-change state names only the destination, so a subscriber watching
1742
+ // the origin hears about the departure only because a reparent is
1743
+ // matched against both sides of the move.
1744
+ const conn = await openChangeFeed(t.app, `/changes?parentId=${origin.id}`, {
1745
+ token: TEST_TOKEN,
1746
+ });
1747
+ try {
1748
+ await conn.waitForFrames(1); // ready
1749
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1750
+ token: TEST_TOKEN,
1751
+ body: { parentId: destination.id },
1752
+ });
1753
+ const [, frame] = await conn.waitForFrames(2);
1754
+ const data = frameData(frame);
1755
+ // Still there and still readable — only its container moved.
1756
+ expect(data.kind).toBe('changed');
1757
+ expect(data.ops).toEqual(['reparent']);
1758
+ expect(data.recordId).toBe(record.id);
1759
+ // The frame carries the destination, as every frame carries the
1760
+ // record's state at the moment of the change.
1761
+ expect(data.parentId).toBe(destination.id);
1762
+ } finally {
1763
+ await conn.close();
1764
+ }
1765
+ });
1766
+
1767
+ test('coverage', () => {
1768
+ assertCoverage(
1769
+ changeFeedFixtures.map((f) => f.name),
1770
+ handled,
1771
+ SKIPPED,
1772
+ );
1773
+ });
1774
+ });
1775
+
1776
+ describe('changeFeed sequence fixtures', () => {
1777
+ const handled = new Set<string>();
1778
+
1779
+ test('change-feed-resume-delivers-what-was-missed', async () => {
1780
+ const fixture = changeFeedSequenceFixtures.find(
1781
+ (f) => f.name === 'change-feed-resume-delivers-what-was-missed',
1782
+ )!;
1783
+ handled.add(fixture.name);
1784
+
1785
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
1786
+
1787
+ // Step 1: connect fresh, see one change, retain the id it carried.
1788
+ const first = await openChangeFeed(t.app, fixture.steps[0]!.path, { token: TEST_TOKEN });
1789
+ let lastEventId: string;
1790
+ try {
1791
+ expect(first.status).toBe(fixture.steps[0]!.responseStatus);
1792
+ const [ready] = await first.waitForFrames(1);
1793
+ expect(ready.event).toBe('ready');
1794
+ expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
1795
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1796
+ token: TEST_TOKEN,
1797
+ body: { contentPatch: { title: 'first' } },
1798
+ });
1799
+ const [, changeFrame] = await first.waitForFrames(2);
1800
+ expect(changeFrame.event).toBe('record');
1801
+ expect(changeFrame.id).toMatch(SEQ_PATTERN);
1802
+ lastEventId = changeFrame.id!;
1803
+ } finally {
1804
+ await first.close();
1805
+ }
1806
+
1807
+ // A second edit lands with nobody listening — the buffer (retained
1808
+ // past disconnect) keeps collecting on its own.
1809
+ await req(t.app, 'PATCH', `/records/${record.id}`, {
1810
+ token: TEST_TOKEN,
1811
+ body: { contentPatch: { title: 'second' } },
1812
+ });
1813
+
1814
+ // Step 2: reconnect presenting the last id seen. ready still leads,
1815
+ // and exactly the missed change is replayed — never the frame the
1816
+ // first connection already had.
1817
+ const second = await openChangeFeed(t.app, fixture.steps[1]!.path, {
1818
+ token: TEST_TOKEN,
1819
+ headers: { 'Last-Event-ID': lastEventId },
1820
+ });
1821
+ try {
1822
+ expect(second.status).toBe(fixture.steps[1]!.responseStatus);
1823
+ const [ready, replayed] = await second.waitForFrames(2);
1824
+ expect(ready.event).toBe('ready');
1825
+ expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
1826
+ expect(replayed.event).toBe('record');
1827
+ expect(replayed.id).toMatch(SEQ_PATTERN);
1828
+ expect(replayed.id).not.toBe(lastEventId);
1829
+ const data = frameData(replayed);
1830
+ expect(data.recordId).toBe(record.id);
1831
+ expect(data.version).toBe(3);
1832
+ // Nothing else arrives — confirms the already-delivered frame from
1833
+ // step 1 wasn't replayed a second time.
1834
+ await expect(second.waitForFrames(3, 300)).rejects.toThrow();
1835
+ } finally {
1836
+ await second.close();
1837
+ }
1838
+ });
1839
+
1840
+ test('change-feed-reset-rather-than-resume-from-wherever-it-can', async () => {
1841
+ const fixture = changeFeedSequenceFixtures.find(
1842
+ (f) => f.name === 'change-feed-reset-rather-than-resume-from-wherever-it-can',
1843
+ )!;
1844
+ handled.add(fixture.name);
1845
+
1846
+ // A buffer past its retention window is unrecognized on reconnect —
1847
+ // forced deterministically with a near-zero window rather than
1848
+ // waiting out the real (5-minute) default.
1849
+ const config = testConfig(t.dbPath);
1850
+ const resumeApp = new Hono<AppEnv>();
1851
+ resumeApp.use(authMiddleware(config.ownerToken, t.ctx));
1852
+ resumeApp.route(
1853
+ '/changes',
1854
+ changeRoutes(t.ctx, config, logger, { resume: true, resumeRetentionMs: 10 }),
1855
+ );
1856
+
1857
+ const first = await openChangeFeed(resumeApp, fixture.steps[0]!.path, { token: TEST_TOKEN });
1858
+ let headCursor: string;
1859
+ try {
1860
+ expect(first.status).toBe(fixture.steps[0]!.responseStatus);
1861
+ const [ready] = await first.waitForFrames(1);
1862
+ expect(ready.event).toBe('ready');
1863
+ headCursor = frameData(ready).seq as string;
1864
+ expect(headCursor).toMatch(SEQ_PATTERN);
1865
+ } finally {
1866
+ await first.close();
1867
+ }
1868
+
1869
+ // Long enough past the deliberately tiny retention window for the
1870
+ // buffer to have been dropped.
1871
+ await new Promise((resolve) => setTimeout(resolve, 50));
1872
+
1873
+ const second = await openChangeFeed(resumeApp, fixture.steps[1]!.path, {
1874
+ token: TEST_TOKEN,
1875
+ headers: { 'Last-Event-ID': headCursor },
1876
+ });
1877
+ try {
1878
+ expect(second.status).toBe(fixture.steps[1]!.responseStatus);
1879
+ const [ready, reset] = await second.waitForFrames(2);
1880
+ expect(ready.event).toBe('ready');
1881
+ // A fresh buffer, so a fresh (different) head cursor.
1882
+ expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
1883
+ expect(frameData(ready).seq).not.toBe(headCursor);
1884
+ expect(reset.event).toBe('reset');
1885
+ expect(frameData(reset).reason).toBe('cursor_expired');
1886
+ } finally {
1887
+ await second.close();
1888
+ }
1889
+ });
1890
+
1891
+ test('coverage', () => {
1892
+ assertCoverage(
1893
+ changeFeedSequenceFixtures.map((f) => f.name),
1894
+ handled,
1895
+ new Set(),
1896
+ );
1897
+ });
1898
+ });
1899
+
1900
+ // -------------------------------------------------------
1901
+ // Auth: DID challenge-response handshake
1902
+ // -------------------------------------------------------
1903
+
1904
+ describe('auth handshake fixtures', () => {
1905
+ // Fixture-carried signatures are real Ed25519 signatures over one exact,
1906
+ // fixed nonce value — this server generates its own random nonces via
1907
+ // POST /auth/challenge, so authTokenFixtures/authSequenceFixtures seed
1908
+ // that exact value directly into the nonce store rather than going
1909
+ // through the challenge endpoint.
1910
+ function seedFixtureNonce(ttlMs = 5 * 60 * 1000) {
1911
+ t.ctx.nonces.seedForTesting(AUTH_FIXTURE_NONCE, AUTH_FIXTURE_DID, new Date(Date.now() + ttlMs));
1912
+ }
1913
+
1914
+ const handled = new Set<string>();
1915
+
1916
+ test('auth-challenge-issues-nonce', async () => {
1917
+ const fixture = authChallengeFixtures.find((f) => f.name === 'auth-challenge-issues-nonce')!;
1918
+ handled.add(fixture.name);
1919
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1920
+ body: fixture.requestBody,
1921
+ });
1922
+ expect(status).toBe(fixture.responseStatus);
1923
+ const d = data as { nonce: string; expiresAt: string };
1924
+ // The nonce itself is server-generated and random — not compared
1925
+ // literally — but must be base64url, per the same charset constraint
1926
+ // buildAuthChallengePayload enforces on the signed payload.
1927
+ expect(d.nonce).toMatch(/^[A-Za-z0-9_-]+$/);
1928
+ expect(typeof d.expiresAt).toBe('string');
1929
+ expect(new Date(d.expiresAt).getTime()).toBeGreaterThan(Date.now());
1930
+ });
1931
+
1932
+ test('auth-challenge-rejects-malformed-did', async () => {
1933
+ const fixture = authChallengeFixtures.find(
1934
+ (f) => f.name === 'auth-challenge-rejects-malformed-did',
1935
+ )!;
1936
+ handled.add(fixture.name);
1937
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1938
+ body: fixture.requestBody,
1939
+ });
1940
+ expect(status).toBe(fixture.responseStatus);
1941
+ expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
1942
+ });
1943
+
1944
+ test('auth-token-issues-bearer-token', async () => {
1945
+ const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-issues-bearer-token')!;
1946
+ handled.add(fixture.name);
1947
+ seedFixtureNonce();
1948
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1949
+ body: fixture.requestBody,
1950
+ });
1951
+ expect(status).toBe(fixture.responseStatus);
1952
+ const d = data as { token: string; principalId: string; subjectId: string };
1953
+ expect(typeof d.token).toBe('string');
1954
+ expect(d.principalId).toBe(AUTH_FIXTURE_DID);
1955
+ expect(d.subjectId).toBe(AUTH_FIXTURE_DID);
1956
+ });
1957
+
1958
+ test('auth-token-rejects-foreign-signature', async () => {
1959
+ const fixture = authTokenFixtures.find(
1960
+ (f) => f.name === 'auth-token-rejects-foreign-signature',
1961
+ )!;
1962
+ handled.add(fixture.name);
1963
+ seedFixtureNonce();
1964
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1965
+ body: fixture.requestBody,
1966
+ });
1967
+ expect(status).toBe(fixture.responseStatus);
1968
+ expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
1969
+ });
1970
+
1971
+ test('auth-token-rejects-expired-nonce', async () => {
1972
+ const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-rejects-expired-nonce')!;
1973
+ handled.add(fixture.name);
1974
+ seedFixtureNonce(-1000); // already expired
1975
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1976
+ body: fixture.requestBody,
1977
+ });
1978
+ expect(status).toBe(fixture.responseStatus);
1979
+ expect((data as { error: { code: string } }).error.code).toBe('expired_nonce');
1980
+ });
1981
+
1982
+ test('auth-token-rejects-nonce-issued-to-another-did', async () => {
1983
+ const fixture = authTokenFixtures.find(
1984
+ (f) => f.name === 'auth-token-rejects-nonce-issued-to-another-did',
1985
+ )!;
1986
+ handled.add(fixture.name);
1987
+ // Nonce is bound to AUTH_FIXTURE_DID; the request signs as a different,
1988
+ // valid DID with its own valid signature — still refused, since the
1989
+ // nonce belongs to the DID it was issued for.
1990
+ seedFixtureNonce();
1991
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
1992
+ body: fixture.requestBody,
1993
+ });
1994
+ expect(status).toBe(fixture.responseStatus);
1995
+ expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
1996
+ });
1997
+
1998
+ test('auth-token-rejects-unknown-nonce', async () => {
1999
+ const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-rejects-unknown-nonce')!;
2000
+ handled.add(fixture.name);
2001
+ // No seeding — this fixture's nonce was never issued.
2002
+ const { status, data } = await req(t.app, fixture.method, fixture.path, {
2003
+ body: fixture.requestBody,
2004
+ });
2005
+ expect(status).toBe(fixture.responseStatus);
2006
+ expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
2007
+ });
2008
+
2009
+ test('auth-nonce-is-single-use', async () => {
2010
+ const sequence = authSequenceFixtures.find((f) => f.name === 'auth-nonce-is-single-use')!;
2011
+ handled.add(sequence.name);
2012
+ seedFixtureNonce();
2013
+ for (const step of sequence.steps) {
2014
+ const { status, data } = await req(t.app, step.method, step.path, {
2015
+ body: step.requestBody,
2016
+ });
2017
+ expect(status).toBe(step.responseStatus);
2018
+ const expected = step.responseBody as { error?: { code: string } } | undefined;
2019
+ if (expected?.error) {
2020
+ expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
2021
+ }
2022
+ }
2023
+ });
2024
+
2025
+ // Last in the block: `handled` is only complete once every test above has
2026
+ // run, and the sequence fixtures are dispatched at the end.
2027
+ test('coverage: authChallengeFixtures + authTokenFixtures + authSequenceFixtures', () => {
2028
+ assertCoverage(
2029
+ [...authChallengeFixtures, ...authTokenFixtures, ...authSequenceFixtures].map((f) => f.name),
2030
+ handled,
2031
+ new Set(),
2032
+ );
2033
+ });
2034
+ });
2035
+
2036
+ // -------------------------------------------------------
2037
+ // Records: the unlisted change-set key
2038
+ // -------------------------------------------------------
2039
+
2040
+ describe('unlisted change fixtures', () => {
2041
+ const handled = new Set<string>();
2042
+
2043
+ // Run together against one seeded record, same as the versions block
2044
+ // below — set-unlisted-false-relists assumes set-unlisted-true already
2045
+ // ran (its own description says so).
2046
+ test('set-unlisted-true then set-unlisted-false-relists', async () => {
2047
+ const trueFixture = unlistedChangeFixtures.find((f) => f.name === 'set-unlisted-true')!;
2048
+ const falseFixture = unlistedChangeFixtures.find(
2049
+ (f) => f.name === 'set-unlisted-false-relists',
2050
+ )!;
2051
+ handled.add(trueFixture.name);
2052
+ handled.add(falseFixture.name);
2053
+
2054
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' });
2055
+
2056
+ const unlisted = await req(t.app, 'PATCH', `/records/${record.id}`, {
2057
+ token: TEST_TOKEN,
2058
+ body: trueFixture.requestBody,
2059
+ });
2060
+ expect(unlisted.status).toBe(trueFixture.responseStatus);
2061
+ expect((unlisted.data as WireRecord).unlistedAt).toBeDefined();
2062
+
2063
+ // Excluded from an unfiltered query, same posture as a soft-deleted record.
2064
+ const queried = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
2065
+ expect(
2066
+ (queried.data as { records: WireRecord[] }).records.some((r) => r.id === record.id),
2067
+ ).toBe(false);
2068
+
2069
+ const relisted = await req(t.app, 'PATCH', `/records/${record.id}`, {
2070
+ token: TEST_TOKEN,
2071
+ body: falseFixture.requestBody,
2072
+ });
2073
+ expect(relisted.status).toBe(falseFixture.responseStatus);
2074
+ expect((relisted.data as WireRecord).unlistedAt).toBeUndefined();
2075
+ });
2076
+
2077
+ test('coverage', () => {
2078
+ assertCoverage(
2079
+ unlistedChangeFixtures.map((f) => f.name),
2080
+ handled,
2081
+ new Set(),
2082
+ );
2083
+ });
2084
+ });
2085
+
2086
+ // -------------------------------------------------------
2087
+ // Records: the parentId change-set key
2088
+ // -------------------------------------------------------
2089
+
2090
+ describe('parent change fixtures', () => {
2091
+ const handled = new Set<string>();
2092
+
2093
+ // Run together against one seeded record: the null fixture moves a
2094
+ // record back to the root, which only means anything once something
2095
+ // has moved it off the root.
2096
+ test('set-parent-moves-a-record-into-a-container then back to the root', async () => {
2097
+ const intoFixture = parentChangeFixtures.find(
2098
+ (f) => f.name === 'set-parent-moves-a-record-into-a-container',
2099
+ )!;
2100
+ const rootFixture = parentChangeFixtures.find(
2101
+ (f) => f.name === 'set-parent-null-moves-a-record-to-the-root',
2102
+ )!;
2103
+ handled.add(intoFixture.name);
2104
+ handled.add(rootFixture.name);
2105
+
2106
+ // The destination has to exist: a caller-named parentId is checked for
2107
+ // format and then for existence, so the fixture's own literal id would
2108
+ // answer 409 here rather than moving anything.
2109
+ const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' });
2110
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' });
2111
+
2112
+ const moved = await req(t.app, 'PATCH', `/records/${record.id}`, {
2113
+ token: TEST_TOKEN,
2114
+ body: { ...intoFixture.requestBody, parentId: container.id },
2115
+ });
2116
+ expect(moved.status).toBe(intoFixture.responseStatus);
2117
+ expect((moved.data as WireRecord).parentId).toBe(container.id);
2118
+ // A move touches containment and nothing else.
2119
+ expect((moved.data as WireRecord).content).toEqual({ title: 'Hello', body: 'World' });
2120
+
2121
+ const toRoot = await req(t.app, 'PATCH', `/records/${record.id}`, {
2122
+ token: TEST_TOKEN,
2123
+ body: rootFixture.requestBody,
2124
+ });
2125
+ expect(toRoot.status).toBe(rootFixture.responseStatus);
2126
+ // State spells the root by omission, where the input spelled it null.
2127
+ expect((toRoot.data as WireRecord).parentId).toBeUndefined();
2128
+ });
2129
+
2130
+ test('coverage', () => {
2131
+ assertCoverage(
2132
+ parentChangeFixtures.map((f) => f.name),
2133
+ handled,
2134
+ new Set(),
2135
+ );
2136
+ });
2137
+ });
2138
+
2139
+ // -------------------------------------------------------
2140
+ // Attachments: download
2141
+ // -------------------------------------------------------
2142
+
2143
+ describe('attachmentDownload fixtures', () => {
2144
+ const handled = new Set<string>();
2145
+
2146
+ function find(name: string) {
2147
+ const fixture = attachmentDownloadFixtures.find((f) => f.name === name)!;
2148
+ handled.add(name);
2149
+ return fixture;
2150
+ }
2151
+
2152
+ // Fixture paths embed the fixture authors' own fileIds — sha256 hashes of
2153
+ // bytes this harness never had. Like every other server-generated value
2154
+ // in this file (record ids, cursors), they're illustrative rather than
2155
+ // literal: each dispatch below uploads its own bytes for a real fileId
2156
+ // and checks the fixture's pinned response headers against that, rather
2157
+ // than replaying the fixture's path verbatim.
2158
+ async function uploadFile(mimeType: string): Promise<string> {
2159
+ const record = await t.ctx.stack.putAttachment(
2160
+ new TextEncoder().encode(`conformance-fixture-bytes:${mimeType}`),
2161
+ mimeType,
2162
+ );
2163
+ return (record.content as { fileId: string }).fileId;
2164
+ }
2165
+
2166
+ function queryFrom(path: string): string {
2167
+ const i = path.indexOf('?');
2168
+ return i === -1 ? '' : path.slice(i);
2169
+ }
2170
+
2171
+ async function dispatch(
2172
+ fixture: (typeof attachmentDownloadFixtures)[number],
2173
+ fileId: string,
2174
+ ): Promise<void> {
2175
+ const res = await t.app.request(`/attachments/${fileId}${queryFrom(fixture.path)}`, {
2176
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
2177
+ });
2178
+ expect(res.status).toBe(200);
2179
+ for (const [name, value] of Object.entries(fixture.responseHeaders)) {
2180
+ expect(res.headers.get(name)).toBe(value);
2181
+ }
2182
+ }
2183
+
2184
+ test('attachment-download-contenttype-param-safe-passes-through', async () => {
2185
+ const fixture = find('attachment-download-contenttype-param-safe-passes-through');
2186
+ await dispatch(fixture, await uploadFile('application/octet-stream'));
2187
+ });
2188
+
2189
+ test('attachment-download-contenttype-param-dangerous-forced', async () => {
2190
+ const fixture = find('attachment-download-contenttype-param-dangerous-forced');
2191
+ await dispatch(fixture, await uploadFile('application/octet-stream'));
2192
+ });
2193
+
2194
+ test('attachment-download-filename-extension-safe-passes-through', async () => {
2195
+ const fixture = find('attachment-download-filename-extension-safe-passes-through');
2196
+ await dispatch(fixture, await uploadFile('application/octet-stream'));
2197
+ });
2198
+
2199
+ test('attachment-download-filename-extension-dangerous-forced', async () => {
2200
+ const fixture = find('attachment-download-filename-extension-dangerous-forced');
2201
+ await dispatch(fixture, await uploadFile('application/octet-stream'));
2202
+ });
2203
+
2204
+ test('attachment-download-stored-mimetype-safe-passes-through', async () => {
2205
+ const fixture = find('attachment-download-stored-mimetype-safe-passes-through');
2206
+ await dispatch(fixture, await uploadFile('image/png'));
2207
+ });
2208
+
2209
+ test('attachment-download-stored-mimetype-dangerous-forced', async () => {
2210
+ const fixture = find('attachment-download-stored-mimetype-dangerous-forced');
2211
+ await dispatch(fixture, await uploadFile('text/html'));
2212
+ });
2213
+
2214
+ test('attachment-download-no-metadata-defaults-to-octet-stream', async () => {
2215
+ const fixture = find('attachment-download-no-metadata-defaults-to-octet-stream');
2216
+ // Raw bytes with no _attachment@1 record at all — bypasses
2217
+ // Stack.putAttachment (which creates the record atomically) by writing
2218
+ // straight through the adapter.
2219
+ const fileId = await t.ctx.adapter.putAttachment(new TextEncoder().encode('orphan-bytes'));
2220
+ await dispatch(fixture, fileId);
2221
+ });
2222
+
2223
+ test('coverage', () => {
2224
+ assertCoverage(
2225
+ attachmentDownloadFixtures.map((f) => f.name),
2226
+ handled,
2227
+ new Set(),
2228
+ );
2229
+ });
2230
+ });
2231
+
2232
+ // -------------------------------------------------------
2233
+ // Attachments: upload
2234
+ // -------------------------------------------------------
2235
+
2236
+ describe('attachmentUpload fixtures', () => {
2237
+ const handled = new Set<string>();
2238
+
2239
+ function find(name: string) {
2240
+ const fixture = attachmentUploadFixtures.find((f) => f.name === name)!;
2241
+ handled.add(name);
2242
+ return fixture;
2243
+ }
2244
+
2245
+ async function dispatch(
2246
+ fixture: (typeof attachmentUploadFixtures)[number],
2247
+ opts: { token?: string; app?: TestApp['app'] } = {},
2248
+ ): Promise<{ status: number; data: Record<string, unknown> }> {
2249
+ const path = fixture.appId
2250
+ ? `/attachments?appId=${encodeURIComponent(fixture.appId)}`
2251
+ : '/attachments';
2252
+ const headers: Record<string, string> = { ...fixture.requestHeaders };
2253
+ if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
2254
+ const res = await (opts.app ?? t.app).request(path, {
2255
+ method: 'POST',
2256
+ headers,
2257
+ body: new Uint8Array(fixture.requestBodyBytes),
2258
+ });
2259
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
2260
+ return { status: res.status, data };
2261
+ }
2262
+
2263
+ test('attachment-upload-creates-metadata-record', async () => {
2264
+ const fixture = find('attachment-upload-creates-metadata-record');
2265
+ const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
2266
+ expect(status).toBe(fixture.responseStatus);
2267
+ const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
2268
+ expect(data.content).toEqual(expected.content);
2269
+ expect(data.typeId).toBe('_attachment@1');
2270
+ expect(data.version).toBe(expected.version);
2271
+ });
2272
+
2273
+ test('attachment-upload-carries-appid-query-param', async () => {
2274
+ const fixture = find('attachment-upload-carries-appid-query-param');
2275
+ const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
2276
+ expect(status).toBe(fixture.responseStatus);
2277
+ const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
2278
+ expect(data.content).toEqual(expected.content);
2279
+ expect(data.appId).toBe(fixture.appId);
2280
+ expect(data.version).toBe(expected.version);
2281
+ });
2282
+
2283
+ test('attachment-upload-no-content-type-defaults-to-octet-stream', async () => {
2284
+ const fixture = find('attachment-upload-no-content-type-defaults-to-octet-stream');
2285
+ const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
2286
+ expect(status).toBe(fixture.responseStatus);
2287
+ const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
2288
+ expect(data.content).toEqual(expected.content);
2289
+ expect(data.version).toBe(expected.version);
2290
+ });
2291
+
2292
+ test('attachment-upload-non-owner-without-create-grant-forbidden', async () => {
2293
+ const fixture = find('attachment-upload-non-owner-without-create-grant-forbidden');
2294
+ const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
2295
+ const { status, data } = await dispatch(fixture, { token });
2296
+ expect(status).toBe(fixture.responseStatus);
2297
+ const expected = fixture.responseBody as { error: { code: string } };
2298
+ expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
2299
+ });
2300
+
2301
+ test('attachment-upload-payload-too-large', async () => {
2302
+ const fixture = find('attachment-upload-payload-too-large');
2303
+ // The fixture's own body doesn't exceed any real limit — per its own
2304
+ // description it stands in for one that does, pinning the error shape
2305
+ // rather than a specific size — so this dispatches against a server
2306
+ // configured with a ceiling below the fixture's body length instead.
2307
+ const smallConfig = { ...testConfig(t.dbPath), maxAttachmentBytes: 2 };
2308
+ const smallApp = createApp(t.ctx, smallConfig, logger);
2309
+ const { status, data } = await dispatch(fixture, { token: TEST_TOKEN, app: smallApp });
2310
+ expect(status).toBe(fixture.responseStatus);
2311
+ const expected = fixture.responseBody as { error: { code: string } };
2312
+ expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
2313
+ });
2314
+
2315
+ test('coverage', () => {
2316
+ assertCoverage(
2317
+ attachmentUploadFixtures.map((f) => f.name),
2318
+ handled,
2319
+ new Set(),
2320
+ );
2321
+ });
2322
+ });