@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,182 @@
1
+ /**
2
+ * POST /records/query's filter.relatedTo — the body-side counterpart to
3
+ * the relatedTo/relatedToEntity/relatedToNs/relatedToId/relatedToStack URL
4
+ * params covered in records.test.ts. Same scoped-target shape, carried as
5
+ * a JSON object instead of query params.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js';
9
+
10
+ const NOTE_TYPE_ID = 'com.example.test/note@1';
11
+
12
+ async function seedType(ctx: TestApp['ctx']) {
13
+ return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
14
+ body: { kind: 'text' as const, required: true as const },
15
+ });
16
+ }
17
+
18
+ async function seedRecord(ctx: TestApp['ctx'], overrides: Record<string, unknown> = {}) {
19
+ return ctx.stack.create(NOTE_TYPE_ID, { body: 'Hello world', ...overrides });
20
+ }
21
+
22
+ describe('POST /records/query filter.relatedTo', () => {
23
+ let t: TestApp;
24
+ beforeEach(async () => {
25
+ t = await buildTestApp();
26
+ await seedType(t.ctx);
27
+ });
28
+ afterEach(async () => {
29
+ await t.cleanup();
30
+ });
31
+
32
+ it('matches a record-scope target, narrowed further by label', async () => {
33
+ const target = await seedRecord(t.ctx, { body: 'target' });
34
+ const child = await t.ctx.stack.create(
35
+ NOTE_TYPE_ID,
36
+ { body: 'child' },
37
+ {
38
+ associations: [
39
+ {
40
+ kind: 'relationship',
41
+ label: 'child',
42
+ target: { scope: 'record', recordId: target.id },
43
+ },
44
+ ],
45
+ },
46
+ );
47
+
48
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
49
+ token: TEST_TOKEN,
50
+ body: { filter: { relatedTo: { target: { scope: 'record', recordId: target.id } } } },
51
+ });
52
+ expect(status).toBe(200);
53
+ expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([
54
+ child.id,
55
+ ]);
56
+ });
57
+
58
+ it('matches an entity-scope target', async () => {
59
+ const child = await t.ctx.stack.create(
60
+ NOTE_TYPE_ID,
61
+ { body: 'child' },
62
+ {
63
+ associations: [
64
+ {
65
+ kind: 'relationship',
66
+ label: 'author',
67
+ target: { scope: 'entity', entityId: OTHER_ENTITY_ID },
68
+ },
69
+ ],
70
+ },
71
+ );
72
+
73
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
74
+ token: TEST_TOKEN,
75
+ body: {
76
+ filter: { relatedTo: { target: { scope: 'entity', entityId: OTHER_ENTITY_ID } } },
77
+ },
78
+ });
79
+ expect(status).toBe(200);
80
+ expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([
81
+ child.id,
82
+ ]);
83
+ });
84
+
85
+ it('matches an external-scope target, and the whole namespace when id is omitted', async () => {
86
+ const syndicated = await t.ctx.stack.create(
87
+ NOTE_TYPE_ID,
88
+ { body: 'syndicated' },
89
+ {
90
+ associations: [
91
+ {
92
+ kind: 'relationship',
93
+ label: 'syndicated-to',
94
+ target: {
95
+ scope: 'external',
96
+ ns: 'atproto',
97
+ id: 'at://did:plc:abc/app.bsky.feed.post/1',
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ );
103
+
104
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
105
+ token: TEST_TOKEN,
106
+ body: { filter: { relatedTo: { target: { scope: 'external', ns: 'atproto' } } } },
107
+ });
108
+ expect(status).toBe(200);
109
+ expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([
110
+ syndicated.id,
111
+ ]);
112
+ });
113
+
114
+ it('label alone (no target) is valid and matches every target under it', async () => {
115
+ const target = await seedRecord(t.ctx, { body: 'target' });
116
+ const child = await t.ctx.stack.create(
117
+ NOTE_TYPE_ID,
118
+ { body: 'child' },
119
+ {
120
+ associations: [
121
+ {
122
+ kind: 'relationship',
123
+ label: 'reply-to',
124
+ target: { scope: 'record', recordId: target.id },
125
+ },
126
+ ],
127
+ },
128
+ );
129
+
130
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
131
+ token: TEST_TOKEN,
132
+ body: { filter: { relatedTo: { label: 'reply-to' } } },
133
+ });
134
+ expect(status).toBe(200);
135
+ expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([
136
+ child.id,
137
+ ]);
138
+ });
139
+
140
+ it('rejects filter.relatedTo naming neither a label nor a target with 400', async () => {
141
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
142
+ token: TEST_TOKEN,
143
+ body: { filter: { relatedTo: {} } },
144
+ });
145
+ expect(status).toBe(400);
146
+ expect((data as { error: { code: string } }).error.code).toBe('bad_request');
147
+ });
148
+
149
+ // An unrecognized target scope is a parsing rejection, pinned once at the
150
+ // unit level in @haverstack/core's wire-request.test.ts rather than here
151
+ // — see server#114.
152
+
153
+ it('rejects a record target with an empty recordId with 400', async () => {
154
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
155
+ token: TEST_TOKEN,
156
+ body: { filter: { relatedTo: { target: { scope: 'record', recordId: '' } } } },
157
+ });
158
+ expect(status).toBe(400);
159
+ expect((data as { error: { code: string } }).error.code).toBe('bad_request');
160
+ });
161
+
162
+ it('rejects a record target with an empty stackUrl with 400', async () => {
163
+ const target = await seedRecord(t.ctx, { body: 'target' });
164
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
165
+ token: TEST_TOKEN,
166
+ body: {
167
+ filter: { relatedTo: { target: { scope: 'record', recordId: target.id, stackUrl: '' } } },
168
+ },
169
+ });
170
+ expect(status).toBe(400);
171
+ expect((data as { error: { code: string } }).error.code).toBe('bad_request');
172
+ });
173
+
174
+ it('rejects an external target with an empty id with 400', async () => {
175
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
176
+ token: TEST_TOKEN,
177
+ body: { filter: { relatedTo: { target: { scope: 'external', ns: 'atproto', id: '' } } } },
178
+ });
179
+ expect(status).toBe(400);
180
+ expect((data as { error: { code: string } }).error.code).toBe('bad_request');
181
+ });
182
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js';
3
+
4
+ describe('POST /tokens', () => {
5
+ let t: TestApp;
6
+ beforeEach(async () => {
7
+ t = await buildTestApp();
8
+ });
9
+ afterEach(async () => {
10
+ await t.cleanup();
11
+ });
12
+
13
+ it('owner can create a token and receives the token value', async () => {
14
+ const { status, data } = await req(t.app, 'POST', '/tokens', {
15
+ token: TEST_TOKEN,
16
+ body: { entityId: OTHER_ENTITY_ID, label: 'test-label' },
17
+ });
18
+ expect(status).toBe(201);
19
+ const d = data as Record<string, unknown>;
20
+ expect(typeof d.id).toBe('string');
21
+ expect(typeof d.token).toBe('string');
22
+ expect(d.principalId).toBe(OTHER_ENTITY_ID);
23
+ expect(d.subjectId).toBe(OTHER_ENTITY_ID);
24
+ expect(d.label).toBe('test-label');
25
+ });
26
+
27
+ it('owner can assert a delegation via onBehalfOf', async () => {
28
+ const subjectId = 'did:key:subject-entity-id-00000003';
29
+ const { status, data } = await req(t.app, 'POST', '/tokens', {
30
+ token: TEST_TOKEN,
31
+ body: { entityId: OTHER_ENTITY_ID, onBehalfOf: subjectId },
32
+ });
33
+ expect(status).toBe(201);
34
+ const d = data as Record<string, unknown>;
35
+ expect(d.principalId).toBe(OTHER_ENTITY_ID);
36
+ expect(d.subjectId).toBe(subjectId);
37
+
38
+ const session = await t.ctx.adapter.lookupToken(d.token as string);
39
+ expect(session).toEqual({ principalId: OTHER_ENTITY_ID, subjectId });
40
+ });
41
+
42
+ it('returns 403 for a non-owner authenticated entity', async () => {
43
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
44
+ const { status } = await req(t.app, 'POST', '/tokens', {
45
+ token,
46
+ body: {},
47
+ });
48
+ expect(status).toBe(403);
49
+ });
50
+
51
+ it('returns 401 for an unauthenticated request', async () => {
52
+ const { status } = await req(t.app, 'POST', '/tokens', { body: {} });
53
+ expect(status).toBe(401);
54
+ });
55
+
56
+ it('rejects a non-DID entityId with 422', async () => {
57
+ const { status, data } = await req(t.app, 'POST', '/tokens', {
58
+ token: TEST_TOKEN,
59
+ body: { entityId: 'not-a-did' },
60
+ });
61
+ expect(status).toBe(422);
62
+ const body = data as { error: { code: string; details: Array<{ path: string }> } };
63
+ expect(body.error.code).toBe('validation');
64
+ expect(body.error.details.some((d) => d.path === 'entityId')).toBe(true);
65
+ });
66
+
67
+ it('rejects a non-DID onBehalfOf with 422', async () => {
68
+ const { status, data } = await req(t.app, 'POST', '/tokens', {
69
+ token: TEST_TOKEN,
70
+ body: { entityId: OTHER_ENTITY_ID, onBehalfOf: 'not-a-did' },
71
+ });
72
+ expect(status).toBe(422);
73
+ const body = data as { error: { code: string; details: Array<{ path: string }> } };
74
+ expect(body.error.code).toBe('validation');
75
+ expect(body.error.details.some((d) => d.path === 'onBehalfOf')).toBe(true);
76
+ });
77
+
78
+ it('reports the stored createdAt, matching what GET /tokens later reports', async () => {
79
+ const { data } = await req(t.app, 'POST', '/tokens', {
80
+ token: TEST_TOKEN,
81
+ body: { entityId: OTHER_ENTITY_ID },
82
+ });
83
+ const created = data as { id: string; createdAt: string };
84
+
85
+ const { data: listed } = await req(t.app, 'GET', '/tokens', { token: TEST_TOKEN });
86
+ const row = (listed as { tokens: Array<{ id: string; createdAt: string }> }).tokens.find(
87
+ (tok) => tok.id === created.id,
88
+ );
89
+ expect(row?.createdAt).toBe(created.createdAt);
90
+ });
91
+ });
92
+
93
+ describe('GET /tokens', () => {
94
+ let t: TestApp;
95
+ beforeEach(async () => {
96
+ t = await buildTestApp();
97
+ });
98
+ afterEach(async () => {
99
+ await t.cleanup();
100
+ });
101
+
102
+ it('owner can list tokens without token values exposed', async () => {
103
+ await t.ctx.adapter.createToken(OTHER_ENTITY_ID, { label: 'tok-a' });
104
+ await t.ctx.adapter.createToken(OTHER_ENTITY_ID, { label: 'tok-b' });
105
+
106
+ const { status, data } = await req(t.app, 'GET', '/tokens', { token: TEST_TOKEN });
107
+ expect(status).toBe(200);
108
+ const d = data as { tokens: Array<Record<string, unknown>> };
109
+ expect(d.tokens).toHaveLength(2);
110
+ for (const t of d.tokens) {
111
+ expect(t.token).toBeUndefined();
112
+ expect(typeof t.id).toBe('string');
113
+ }
114
+ });
115
+
116
+ it('returns 403 for a non-owner authenticated entity', async () => {
117
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
118
+ const { status } = await req(t.app, 'GET', '/tokens', { token });
119
+ expect(status).toBe(403);
120
+ });
121
+
122
+ it('returns 401 for an unauthenticated request', async () => {
123
+ const { status } = await req(t.app, 'GET', '/tokens');
124
+ expect(status).toBe(401);
125
+ });
126
+ });
127
+
128
+ describe('DELETE /tokens/:id', () => {
129
+ let t: TestApp;
130
+ beforeEach(async () => {
131
+ t = await buildTestApp();
132
+ });
133
+ afterEach(async () => {
134
+ await t.cleanup();
135
+ });
136
+
137
+ it('owner can revoke a token', async () => {
138
+ const { id } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
139
+ const { status } = await req(t.app, 'DELETE', `/tokens/${id}`, { token: TEST_TOKEN });
140
+ expect(status).toBe(204);
141
+
142
+ const tokens = await t.ctx.adapter.listTokens();
143
+ expect(tokens.find((tok) => tok.id === id)).toBeUndefined();
144
+ });
145
+
146
+ it('returns 403 for a non-owner authenticated entity', async () => {
147
+ const { id, token: otherToken } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
148
+ const { status } = await req(t.app, 'DELETE', `/tokens/${id}`, { token: otherToken });
149
+ expect(status).toBe(403);
150
+ });
151
+
152
+ it('returns 401 for an unauthenticated request', async () => {
153
+ const { id } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
154
+ const { status } = await req(t.app, 'DELETE', `/tokens/${id}`);
155
+ expect(status).toBe(401);
156
+ });
157
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Soft-delete tombstone projection and mutation refusal. The behavior is
3
+ * core's, pinned at this server's HTTP surface rather than assumed to
4
+ * survive the trip through it.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js';
8
+ import { openChangeFeed } from '../changeFeedClient.js';
9
+
10
+ const NOTE_TYPE = 'com.example.test/note@1';
11
+
12
+ async function seedNoteType(ctx: TestApp['ctx']) {
13
+ await ctx.stack.defineType(NOTE_TYPE, 'Note', {
14
+ title: { kind: 'string' },
15
+ body: { kind: 'text' },
16
+ });
17
+ }
18
+
19
+ describe('tombstone projection and mutation refusal', () => {
20
+ let t: TestApp;
21
+ beforeEach(async () => {
22
+ t = await buildTestApp();
23
+ await seedNoteType(t.ctx);
24
+ });
25
+ afterEach(async () => {
26
+ await t.cleanup();
27
+ });
28
+
29
+ it('GET on a soft-deleted record projects a tombstone: content emptied, deletedAt set', async () => {
30
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
31
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
32
+ const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, {
33
+ token: TEST_TOKEN,
34
+ });
35
+ expect(status).toBe(200);
36
+ const d = data as { content: unknown; deletedAt?: string };
37
+ expect(d.content).toEqual({});
38
+ expect(typeof d.deletedAt).toBe('string');
39
+ });
40
+
41
+ it('query({ includeDeleted: true }) surfaces the same tombstone projection', async () => {
42
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'y' });
43
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
44
+ const { status, data } = await req(t.app, 'POST', '/records/query', {
45
+ token: TEST_TOKEN,
46
+ body: { filter: { typeId: NOTE_TYPE, includeDeleted: true } },
47
+ });
48
+ expect(status).toBe(200);
49
+ const d = data as { records: Array<{ id: string; content: unknown; deletedAt?: string }> };
50
+ const found = d.records.find((r) => r.id === record.id)!;
51
+ expect(found.content).toEqual({});
52
+ expect(typeof found.deletedAt).toBe('string');
53
+ });
54
+
55
+ it('the change feed carries the same tombstone projection on delete', async () => {
56
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
57
+ const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN });
58
+ try {
59
+ await conn.waitForFrames(1); // ready
60
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
61
+ const [, frame] = await conn.waitForFrames(2);
62
+ const data = frame.data as unknown as {
63
+ record: { content: unknown; deletedAt?: string };
64
+ };
65
+ expect(data.record.content).toEqual({});
66
+ expect(typeof data.record.deletedAt).toBe('string');
67
+ } finally {
68
+ await conn.close();
69
+ }
70
+ });
71
+
72
+ it('mutating a soft-deleted record is refused with 409, not applied to the tombstone', async () => {
73
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'z' });
74
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
75
+ const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
76
+ token: TEST_TOKEN,
77
+ body: { contentPatch: { title: 'edited' } },
78
+ });
79
+ expect(status).toBe(409);
80
+ expect((data as { error: { code: string } }).error.code).toBe('conflict');
81
+ });
82
+
83
+ it('version history is exempt from the tombstone projection and still serves content', async () => {
84
+ const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'w' });
85
+ await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
86
+ const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions`, {
87
+ token: TEST_TOKEN,
88
+ });
89
+ expect(status).toBe(200);
90
+ const versions = data as Array<{ content: Record<string, unknown> }>;
91
+ expect(versions[0]!.content).toEqual({ title: 'w' });
92
+ });
93
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { hashSchema } from '@haverstack/core';
3
+ import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js';
4
+
5
+ describe('Types', () => {
6
+ let t: TestApp;
7
+ beforeEach(async () => {
8
+ t = await buildTestApp();
9
+ });
10
+ afterEach(async () => {
11
+ await t.cleanup();
12
+ });
13
+
14
+ const typeId = 'com.example.test/item@1';
15
+ const schema = { name: { kind: 'string' as const, required: true as const } };
16
+
17
+ describe('POST /types', () => {
18
+ it('registers a type as owner', async () => {
19
+ const schemaHash = await hashSchema(schema);
20
+ const { status, data } = await req(t.app, 'POST', '/types', {
21
+ token: TEST_TOKEN,
22
+ body: {
23
+ id: typeId,
24
+ baseId: 'com.example.test/item',
25
+ version: 1,
26
+ name: 'Item',
27
+ schema,
28
+ schemaHash,
29
+ createdAt: new Date().toISOString(),
30
+ },
31
+ });
32
+ expect(status).toBe(201);
33
+ expect((data as Record<string, unknown>).id).toBe(typeId);
34
+ });
35
+
36
+ it('returns 401 without auth', async () => {
37
+ const { status } = await req(t.app, 'POST', '/types', {
38
+ body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' },
39
+ });
40
+ expect(status).toBe(401);
41
+ });
42
+
43
+ it('returns 403 for a non-owner entity', async () => {
44
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
45
+ const { status } = await req(t.app, 'POST', '/types', {
46
+ token,
47
+ body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' },
48
+ });
49
+ expect(status).toBe(403);
50
+ });
51
+
52
+ it('re-POSTing an identical schema is a no-op that preserves createdAt', async () => {
53
+ const schemaHash = await hashSchema(schema);
54
+ const first = await req(t.app, 'POST', '/types', {
55
+ token: TEST_TOKEN,
56
+ body: { id: typeId, name: 'Item', schema, schemaHash },
57
+ });
58
+ expect(first.status).toBe(201);
59
+ const createdAt = (first.data as Record<string, unknown>).createdAt;
60
+
61
+ const second = await req(t.app, 'POST', '/types', {
62
+ token: TEST_TOKEN,
63
+ body: { id: typeId, name: 'Item', schema, schemaHash },
64
+ });
65
+ expect(second.status).toBe(201);
66
+ expect((second.data as Record<string, unknown>).createdAt).toBe(createdAt);
67
+ });
68
+
69
+ it('re-POSTing an additive-legal schema change is accepted', async () => {
70
+ const schemaHash = await hashSchema(schema);
71
+ await req(t.app, 'POST', '/types', {
72
+ token: TEST_TOKEN,
73
+ body: { id: typeId, name: 'Item', schema, schemaHash },
74
+ });
75
+
76
+ const widened = {
77
+ ...schema,
78
+ note: { kind: 'string' as const, required: false as const },
79
+ };
80
+ const widenedHash = await hashSchema(widened);
81
+ const { status, data } = await req(t.app, 'POST', '/types', {
82
+ token: TEST_TOKEN,
83
+ body: { id: typeId, name: 'Item', schema: widened, schemaHash: widenedHash },
84
+ });
85
+ expect(status).toBe(201);
86
+ expect((data as Record<string, unknown>).schema).toEqual(widened);
87
+ });
88
+
89
+ it('re-POSTing a non-additive schema change returns 409 schema_drift', async () => {
90
+ const schemaHash = await hashSchema(schema);
91
+ await req(t.app, 'POST', '/types', {
92
+ token: TEST_TOKEN,
93
+ body: { id: typeId, name: 'Item', schema, schemaHash },
94
+ });
95
+
96
+ const incompatible = { name: { kind: 'number' as const, required: true as const } };
97
+ const incompatibleHash = await hashSchema(incompatible);
98
+ const { status, data } = await req(t.app, 'POST', '/types', {
99
+ token: TEST_TOKEN,
100
+ body: { id: typeId, name: 'Item', schema: incompatible, schemaHash: incompatibleHash },
101
+ });
102
+ expect(status).toBe(409);
103
+ expect((data as { error: { code: string } }).error.code).toBe('schema_drift');
104
+ });
105
+ });
106
+
107
+ describe('GET /types', () => {
108
+ it('returns all registered types (no auth required)', async () => {
109
+ await t.ctx.stack.defineType(typeId, 'Item', schema);
110
+ const { status, data } = await req(t.app, 'GET', '/types');
111
+ expect(status).toBe(200);
112
+ expect((data as unknown[]).length).toBeGreaterThanOrEqual(1);
113
+ });
114
+ });
115
+
116
+ describe('GET /types/:id', () => {
117
+ it('returns one type (URL-encoded, no auth required)', async () => {
118
+ await t.ctx.stack.defineType(typeId, 'Item', schema);
119
+ const { status, data } = await req(t.app, 'GET', `/types/${encodeURIComponent(typeId)}`);
120
+ expect(status).toBe(200);
121
+ expect((data as Record<string, unknown>).id).toBe(typeId);
122
+ });
123
+
124
+ it('returns 404 for unknown type', async () => {
125
+ const { status } = await req(t.app, 'GET', '/types/unknown%40999');
126
+ expect(status).toBe(404);
127
+ });
128
+ });
129
+ });