@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,170 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ buildTestApp,
4
+ req,
5
+ TEST_TOKEN,
6
+ TEST_ENTITY_ID,
7
+ OTHER_ENTITY_ID,
8
+ type TestApp,
9
+ } from '../setup.js';
10
+ import type { StackRecord } from '@haverstack/core';
11
+
12
+ async function seedEntityRecord(ctx: TestApp['ctx']): Promise<StackRecord> {
13
+ return ctx.adapter.createRecord({
14
+ id: TEST_ENTITY_ID,
15
+ typeId: '_entity@1',
16
+ content: { did: TEST_ENTITY_ID, name: 'Test Entity' },
17
+ createdAt: new Date(),
18
+ updatedAt: new Date(),
19
+ version: 1,
20
+ entityId: TEST_ENTITY_ID,
21
+ });
22
+ }
23
+
24
+ // _entity@1 records get an auto-generated id — even the owner's own card
25
+ // (see Stack.create()'s ownerProfile bootstrap) — with the binding held in
26
+ // content.did rather than the record id. This is the realistic shape.
27
+ async function seedEntityRecordWithGeneratedId(ctx: TestApp['ctx']): Promise<StackRecord> {
28
+ return ctx.stack.create('_entity@1', { did: TEST_ENTITY_ID, name: 'Test Entity' });
29
+ }
30
+
31
+ describe('GET /entity', () => {
32
+ let t: TestApp;
33
+ beforeEach(async () => {
34
+ t = await buildTestApp();
35
+ });
36
+ afterEach(async () => {
37
+ await t.cleanup();
38
+ });
39
+
40
+ it('returns the owner entity record when authenticated as owner', async () => {
41
+ await seedEntityRecord(t.ctx);
42
+ const { status, data } = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
43
+ expect(status).toBe(200);
44
+ expect((data as Record<string, unknown>).id).toBe(TEST_ENTITY_ID);
45
+ expect(((data as Record<string, unknown>).content as Record<string, unknown>).name).toBe(
46
+ 'Test Entity',
47
+ );
48
+ });
49
+
50
+ it('returns 404 when the entity record does not exist', async () => {
51
+ const { status } = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
52
+ expect(status).toBe(404);
53
+ });
54
+
55
+ it('finds the owner record by content.did even when its id differs from ownerEntityId', async () => {
56
+ await seedEntityRecordWithGeneratedId(t.ctx);
57
+ const { status, data } = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
58
+ expect(status).toBe(200);
59
+ const content = (data as Record<string, unknown>).content as Record<string, unknown>;
60
+ expect(content.did).toBe(TEST_ENTITY_ID);
61
+ expect(content.name).toBe('Test Entity');
62
+ });
63
+
64
+ it('returns 404, not 403, for a non-owner authenticated entity (anti-oracle rule)', async () => {
65
+ await seedEntityRecord(t.ctx);
66
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
67
+ const { status } = await req(t.app, 'GET', '/entity', { token });
68
+ expect(status).toBe(404);
69
+ });
70
+
71
+ it('returns 401 for an unauthenticated request', async () => {
72
+ const { status } = await req(t.app, 'GET', '/entity');
73
+ expect(status).toBe(401);
74
+ });
75
+
76
+ it('resolves the current owner record on each request, including after delete and recreate', async () => {
77
+ const first = await seedEntityRecordWithGeneratedId(t.ctx);
78
+
79
+ const populate = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
80
+ expect(populate.status).toBe(200);
81
+
82
+ await t.ctx.adapter.deleteRecord(first.id, { hard: true });
83
+ const afterDelete = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
84
+ expect(afterDelete.status).toBe(404);
85
+
86
+ const second = await seedEntityRecordWithGeneratedId(t.ctx);
87
+ expect(second.id).not.toBe(first.id);
88
+ const afterRecreate = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
89
+ expect(afterRecreate.status).toBe(200);
90
+ expect((afterRecreate.data as Record<string, unknown>).id).toBe(second.id);
91
+ });
92
+ });
93
+
94
+ describe('PATCH /entity', () => {
95
+ let t: TestApp;
96
+ beforeEach(async () => {
97
+ t = await buildTestApp();
98
+ });
99
+ afterEach(async () => {
100
+ await t.cleanup();
101
+ });
102
+
103
+ it('merges content and returns the updated record', async () => {
104
+ await seedEntityRecord(t.ctx);
105
+ const { status, data } = await req(t.app, 'PATCH', '/entity', {
106
+ token: TEST_TOKEN,
107
+ body: { content: { name: 'Updated Entity', handle: 'updated' } },
108
+ });
109
+ expect(status).toBe(200);
110
+ const content = (data as Record<string, unknown>).content as Record<string, unknown>;
111
+ expect(content.name).toBe('Updated Entity');
112
+ expect(content.handle).toBe('updated');
113
+ });
114
+
115
+ it('returns 404 when the entity record does not exist', async () => {
116
+ const { status } = await req(t.app, 'PATCH', '/entity', {
117
+ token: TEST_TOKEN,
118
+ body: { content: { name: 'X' } },
119
+ });
120
+ expect(status).toBe(404);
121
+ });
122
+
123
+ it('updates the owner record found by content.did even when its id differs from ownerEntityId', async () => {
124
+ await seedEntityRecordWithGeneratedId(t.ctx);
125
+ const { status, data } = await req(t.app, 'PATCH', '/entity', {
126
+ token: TEST_TOKEN,
127
+ body: { content: { name: 'Renamed' } },
128
+ });
129
+ expect(status).toBe(200);
130
+ const content = (data as Record<string, unknown>).content as Record<string, unknown>;
131
+ expect(content.name).toBe('Renamed');
132
+ expect(content.did).toBe(TEST_ENTITY_ID);
133
+ });
134
+
135
+ it('returns 403 for a non-owner authenticated entity', async () => {
136
+ await seedEntityRecord(t.ctx);
137
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
138
+ const { status } = await req(t.app, 'PATCH', '/entity', {
139
+ token,
140
+ body: { content: { name: 'Hacked' } },
141
+ });
142
+ expect(status).toBe(403);
143
+ });
144
+
145
+ it('returns 401 for an unauthenticated request', async () => {
146
+ const { status } = await req(t.app, 'PATCH', '/entity', {
147
+ body: { content: { name: 'X' } },
148
+ });
149
+ expect(status).toBe(401);
150
+ });
151
+
152
+ it('resolves the current owner record on each PATCH request after a since-deleted record', async () => {
153
+ const record = await seedEntityRecordWithGeneratedId(t.ctx);
154
+ await req(t.app, 'GET', '/entity', { token: TEST_TOKEN });
155
+
156
+ await t.ctx.adapter.deleteRecord(record.id, { hard: true });
157
+ const stale = await req(t.app, 'PATCH', '/entity', {
158
+ token: TEST_TOKEN,
159
+ body: { content: { name: 'X' } },
160
+ });
161
+ expect(stale.status).toBe(404);
162
+
163
+ await seedEntityRecordWithGeneratedId(t.ctx);
164
+ const recovered = await req(t.app, 'PATCH', '/entity', {
165
+ token: TEST_TOKEN,
166
+ body: { content: { name: 'Recovered' } },
167
+ });
168
+ expect(recovered.status).toBe(200);
169
+ });
170
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * _group-scoped permissions: a permission's groupId resolves only against
3
+ * a real _group Record's roster. Core's rule, pinned at this server's HTTP
4
+ * surface rather than assumed to arrive intact.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { SYSTEM_TYPES } from '@haverstack/core';
8
+ import { buildTestApp, req, OTHER_ENTITY_ID, type TestApp } from '../setup.js';
9
+
10
+ const NOTE_TYPE = 'com.example.test/note@1';
11
+ const GROUP_TYPE = `${SYSTEM_TYPES.GROUP}@1`;
12
+
13
+ describe('_group ACL', () => {
14
+ let t: TestApp;
15
+ beforeEach(async () => {
16
+ t = await buildTestApp();
17
+ await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } });
18
+ });
19
+ afterEach(async () => {
20
+ await t.cleanup();
21
+ });
22
+
23
+ it('an entity added to a real _group roster gains group-scoped read access', async () => {
24
+ const group = await t.ctx.stack.create(GROUP_TYPE, { name: 'Test Group' });
25
+ await t.ctx.stack.associate(group.id, {
26
+ kind: 'relationship',
27
+ label: 'member',
28
+ target: { scope: 'entity', entityId: OTHER_ENTITY_ID },
29
+ });
30
+ const record = await t.ctx.stack.create(
31
+ NOTE_TYPE,
32
+ { title: 'group-visible' },
33
+ { permissions: [{ access: 'group', groupId: group.id, read: true, write: false }] },
34
+ );
35
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
36
+ const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token });
37
+ expect(status).toBe(200);
38
+ });
39
+
40
+ it('a non-_group record with matching relationship associations is never treated as a roster', async () => {
41
+ // Same association shape a real group roster uses (kind: relationship,
42
+ // label: member, target: entity) — but on an ordinary Note, not a
43
+ // _group Record. Before the 0.20 fix, any record's relationship
44
+ // associations could serve as an ACL; this pins that it can't.
45
+ const notAGroup = await t.ctx.stack.create(
46
+ NOTE_TYPE,
47
+ { title: 'not a real group' },
48
+ {
49
+ associations: [
50
+ {
51
+ kind: 'relationship',
52
+ label: 'member',
53
+ target: { scope: 'entity', entityId: OTHER_ENTITY_ID },
54
+ },
55
+ ],
56
+ },
57
+ );
58
+ const record = await t.ctx.stack.create(
59
+ NOTE_TYPE,
60
+ { title: 'should stay private' },
61
+ { permissions: [{ access: 'group', groupId: notAGroup.id, read: true, write: false }] },
62
+ );
63
+ const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
64
+ const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token });
65
+ // Anti-oracle: unreadable is 404, same as any other record this entity
66
+ // can't reach — never a 403 that would confirm the record exists.
67
+ expect(status).toBe(404);
68
+ });
69
+ });