@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,448 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { generateDidKeypair } from '@haverstack/core/did';
3
- import { signAuthChallenge, base64urlEncode } from '@haverstack/core/wire';
4
- import type { DidKeypair } from '@haverstack/core/did';
5
- import {
6
- buildTestApp,
7
- req,
8
- TEST_BASE_URL,
9
- TEST_TOKEN,
10
- OTHER_ENTITY_ID,
11
- type TestApp,
12
- } from '../setup.js';
13
-
14
- const NOTE_TYPE_ID = 'com.example.test/note@1';
15
-
16
- async function challengeAndSign(
17
- t: TestApp,
18
- keypair: DidKeypair,
19
- opts: { origin?: string; overrideNonce?: string } = {},
20
- ) {
21
- const { data } = await req(t.app, 'POST', '/auth/challenge', {
22
- body: { did: keypair.did },
23
- });
24
- const { nonce } = data as { nonce: string; expiresAt: string };
25
- const signedNonce = opts.overrideNonce ?? nonce;
26
- const signature = await signAuthChallenge(keypair.privateKey, {
27
- origin: opts.origin ?? TEST_BASE_URL,
28
- did: keypair.did,
29
- nonce: signedNonce,
30
- });
31
- return { nonce: signedNonce, signature: base64urlEncode(signature) };
32
- }
33
-
34
- describe('POST /auth/challenge', () => {
35
- let t: TestApp;
36
- beforeEach(async () => {
37
- t = await buildTestApp();
38
- });
39
- afterEach(async () => {
40
- await t.cleanup();
41
- });
42
-
43
- it('issues a nonce for a well-formed DID, unauthenticated', async () => {
44
- const keypair = await generateDidKeypair();
45
- const { status, data } = await req(t.app, 'POST', '/auth/challenge', {
46
- body: { did: keypair.did },
47
- });
48
- expect(status).toBe(200);
49
- const d = data as { nonce: string; expiresAt: string };
50
- expect(d.nonce).toMatch(/^[A-Za-z0-9_-]+$/);
51
- expect(new Date(d.expiresAt).getTime()).toBeGreaterThan(Date.now());
52
- });
53
-
54
- it('issues a fresh, distinct nonce on each call', async () => {
55
- const keypair = await generateDidKeypair();
56
- const first = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
57
- const second = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
58
- expect((first.data as { nonce: string }).nonce).not.toBe(
59
- (second.data as { nonce: string }).nonce,
60
- );
61
- });
62
-
63
- it('rejects a malformed DID with 400 invalid_did', async () => {
64
- const { status, data } = await req(t.app, 'POST', '/auth/challenge', {
65
- body: { did: 'not-a-did' },
66
- });
67
- expect(status).toBe(400);
68
- expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
69
- });
70
-
71
- it('rejects a missing did with 400 invalid_did', async () => {
72
- const { status, data } = await req(t.app, 'POST', '/auth/challenge', { body: {} });
73
- expect(status).toBe(400);
74
- expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
75
- });
76
- });
77
-
78
- describe('POST /auth/token', () => {
79
- let t: TestApp;
80
- beforeEach(async () => {
81
- t = await buildTestApp();
82
- });
83
- afterEach(async () => {
84
- await t.cleanup();
85
- });
86
-
87
- it('issues an undelegated bearer token for a valid handshake', async () => {
88
- const keypair = await generateDidKeypair();
89
- const { nonce, signature } = await challengeAndSign(t, keypair);
90
-
91
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
92
- body: { did: keypair.did, nonce, signature },
93
- });
94
- expect(status).toBe(200);
95
- const d = data as {
96
- token: string;
97
- expiresAt?: string;
98
- principalId: string;
99
- subjectId: string;
100
- };
101
- expect(typeof d.token).toBe('string');
102
- expect(d.principalId).toBe(keypair.did);
103
- expect(d.subjectId).toBe(keypair.did);
104
-
105
- // The issued token actually authenticates as that DID.
106
- const session = await t.ctx.tokens.lookupToken(d.token);
107
- expect(session).toEqual({ principalId: keypair.did, subjectId: keypair.did });
108
- });
109
-
110
- it('never lets the client name its own subject, even if it tries', async () => {
111
- const keypair = await generateDidKeypair();
112
- const { nonce, signature } = await challengeAndSign(t, keypair);
113
-
114
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
115
- body: {
116
- did: keypair.did,
117
- nonce,
118
- signature,
119
- onBehalfOf: 'did:key:someone-else',
120
- subjectId: 'did:key:someone-else',
121
- },
122
- });
123
- expect(status).toBe(200);
124
- const d = data as { principalId: string; subjectId: string };
125
- expect(d.principalId).toBe(keypair.did);
126
- expect(d.subjectId).toBe(keypair.did);
127
- });
128
-
129
- it('is single-use: redeeming the same nonce twice fails the second time', async () => {
130
- const keypair = await generateDidKeypair();
131
- const { nonce, signature } = await challengeAndSign(t, keypair);
132
-
133
- const first = await req(t.app, 'POST', '/auth/token', {
134
- body: { did: keypair.did, nonce, signature },
135
- });
136
- expect(first.status).toBe(200);
137
-
138
- const second = await req(t.app, 'POST', '/auth/token', {
139
- body: { did: keypair.did, nonce, signature },
140
- });
141
- expect(second.status).toBe(401);
142
- expect((second.data as { error: { code: string } }).error.code).toBe('unknown_nonce');
143
- });
144
-
145
- it('rejects a nonce that was never issued with 401 unknown_nonce', async () => {
146
- const keypair = await generateDidKeypair();
147
- const signature = await signAuthChallenge(keypair.privateKey, {
148
- origin: TEST_BASE_URL,
149
- did: keypair.did,
150
- nonce: 'never-issued-nonce',
151
- });
152
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
153
- body: {
154
- did: keypair.did,
155
- nonce: 'never-issued-nonce',
156
- signature: base64urlEncode(signature),
157
- },
158
- });
159
- expect(status).toBe(401);
160
- expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
161
- });
162
-
163
- it('rejects a nonce bound to a different DID with 401 unknown_nonce, not invalid_signature', async () => {
164
- const owner = await generateDidKeypair();
165
- const impostor = await generateDidKeypair();
166
- // Challenge is issued to `owner`, but `impostor` signs it correctly
167
- // with its own key over the same nonce and origin.
168
- const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: owner.did } });
169
- const { nonce } = data as { nonce: string };
170
- const signature = await signAuthChallenge(impostor.privateKey, {
171
- origin: TEST_BASE_URL,
172
- did: impostor.did,
173
- nonce,
174
- });
175
-
176
- const { status, data: body } = await req(t.app, 'POST', '/auth/token', {
177
- body: { did: impostor.did, nonce, signature: base64urlEncode(signature) },
178
- });
179
- expect(status).toBe(401);
180
- expect((body as { error: { code: string } }).error.code).toBe('unknown_nonce');
181
- });
182
-
183
- it('rejects an expired nonce with 401 expired_nonce', async () => {
184
- const keypair = await generateDidKeypair();
185
- const nonce = 'seeded-expired-nonce';
186
- t.ctx.nonces.seedForTesting(nonce, keypair.did, new Date(Date.now() - 1000));
187
- const signature = await signAuthChallenge(keypair.privateKey, {
188
- origin: TEST_BASE_URL,
189
- did: keypair.did,
190
- nonce,
191
- });
192
-
193
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
194
- body: { did: keypair.did, nonce, signature: base64urlEncode(signature) },
195
- });
196
- expect(status).toBe(401);
197
- expect((data as { error: { code: string } }).error.code).toBe('expired_nonce');
198
- });
199
-
200
- it('rejects a well-formed but wrong signature with 401 invalid_signature', async () => {
201
- const signer = await generateDidKeypair();
202
- const { nonce, signature } = await challengeAndSign(t, signer);
203
- // Corrupt the signature bytes while keeping it syntactically valid
204
- // base64url and the right length — still claims `signer`'s DID and the
205
- // nonce issued to it, so this pins verification failure specifically,
206
- // distinct from the DID-binding case above.
207
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
208
- body: { did: signer.did, nonce, signature: signature.slice(0, -4) + 'AAAA' },
209
- });
210
- expect(status).toBe(401);
211
- expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
212
- });
213
-
214
- it('rejects a malformed (non-base64url) signature with 401 invalid_signature', async () => {
215
- const keypair = await generateDidKeypair();
216
- const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
217
- const { nonce } = data as { nonce: string };
218
-
219
- const { status, data: body } = await req(t.app, 'POST', '/auth/token', {
220
- body: { did: keypair.did, nonce, signature: 'not+valid/base64url==' },
221
- });
222
- expect(status).toBe(401);
223
- expect((body as { error: { code: string } }).error.code).toBe('invalid_signature');
224
- });
225
-
226
- it('rejects a malformed DID with 400 invalid_did before touching the nonce store', async () => {
227
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
228
- body: { did: 'not-a-did', nonce: 'x', signature: 'y' },
229
- });
230
- expect(status).toBe(400);
231
- expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
232
- });
233
-
234
- it('builds the signed origin from server config, not a client-supplied Host header', async () => {
235
- const keypair = await generateDidKeypair();
236
- // Signed for the server's real configured origin (TEST_BASE_URL) while
237
- // the request lies about its Host — must still succeed, since the
238
- // server never derives the origin from the header.
239
- const { nonce, signature } = await challengeAndSign(t, keypair);
240
- const { status } = await req(t.app, 'POST', '/auth/token', {
241
- body: { did: keypair.did, nonce, signature },
242
- headers: { Host: 'evil.example.com', 'X-Forwarded-Host': 'evil.example.com' },
243
- });
244
- expect(status).toBe(200);
245
- });
246
-
247
- it('rejects a signature scoped to a different origin even if a client claims it via headers', async () => {
248
- const keypair = await generateDidKeypair();
249
- // Signed for an origin that is *not* this server's configured one.
250
- const { nonce, signature } = await challengeAndSign(t, keypair, {
251
- origin: 'https://attacker.example.com',
252
- });
253
- const { status, data } = await req(t.app, 'POST', '/auth/token', {
254
- body: { did: keypair.did, nonce, signature },
255
- headers: { Host: 'attacker.example.com', 'X-Forwarded-Host': 'attacker.example.com' },
256
- });
257
- expect(status).toBe(401);
258
- expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
259
- });
260
- });
261
-
262
- describe('handshake input hardening', () => {
263
- let t: TestApp;
264
- beforeEach(async () => {
265
- t = await buildTestApp();
266
- });
267
- afterEach(async () => {
268
- await t.cleanup();
269
- });
270
-
271
- // A DID whose method core cannot verify can never redeem the nonce a
272
- // challenge would issue, so issuing one is pure storage spent on a
273
- // handshake guaranteed to fail. Refused up front instead.
274
- it.each(['did:web:example.com', 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'])(
275
- 'refuses to issue a challenge for a non-did:key DID (%s)',
276
- async (did) => {
277
- const { status, data } = await req(t.app, 'POST', '/auth/challenge', { body: { did } });
278
- expect(status).toBe(400);
279
- expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
280
- },
281
- );
282
-
283
- // The generic DID grammar has no length bound, so without one an
284
- // unauthenticated caller writes a body-limit's worth of "DID" into a
285
- // nonce row per request, retained for the nonce TTL.
286
- it('refuses an oversized DID and stores nothing for it', async () => {
287
- const { status } = await req(t.app, 'POST', '/auth/challenge', {
288
- body: { did: 'did:key:z' + 'a'.repeat(500_000) },
289
- });
290
- expect(status).toBe(400);
291
- const rows = (
292
- t.ctx.nonces as unknown as {
293
- db: { prepare(sql: string): { get(): { n: number } } };
294
- }
295
- ).db
296
- .prepare('SELECT COUNT(*) AS n FROM nonces')
297
- .get();
298
- expect(rows.n).toBe(0);
299
- // Tight timeout on purpose: refusing this rests on core screening the
300
- // length before it decodes, and a lost screen surfaces as a hang rather
301
- // than a wrong answer.
302
- }, 5_000);
303
-
304
- // `null` is valid JSON but not a readable body: indexing a field off it
305
- // throws a bare TypeError, which surfaces as an unlabeled 500 — on an
306
- // unauthenticated route.
307
- it.each(['/auth/challenge', '/auth/token'])(
308
- 'answers 400, not 500, for a non-object JSON body (%s)',
309
- async (path) => {
310
- const res = await t.app.request(path, {
311
- method: 'POST',
312
- headers: { 'Content-Type': 'application/json' },
313
- body: 'null',
314
- });
315
- expect(res.status).toBe(400);
316
- },
317
- );
318
- });
319
-
320
- describe('handshake token bookkeeping', () => {
321
- let t: TestApp;
322
- beforeEach(async () => {
323
- t = await buildTestApp();
324
- });
325
- afterEach(async () => {
326
- await t.cleanup();
327
- });
328
-
329
- // Without a label the owner's only view of what exists cannot tell a
330
- // self-service token from one they minted deliberately.
331
- it('labels handshake-issued tokens so the owner can tell them apart', async () => {
332
- const keypair = await generateDidKeypair();
333
- const { nonce, signature } = await challengeAndSign(t, keypair);
334
- await req(t.app, 'POST', '/auth/token', { body: { did: keypair.did, nonce, signature } });
335
-
336
- const { data } = await req(t.app, 'GET', '/tokens', { token: TEST_TOKEN });
337
- const rows = (data as { tokens: Array<{ principalId: string; label: string | null }> }).tokens;
338
- const issued = rows.find((r) => r.principalId === keypair.did);
339
- expect(issued?.label).toBe('did-challenge');
340
- });
341
-
342
- // lookupToken() refuses an expired token but never deletes the row, and
343
- // nothing else does either — so an unauthenticated route that mints one
344
- // per call grows the table without bound.
345
- it('reclaims expired token rows rather than letting them accumulate', async () => {
346
- await t.ctx.tokens.createToken(OTHER_ENTITY_ID, { expiresAt: new Date(Date.now() - 1000) });
347
- expect((await t.ctx.tokens.listTokens()).length).toBe(1);
348
-
349
- const keypair = await generateDidKeypair();
350
- const { nonce, signature } = await challengeAndSign(t, keypair);
351
- await req(t.app, 'POST', '/auth/token', { body: { did: keypair.did, nonce, signature } });
352
-
353
- const remaining = await t.ctx.tokens.listTokens();
354
- expect(remaining.map((r) => r.principalId)).toEqual([keypair.did]);
355
- });
356
-
357
- // The redeem is one DELETE ... RETURNING rather than a SELECT then a
358
- // DELETE; the DID predicate sits in the WHERE clause precisely so a
359
- // mismatched attempt still spends nothing.
360
- it('does not let a wrong-DID attempt burn a nonce the rightful holder still needs', async () => {
361
- const owner = await generateDidKeypair();
362
- const impostor = await generateDidKeypair();
363
- const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: owner.did } });
364
- const { nonce } = data as { nonce: string };
365
-
366
- const impostorSig = await signAuthChallenge(impostor.privateKey, {
367
- origin: TEST_BASE_URL,
368
- did: impostor.did,
369
- nonce,
370
- });
371
- const attempt = await req(t.app, 'POST', '/auth/token', {
372
- body: { did: impostor.did, nonce, signature: base64urlEncode(impostorSig) },
373
- });
374
- expect(attempt.status).toBe(401);
375
-
376
- const ownerSig = await signAuthChallenge(owner.privateKey, {
377
- origin: TEST_BASE_URL,
378
- did: owner.did,
379
- nonce,
380
- });
381
- const { status } = await req(t.app, 'POST', '/auth/token', {
382
- body: { did: owner.did, nonce, signature: base64urlEncode(ownerSig) },
383
- });
384
- expect(status).toBe(200);
385
- });
386
- });
387
-
388
- // A present-but-invalid credential must 401, never a silent downgrade to
389
- // anonymous: on an optional-auth route that would surface as a 200 with the
390
- // public-only subset instead of telling the caller its session is dead.
391
- describe('invalid bearer credentials', () => {
392
- let t: TestApp;
393
- beforeEach(async () => {
394
- t = await buildTestApp();
395
- await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
396
- body: { kind: 'text' as const, required: true as const },
397
- });
398
- await t.ctx.stack.create(
399
- NOTE_TYPE_ID,
400
- { body: 'public content' },
401
- { permissions: [{ access: 'public' }] },
402
- );
403
- });
404
- afterEach(async () => {
405
- await t.cleanup();
406
- });
407
-
408
- it('rejects an unknown token on an optional-auth route with 401, not an anonymous 200', async () => {
409
- const { status, data } = await req(t.app, 'GET', '/records', { token: 'not-a-real-token' });
410
- expect(status).toBe(401);
411
- expect((data as { error: { code: string } }).error.code).toBe('unauthorized');
412
- });
413
-
414
- it('rejects an expired token on an optional-auth route with 401', async () => {
415
- const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID, {
416
- expiresAt: new Date(Date.now() - 1000),
417
- });
418
- const { status } = await req(t.app, 'GET', '/records', { token });
419
- expect(status).toBe(401);
420
- });
421
-
422
- it('rejects a revoked token on an optional-auth route with 401', async () => {
423
- const { id, token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
424
- await t.ctx.tokens.revokeToken(id);
425
- const { status } = await req(t.app, 'GET', '/records', { token });
426
- expect(status).toBe(401);
427
- });
428
-
429
- it('rejects a malformed Authorization header (non-Bearer scheme) with 401', async () => {
430
- const { status } = await req(t.app, 'GET', '/records', {
431
- headers: { Authorization: 'Basic dXNlcjpwYXNz' },
432
- });
433
- expect(status).toBe(401);
434
- });
435
-
436
- // No credential at all is a different state from a bad one — it must
437
- // still serve the public subset, not be swept up by the fix above.
438
- it('still serves the public subset with no Authorization header at all', async () => {
439
- const { status, data } = await req(t.app, 'GET', '/records');
440
- expect(status).toBe(200);
441
- expect((data as { records: unknown[] }).records.length).toBe(1);
442
- });
443
-
444
- it('rejects an unknown token on a requireAuth() route with 401 too', async () => {
445
- const { status } = await req(t.app, 'GET', '/entity', { token: 'not-a-real-token' });
446
- expect(status).toBe(401);
447
- });
448
- });