@haverstack/server 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +108 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +42 -17
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
@@ -1,621 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
- import {
4
- buildTestApp,
5
- req,
6
- testConfig,
7
- logger,
8
- TEST_TOKEN,
9
- TEST_ENTITY_ID,
10
- OTHER_ENTITY_ID,
11
- type TestApp,
12
- } from '../setup.js';
13
- import { createApp } from '../../src/app.js';
14
-
15
- const NOTE_TYPE_ID = 'com.example.test/note@1';
16
-
17
- async function seedType(ctx: TestApp['ctx']) {
18
- return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
19
- body: { kind: 'text' as const, required: true as const },
20
- });
21
- }
22
-
23
- /** Seeds bytes via the unscoped Stack (bypasses the wire route entirely). */
24
- async function putFile(ctx: TestApp['ctx'], content = 'hello') {
25
- const record = await ctx.stack.putAttachment(new TextEncoder().encode(content), 'text/plain');
26
- return (record.content as { fileId: string }).fileId;
27
- }
28
-
29
- function sha256Hex(bytes: Uint8Array): string {
30
- return createHash('sha256').update(bytes).digest('hex');
31
- }
32
-
33
- describe('GET /attachments/:fileId', () => {
34
- let t: TestApp;
35
- beforeEach(async () => {
36
- t = await buildTestApp();
37
- await seedType(t.ctx);
38
- });
39
- afterEach(async () => {
40
- await t.cleanup();
41
- });
42
-
43
- it('allows the owner to read an unattached file', async () => {
44
- const fileId = await putFile(t.ctx);
45
- const { status, data } = await req(t.app, 'GET', `/attachments/${fileId}`, {
46
- token: TEST_TOKEN,
47
- });
48
- expect(status).toBe(200);
49
- expect(data).toBe('hello');
50
- });
51
-
52
- it('rejects an anonymous request for an unattached file', async () => {
53
- const fileId = await putFile(t.ctx);
54
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`);
55
- expect(status).toBe(401);
56
- });
57
-
58
- it('rejects a non-owner authenticated request for an unattached file', async () => {
59
- const fileId = await putFile(t.ctx);
60
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
61
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token });
62
- expect(status).toBe(403);
63
- });
64
-
65
- it('allows anonymous access when the referencing record is public', async () => {
66
- const fileId = await putFile(t.ctx);
67
- await t.ctx.stack.create(
68
- NOTE_TYPE_ID,
69
- { body: 'public note' },
70
- {
71
- permissions: [{ access: 'public' }],
72
- associations: [{ kind: 'attachment', label: 'file', fileId }],
73
- },
74
- );
75
-
76
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`);
77
- expect(status).toBe(200);
78
- });
79
-
80
- it('rejects anonymous access when the referencing record is private', async () => {
81
- const fileId = await putFile(t.ctx);
82
- await t.ctx.stack.create(
83
- NOTE_TYPE_ID,
84
- { body: 'private note' },
85
- {
86
- associations: [{ kind: 'attachment', label: 'file', fileId }],
87
- },
88
- );
89
-
90
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`);
91
- expect(status).toBe(401);
92
- });
93
-
94
- it('grants access if ANY referencing record is accessible, not just the first', async () => {
95
- const fileId = await putFile(t.ctx);
96
- // Private record references the file first...
97
- await t.ctx.stack.create(
98
- NOTE_TYPE_ID,
99
- { body: 'private note' },
100
- {
101
- associations: [{ kind: 'attachment', label: 'file', fileId }],
102
- },
103
- );
104
- // ...a second, public record also references it.
105
- await t.ctx.stack.create(
106
- NOTE_TYPE_ID,
107
- { body: 'public note' },
108
- {
109
- permissions: [{ access: 'public' }],
110
- associations: [{ kind: 'attachment', label: 'file', fileId }],
111
- },
112
- );
113
-
114
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`);
115
- expect(status).toBe(200);
116
- });
117
-
118
- it('grants a non-owner entity access via an entity-scoped read grant', async () => {
119
- const fileId = await putFile(t.ctx);
120
- await t.ctx.stack.create(
121
- NOTE_TYPE_ID,
122
- { body: 'shared note' },
123
- {
124
- permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
125
- associations: [{ kind: 'attachment', label: 'file', fileId }],
126
- },
127
- );
128
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
129
-
130
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token });
131
- expect(status).toBe(200);
132
- });
133
-
134
- it('a readable unlisted record still conveys access to its attachment (core 0.18)', async () => {
135
- // ScopedStack.getAttachment() scans referencing records with
136
- // includeUnlisted itself — enumeration standing (unlisted) and read
137
- // access (permissions) are orthogonal, so an unlisted-but-shared
138
- // record still unlocks its attachment for a reader who holds a grant.
139
- const fileId = await putFile(t.ctx);
140
- await t.ctx.stack.create(
141
- NOTE_TYPE_ID,
142
- { body: 'unlisted but shared' },
143
- {
144
- unlisted: true,
145
- permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
146
- associations: [{ kind: 'attachment', label: 'file', fileId }],
147
- },
148
- );
149
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
150
-
151
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token });
152
- expect(status).toBe(200);
153
- });
154
-
155
- it('rejects a non-owner entity without a matching read grant', async () => {
156
- const fileId = await putFile(t.ctx);
157
- await t.ctx.stack.create(
158
- NOTE_TYPE_ID,
159
- { body: 'private note' },
160
- {
161
- associations: [{ kind: 'attachment', label: 'file', fileId }],
162
- },
163
- );
164
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
165
-
166
- const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token });
167
- expect(status).toBe(403);
168
- });
169
-
170
- it('gives an identical response for a missing file and an inaccessible file, for a non-owner', async () => {
171
- const fileId = await putFile(t.ctx);
172
- await t.ctx.stack.create(
173
- NOTE_TYPE_ID,
174
- { body: 'private note' },
175
- {
176
- associations: [{ kind: 'attachment', label: 'file', fileId }],
177
- },
178
- );
179
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
180
-
181
- const inaccessible = await req(t.app, 'GET', `/attachments/${fileId}`, { token });
182
- const missing = await req(
183
- t.app,
184
- 'GET',
185
- '/attachments/0000000000000000000000000000000000000000000000000000000000000000',
186
- { token },
187
- );
188
- expect(inaccessible.status).toBe(missing.status);
189
- expect(inaccessible.data).toEqual(missing.data);
190
- });
191
-
192
- it('uses ?contentType param as Content-Type without a metadata record', async () => {
193
- const fileId = await putFile(t.ctx);
194
- const res = await t.app.request(`/attachments/${fileId}?contentType=image/png`, {
195
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
196
- });
197
- expect(res.status).toBe(200);
198
- expect(res.headers.get('Content-Type')).toBe('image/png');
199
- });
200
-
201
- it('uses ?filename param in Content-Disposition without a metadata record', async () => {
202
- const fileId = await putFile(t.ctx);
203
- const res = await t.app.request(`/attachments/${fileId}?filename=photo.png`, {
204
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
205
- });
206
- expect(res.status).toBe(200);
207
- expect(res.headers.get('Content-Disposition')).toBe("attachment; filename*=UTF-8''photo.png");
208
- });
209
-
210
- it('infers Content-Type from ?filename extension when no ?contentType is given', async () => {
211
- const fileId = await putFile(t.ctx);
212
- const res = await t.app.request(`/attachments/${fileId}?filename=photo.png`, {
213
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
214
- });
215
- expect(res.status).toBe(200);
216
- expect(res.headers.get('Content-Type')).toBe('image/png');
217
- });
218
-
219
- it('sanitizes a blocked ?contentType param to application/octet-stream', async () => {
220
- const fileId = await putFile(t.ctx);
221
- const res = await t.app.request(`/attachments/${fileId}?contentType=text/html`, {
222
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
223
- });
224
- expect(res.status).toBe(200);
225
- expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
226
- });
227
-
228
- it('drops a dangerous ?filename from Content-Disposition once its extension forces the type', async () => {
229
- // The filename itself is what makes the type dangerous here — letting it
230
- // through unchanged would leave a browser a filename-based sniffing hint
231
- // even with Content-Type neutralized (core's AttachmentDownloadContentType.forced).
232
- const fileId = await putFile(t.ctx);
233
- const res = await t.app.request(`/attachments/${fileId}?filename=payload.html`, {
234
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
235
- });
236
- expect(res.status).toBe(200);
237
- expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
238
- expect(res.headers.get('Content-Disposition')).toBe('attachment');
239
- });
240
-
241
- it('always sets X-Content-Type-Options: nosniff', async () => {
242
- const fileId = await putFile(t.ctx);
243
- const res = await t.app.request(`/attachments/${fileId}`, {
244
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
245
- });
246
- expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
247
- });
248
-
249
- it('resolves Content-Type from the stored metadata record when no param is given', async () => {
250
- const fileId = await putFile(t.ctx, 'stored-type content');
251
- const res = await t.app.request(`/attachments/${fileId}`, {
252
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
253
- });
254
- expect(res.status).toBe(200);
255
- expect(res.headers.get('Content-Type')).toBe('text/plain');
256
- });
257
-
258
- it('falls back to the first-recorded filename when the requester has no own record', async () => {
259
- const bytes = new TextEncoder().encode('shared content');
260
- await t.ctx.stack.putAttachment(bytes, 'text/plain', 'first.txt');
261
- const second = await t.ctx.stack.putAttachment(bytes, 'text/plain', 'second.txt');
262
- const fileId = (second.content as { fileId: string }).fileId;
263
-
264
- const res = await t.app.request(`/attachments/${fileId}`, {
265
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
266
- });
267
- expect(res.status).toBe(200);
268
- expect(res.headers.get('Content-Disposition')).toBe("attachment; filename*=UTF-8''first.txt");
269
- });
270
-
271
- it("prefers the requester's own record for filename over the first-recorded record", async () => {
272
- const bytes = new TextEncoder().encode('shared content 2');
273
- await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: '_attachment@1' }]);
274
- const first = await t.ctx.stack
275
- .forSession({ principalId: OTHER_ENTITY_ID, subjectId: OTHER_ENTITY_ID })
276
- .putAttachment(bytes, 'text/plain', 'first.txt');
277
- const fileId = (first.content as { fileId: string }).fileId;
278
- await t.ctx.stack
279
- .forSession({ principalId: TEST_ENTITY_ID, subjectId: TEST_ENTITY_ID })
280
- .putAttachment(bytes, 'text/plain', 'mine.txt');
281
-
282
- const res = await t.app.request(`/attachments/${fileId}`, {
283
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
284
- });
285
- expect(res.status).toBe(200);
286
- expect(res.headers.get('Content-Type')).toBe('text/plain');
287
- expect(res.headers.get('Content-Disposition')).toBe("attachment; filename*=UTF-8''mine.txt");
288
- });
289
-
290
- it('forces a dangerous stored mimeType to application/octet-stream', async () => {
291
- const record = await t.ctx.stack.putAttachment(
292
- new TextEncoder().encode('<script>alert(1)</script>'),
293
- 'text/html',
294
- 'evil.html',
295
- );
296
- const fileId = (record.content as { fileId: string }).fileId;
297
-
298
- const res = await t.app.request(`/attachments/${fileId}`, {
299
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
300
- });
301
- expect(res.status).toBe(200);
302
- expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
303
- });
304
- });
305
-
306
- describe('POST /attachments', () => {
307
- let t: TestApp;
308
- beforeEach(async () => {
309
- t = await buildTestApp();
310
- await seedType(t.ctx);
311
- });
312
- afterEach(async () => {
313
- await t.cleanup();
314
- });
315
-
316
- it('stores bytes and creates the _attachment@1 record atomically, returning it', async () => {
317
- const bytes = new TextEncoder().encode('file content');
318
- const res = await t.app.request('/attachments', {
319
- method: 'POST',
320
- headers: {
321
- Authorization: `Bearer ${TEST_TOKEN}`,
322
- 'Content-Type': 'text/plain',
323
- },
324
- body: bytes,
325
- });
326
- expect(res.status).toBe(200);
327
- const record = (await res.json()) as Record<string, unknown>;
328
- expect(record.typeId).toBe('_attachment@1');
329
- const content = record.content as Record<string, unknown>;
330
- expect(content.fileId).toBe(sha256Hex(bytes));
331
- expect(content.mimeType).toBe('text/plain');
332
- expect(content.size).toBe(bytes.byteLength);
333
-
334
- const stored = await t.ctx.stack.query({
335
- filter: { typeId: '_attachment@1', content: { fileId: content.fileId } },
336
- });
337
- expect(stored.records).toHaveLength(1);
338
- });
339
-
340
- it('defaults mimeType to application/octet-stream when Content-Type is omitted', async () => {
341
- const res = await t.app.request('/attachments', {
342
- method: 'POST',
343
- headers: { Authorization: `Bearer ${TEST_TOKEN}` },
344
- body: new TextEncoder().encode('raw bytes'),
345
- });
346
- expect(res.status).toBe(200);
347
- const record = (await res.json()) as { content: Record<string, unknown> };
348
- expect(record.content.mimeType).toBe('application/octet-stream');
349
- });
350
-
351
- it('parses filename from the RFC 5987 Content-Disposition form', async () => {
352
- const res = await t.app.request('/attachments', {
353
- method: 'POST',
354
- headers: {
355
- Authorization: `Bearer ${TEST_TOKEN}`,
356
- 'Content-Type': 'text/plain',
357
- 'Content-Disposition': "attachment; filename*=UTF-8''caf%C3%A9.txt",
358
- },
359
- body: new TextEncoder().encode('content'),
360
- });
361
- expect(res.status).toBe(200);
362
- const record = (await res.json()) as { content: Record<string, unknown> };
363
- expect(record.content.filename).toBe('café.txt');
364
- });
365
-
366
- it('passes ?appId= through to the created record', async () => {
367
- const res = await t.app.request('/attachments?appId=my-app-id', {
368
- method: 'POST',
369
- headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
370
- body: new TextEncoder().encode('content'),
371
- });
372
- expect(res.status).toBe(200);
373
- const record = (await res.json()) as Record<string, unknown>;
374
- expect(record.appId).toBe('my-app-id');
375
- });
376
-
377
- it('rejects an anonymous upload with 401', async () => {
378
- const res = await t.app.request('/attachments', {
379
- method: 'POST',
380
- headers: { 'Content-Type': 'text/plain' },
381
- body: new TextEncoder().encode('content'),
382
- });
383
- expect(res.status).toBe(401);
384
- });
385
-
386
- it('rejects a token holder with no create grant on _attachment@1 with 403', async () => {
387
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
388
- const res = await t.app.request('/attachments', {
389
- method: 'POST',
390
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' },
391
- body: new TextEncoder().encode('content'),
392
- });
393
- expect(res.status).toBe(403);
394
- });
395
-
396
- it('allows an entity with a create grant on _attachment@1 to upload', async () => {
397
- await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: '_attachment@1' }]);
398
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
399
- const res = await t.app.request('/attachments', {
400
- method: 'POST',
401
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' },
402
- body: new TextEncoder().encode('content'),
403
- });
404
- expect(res.status).toBe(200);
405
- const record = (await res.json()) as Record<string, unknown>;
406
- expect(record.entityId).toBe(OTHER_ENTITY_ID);
407
- });
408
-
409
- it('identical bytes uploaded twice produce the same fileId but two distinct records', async () => {
410
- const bytes = new TextEncoder().encode('same content');
411
- const upload = () =>
412
- t.app.request('/attachments', {
413
- method: 'POST',
414
- headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
415
- body: bytes,
416
- });
417
-
418
- const first = (await (await upload()).json()) as { id: string; content: { fileId: string } };
419
- const second = (await (await upload()).json()) as { id: string; content: { fileId: string } };
420
-
421
- expect(second.content.fileId).toBe(first.content.fileId);
422
- expect(second.id).not.toBe(first.id);
423
- });
424
-
425
- it('rejects a second upload with a conflicting mimeType with 422', async () => {
426
- const bytes = new TextEncoder().encode('same content');
427
- await t.app.request('/attachments', {
428
- method: 'POST',
429
- headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
430
- body: bytes,
431
- });
432
-
433
- const res = await t.app.request('/attachments', {
434
- method: 'POST',
435
- headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'image/png' },
436
- body: bytes,
437
- });
438
- expect(res.status).toBe(422);
439
- const body = (await res.json()) as {
440
- error: { code: string; details: Array<{ path: string }> };
441
- };
442
- expect(body.error.code).toBe('validation');
443
- expect(body.error.details.some((d) => d.path === 'mimeType')).toBe(true);
444
- // Anti-oracle: the established mimeType must never be named in the error.
445
- expect(JSON.stringify(body)).not.toContain('text/plain');
446
- });
447
-
448
- it('returns 413 when Content-Length exceeds the limit (pre-check)', async () => {
449
- const smallConfig = { ...testConfig(t.dbPath), maxAttachmentBytes: 10 };
450
- const smallApp = createApp(t.ctx, smallConfig, logger);
451
-
452
- const res = await smallApp.request('/attachments', {
453
- method: 'POST',
454
- headers: {
455
- Authorization: `Bearer ${TEST_TOKEN}`,
456
- 'Content-Type': 'text/plain',
457
- 'Content-Length': '100',
458
- },
459
- body: 'x'.repeat(11),
460
- });
461
- expect(res.status).toBe(413);
462
- });
463
-
464
- it('returns 413 for a chunked upload without Content-Length, aborting before the stream drains', async () => {
465
- const smallConfig = { ...testConfig(t.dbPath), maxAttachmentBytes: 10 };
466
- const smallApp = createApp(t.ctx, smallConfig, logger);
467
-
468
- let pulls = 0;
469
- const stream = new ReadableStream<Uint8Array>({
470
- pull(controller) {
471
- pulls++;
472
- if (pulls > 3) {
473
- controller.error(new Error('body was read past where the limit should have aborted it'));
474
- return;
475
- }
476
- controller.enqueue(new Uint8Array(20).fill(65)); // 20 bytes, already over the 10-byte limit
477
- },
478
- });
479
-
480
- const res = await smallApp.request('/attachments', {
481
- method: 'POST',
482
- headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
483
- duplex: 'half',
484
- body: stream,
485
- });
486
- expect(res.status).toBe(413);
487
- expect(pulls).toBeLessThanOrEqual(3);
488
- });
489
- });
490
-
491
- describe('DELETE /attachments/:fileId', () => {
492
- let t: TestApp;
493
- beforeEach(async () => {
494
- t = await buildTestApp();
495
- await seedType(t.ctx);
496
- });
497
- afterEach(async () => {
498
- await t.cleanup();
499
- });
500
-
501
- it('allows the owner to delete', async () => {
502
- const fileId = await putFile(t.ctx);
503
- const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, {
504
- token: TEST_TOKEN,
505
- });
506
- expect(status).toBe(204);
507
- });
508
-
509
- it('returns 409 when the file is still referenced by a record', async () => {
510
- const fileId = await putFile(t.ctx);
511
- await t.ctx.stack.create(
512
- NOTE_TYPE_ID,
513
- { body: 'note with attachment' },
514
- {
515
- associations: [{ kind: 'attachment', label: 'file', fileId }],
516
- },
517
- );
518
- const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, {
519
- token: TEST_TOKEN,
520
- });
521
- expect(status).toBe(409);
522
- });
523
-
524
- it('rejects a non-owner entity even with a write grant on a referencing record', async () => {
525
- const fileId = await putFile(t.ctx);
526
- await t.ctx.stack.create(
527
- NOTE_TYPE_ID,
528
- { body: 'shared note' },
529
- {
530
- permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
531
- associations: [{ kind: 'attachment', label: 'file', fileId }],
532
- },
533
- );
534
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
535
-
536
- const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { token });
537
- expect(status).toBe(403);
538
- });
539
- });
540
-
541
- describe('POST /attachments/gc', () => {
542
- let t: TestApp;
543
- beforeEach(async () => {
544
- t = await buildTestApp();
545
- await seedType(t.ctx);
546
- });
547
- afterEach(async () => {
548
- await t.cleanup();
549
- });
550
-
551
- it('rejects an anonymous request with 401', async () => {
552
- const { status } = await req(t.app, 'POST', '/attachments/gc');
553
- expect(status).toBe(401);
554
- });
555
-
556
- it('rejects a non-owner request with 403', async () => {
557
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
558
- const { status } = await req(t.app, 'POST', '/attachments/gc', { token });
559
- expect(status).toBe(403);
560
- });
561
-
562
- it('accepts an empty body', async () => {
563
- const { status } = await req(t.app, 'POST', '/attachments/gc', { token: TEST_TOKEN });
564
- expect(status).toBe(200);
565
- });
566
-
567
- it('dry-run reports what would be deleted without deleting it', async () => {
568
- const fileId = await putFile(t.ctx);
569
- const { status, data } = await req(t.app, 'POST', '/attachments/gc', {
570
- token: TEST_TOKEN,
571
- body: { graceMs: 0, dryRun: true },
572
- });
573
- expect(status).toBe(200);
574
- const result = data as { deleted: string[]; reclaimedBytes: number };
575
- expect(result.deleted).toContain(fileId);
576
-
577
- const after = await req(t.app, 'GET', `/attachments/${fileId}`, { token: TEST_TOKEN });
578
- expect(after.status).toBe(200);
579
- });
580
-
581
- it('honors the grace period, skipping a recently uploaded unreferenced file', async () => {
582
- const fileId = await putFile(t.ctx);
583
- const { status, data } = await req(t.app, 'POST', '/attachments/gc', { token: TEST_TOKEN });
584
- expect(status).toBe(200);
585
- const result = data as { deleted: string[] };
586
- expect(result.deleted).not.toContain(fileId);
587
- });
588
-
589
- it('deletes unreferenced files past the grace period and reclaims their bytes', async () => {
590
- const fileId = await putFile(t.ctx);
591
- const { status, data } = await req(t.app, 'POST', '/attachments/gc', {
592
- token: TEST_TOKEN,
593
- body: { graceMs: 0 },
594
- });
595
- expect(status).toBe(200);
596
- const result = data as { deleted: string[]; reclaimedBytes: number };
597
- expect(result.deleted).toContain(fileId);
598
- expect(result.reclaimedBytes).toBeGreaterThan(0);
599
-
600
- const after = await req(t.app, 'GET', `/attachments/${fileId}`, { token: TEST_TOKEN });
601
- expect(after.status).toBe(404);
602
- });
603
-
604
- it('does not delete a file still referenced by a record', async () => {
605
- const fileId = await putFile(t.ctx);
606
- await t.ctx.stack.create(
607
- NOTE_TYPE_ID,
608
- { body: 'note with attachment' },
609
- {
610
- associations: [{ kind: 'attachment', label: 'file', fileId }],
611
- },
612
- );
613
- const { status, data } = await req(t.app, 'POST', '/attachments/gc', {
614
- token: TEST_TOKEN,
615
- body: { graceMs: 0 },
616
- });
617
- expect(status).toBe(200);
618
- const result = data as { deleted: string[] };
619
- expect(result.deleted).not.toContain(fileId);
620
- });
621
- });