@interop/was-conformance-suite 0.1.2 → 0.3.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 (70) hide show
  1. package/README.md +2 -2
  2. package/dist/suites/authz-ordering-api.d.ts +9 -0
  3. package/dist/suites/authz-ordering-api.d.ts.map +1 -0
  4. package/dist/suites/authz-ordering-api.js +286 -0
  5. package/dist/suites/authz-ordering-api.js.map +1 -0
  6. package/dist/suites/blinded-index-api.d.ts +11 -0
  7. package/dist/suites/blinded-index-api.d.ts.map +1 -0
  8. package/dist/suites/blinded-index-api.js +468 -0
  9. package/dist/suites/blinded-index-api.js.map +1 -0
  10. package/dist/suites/changes-query-api.d.ts.map +1 -1
  11. package/dist/suites/changes-query-api.js +61 -0
  12. package/dist/suites/changes-query-api.js.map +1 -1
  13. package/dist/suites/chunks-api.d.ts +27 -0
  14. package/dist/suites/chunks-api.d.ts.map +1 -0
  15. package/dist/suites/chunks-api.js +613 -0
  16. package/dist/suites/chunks-api.js.map +1 -0
  17. package/dist/suites/client-backends.d.ts.map +1 -1
  18. package/dist/suites/client-backends.js +23 -0
  19. package/dist/suites/client-backends.js.map +1 -1
  20. package/dist/suites/client-delegation.d.ts.map +1 -1
  21. package/dist/suites/client-delegation.js +12 -0
  22. package/dist/suites/client-delegation.js.map +1 -1
  23. package/dist/suites/client-export-import.d.ts.map +1 -1
  24. package/dist/suites/client-export-import.js +1 -0
  25. package/dist/suites/client-export-import.js.map +1 -1
  26. package/dist/suites/client-resources.d.ts.map +1 -1
  27. package/dist/suites/client-resources.js +30 -0
  28. package/dist/suites/client-resources.js.map +1 -1
  29. package/dist/suites/client-spaces.d.ts.map +1 -1
  30. package/dist/suites/client-spaces.js +27 -0
  31. package/dist/suites/client-spaces.js.map +1 -1
  32. package/dist/suites/collection-api.d.ts.map +1 -1
  33. package/dist/suites/collection-api.js +151 -0
  34. package/dist/suites/collection-api.js.map +1 -1
  35. package/dist/suites/conditional-requests-api.d.ts +11 -0
  36. package/dist/suites/conditional-requests-api.d.ts.map +1 -0
  37. package/dist/suites/conditional-requests-api.js +444 -0
  38. package/dist/suites/conditional-requests-api.js.map +1 -0
  39. package/dist/suites/digest-api.d.ts +7 -0
  40. package/dist/suites/digest-api.d.ts.map +1 -0
  41. package/dist/suites/digest-api.js +157 -0
  42. package/dist/suites/digest-api.js.map +1 -0
  43. package/dist/suites/encryption-marker-api.d.ts.map +1 -1
  44. package/dist/suites/encryption-marker-api.js +132 -8
  45. package/dist/suites/encryption-marker-api.js.map +1 -1
  46. package/dist/suites/index.d.ts +8 -1
  47. package/dist/suites/index.d.ts.map +1 -1
  48. package/dist/suites/index.js +16 -2
  49. package/dist/suites/index.js.map +1 -1
  50. package/dist/suites/invocation-target-api.d.ts +12 -0
  51. package/dist/suites/invocation-target-api.d.ts.map +1 -0
  52. package/dist/suites/invocation-target-api.js +252 -0
  53. package/dist/suites/invocation-target-api.js.map +1 -0
  54. package/dist/suites/policy-api.d.ts.map +1 -1
  55. package/dist/suites/policy-api.js +23 -0
  56. package/dist/suites/policy-api.js.map +1 -1
  57. package/dist/suites/resource-api.d.ts.map +1 -1
  58. package/dist/suites/resource-api.js +269 -0
  59. package/dist/suites/resource-api.js.map +1 -1
  60. package/dist/suites/server.d.ts.map +1 -1
  61. package/dist/suites/server.js +1 -0
  62. package/dist/suites/server.js.map +1 -1
  63. package/dist/suites/spaces-api.d.ts.map +1 -1
  64. package/dist/suites/spaces-api.js +565 -0
  65. package/dist/suites/spaces-api.js.map +1 -1
  66. package/dist/suites/write-validation-api.d.ts +7 -0
  67. package/dist/suites/write-validation-api.d.ts.map +1 -0
  68. package/dist/suites/write-validation-api.js +186 -0
  69. package/dist/suites/write-validation-api.js.map +1 -0
  70. package/package.json +8 -3
@@ -76,6 +76,7 @@ export const spacesApi = {
76
76
  id: 'repository.anonymous-list-empty',
77
77
  name: 'GET /spaces/ without auth headers returns the empty listing (200)',
78
78
  group: 'Spaces Repository API',
79
+ specRefs: ['https://wallet.storage/spec#list-spaces-operation'],
79
80
  run: async (ctx) => {
80
81
  const { serverUrl } = ctx;
81
82
  // List Spaces is the spec's exception to 404 masking: an anonymous
@@ -93,6 +94,7 @@ export const spacesApi = {
93
94
  id: 'repository.list-scoped-to-controller',
94
95
  name: '[root] GET /spaces/ lists only spaces controlled by the requester',
95
96
  group: 'Spaces Repository API',
97
+ specRefs: ['https://wallet.storage/spec#list-spaces-operation'],
96
98
  run: async (ctx, state) => {
97
99
  const { serverUrl, createSpace, generateId } = ctx;
98
100
  const { alice, bob } = state;
@@ -133,6 +135,7 @@ export const spacesApi = {
133
135
  id: 'repository.create-unauthorized-401',
134
136
  name: 'POST /spaces/ should 401 error when no authorization headers',
135
137
  group: 'Spaces Repository API',
138
+ specRefs: ['https://wallet.storage/spec#create-space-operation'],
136
139
  run: async (ctx) => {
137
140
  const { serverUrl } = ctx;
138
141
  const response = await fetch(new URL('/spaces/', serverUrl), {
@@ -142,10 +145,264 @@ export const spacesApi = {
142
145
  assert.match(response.headers.get('content-type'), /application\/problem\+json/);
143
146
  }
144
147
  },
148
+ {
149
+ id: 'repository.create-missing-controller-400',
150
+ name: 'POST /spaces/ without a "controller" in the body yields invalid-request-body (400)',
151
+ group: 'Spaces Repository API',
152
+ specRefs: [
153
+ 'https://wallet.storage/spec#create-space-errors',
154
+ 'https://wallet.storage/spec#invalid-request-body'
155
+ ],
156
+ run: async (ctx, state) => {
157
+ const { createSpace, generateId } = ctx;
158
+ const { alice } = state;
159
+ // The `controller` requirement applies before any provisioning or
160
+ // capability concern, so this holds on both the onboarding-token and
161
+ // signed-zcap paths. The token path of createSpace() returns the
162
+ // status; the zcap path throws on non-2xx -- capture either shape.
163
+ let status, problem;
164
+ try {
165
+ const response = await createSpace({
166
+ spaceDescription: {
167
+ id: generateId(),
168
+ name: 'Space With No Controller'
169
+ },
170
+ rootClient: alice.rootClient
171
+ });
172
+ status = response.status;
173
+ problem = response.data;
174
+ }
175
+ catch (err) {
176
+ status = err.response?.status;
177
+ problem = err.data;
178
+ }
179
+ assert.equal(status, 400);
180
+ assert.equal(problem.type, 'https://wallet.storage/spec#invalid-request-body');
181
+ }
182
+ },
183
+ {
184
+ id: 'repository.create-controller-mismatch-400',
185
+ name: "[root] POST /spaces/ signed by a key that is not the body's controller yields controller-mismatch (400)",
186
+ group: 'Spaces Repository API',
187
+ specRefs: [
188
+ 'https://wallet.storage/spec#create-space-errors',
189
+ 'https://wallet.storage/spec#controller-mismatch'
190
+ ],
191
+ run: async (ctx, state) => {
192
+ const { serverUrl, generateId } = ctx;
193
+ const { alice, bob } = state;
194
+ if (ctx.onboardingToken) {
195
+ // With an onboarding token configured, the token itself vouches for
196
+ // provisioning (delegated provisioning), so the signer-vs-body
197
+ // consent check under test here is legitimately skipped.
198
+ ctx.skip('onboarding token configured: token vouches for provisioning');
199
+ }
200
+ // Create Space is verified against the *body's* controller: Bob signs
201
+ // the invocation but names Alice as controller, with no delegation
202
+ // chain rooted in her -- the direct signer-mismatch shape.
203
+ const spaceId = generateId();
204
+ let expectedError;
205
+ try {
206
+ await bob.rootClient.request({
207
+ url: new URL('/spaces/', serverUrl).toString(),
208
+ method: 'POST',
209
+ action: 'POST',
210
+ json: {
211
+ id: spaceId,
212
+ name: 'Space Bob Claims For Alice',
213
+ controller: alice.did
214
+ }
215
+ });
216
+ }
217
+ catch (err) {
218
+ expectedError = err;
219
+ }
220
+ assert.ok(expectedError, 'expected the mismatched create to fail');
221
+ assert.equal(expectedError.response.status, 400);
222
+ assert.equal(expectedError.data.type, 'https://wallet.storage/spec#controller-mismatch');
223
+ // The Space must not have been created: its named controller (Alice)
224
+ // would be able to read it if it had been.
225
+ let checkError;
226
+ try {
227
+ await alice.rootClient.request({
228
+ url: new URL(`/space/${spaceId}`, serverUrl).toString(),
229
+ method: 'GET'
230
+ });
231
+ }
232
+ catch (err) {
233
+ checkError = err;
234
+ }
235
+ assert.ok(checkError, 'the rejected Space must not exist');
236
+ assert.equal(checkError.response.status, 404);
237
+ }
238
+ },
239
+ {
240
+ id: 'repository.create-invalid-id-400',
241
+ name: 'POST /spaces/ with a non-URL-safe space id yields invalid-id (400)',
242
+ group: 'Spaces Repository API',
243
+ specRefs: [
244
+ 'https://wallet.storage/spec#identifiers',
245
+ 'https://wallet.storage/spec#invalid-id'
246
+ ],
247
+ run: async (ctx, state) => {
248
+ const { createSpace } = ctx;
249
+ const { alice } = state;
250
+ // The id is carried in the request body (not the URL), so it reaches
251
+ // id validation rather than being reshaped by routing. A space
252
+ // character is outside the RFC 3986 unreserved set, so the id is not
253
+ // URL-safe (spec: Identifier Required Properties). Rejection precedes
254
+ // any provisioning concern, so this holds on both the onboarding-token
255
+ // and signed-zcap paths: the token path of createSpace() returns the
256
+ // status; the zcap path throws on non-2xx -- capture either shape.
257
+ let status, problem;
258
+ try {
259
+ const response = await createSpace({
260
+ spaceDescription: {
261
+ id: 'a b',
262
+ name: 'Space With An Unsafe Id',
263
+ controller: alice.did
264
+ },
265
+ rootClient: alice.rootClient
266
+ });
267
+ status = response.status;
268
+ problem = response.data;
269
+ }
270
+ catch (err) {
271
+ status = err.response?.status;
272
+ problem = err.data;
273
+ }
274
+ assert.equal(status, 400);
275
+ assert.equal(problem.type, 'https://wallet.storage/spec#invalid-id');
276
+ }
277
+ },
278
+ {
279
+ id: 'repository.create-controller-not-did-400',
280
+ name: 'POST /spaces/ whose body controller is not a DID is rejected (400 invalid-request-body)',
281
+ group: 'Spaces Repository API',
282
+ specRefs: [
283
+ 'https://wallet.storage/spec#create-space-errors',
284
+ 'https://wallet.storage/spec#invalid-request-body'
285
+ ],
286
+ run: async (ctx, state) => {
287
+ const { createSpace, generateId } = ctx;
288
+ const { alice } = state;
289
+ // A `controller` that is present but not a DID (here an https URL) is a
290
+ // malformed request body -- rejected before any provisioning concern,
291
+ // so this holds on both the onboarding-token and signed-zcap paths. The
292
+ // token path of createSpace() returns the status; the zcap path throws
293
+ // on non-2xx -- capture either shape.
294
+ let status, problem;
295
+ try {
296
+ const response = await createSpace({
297
+ spaceDescription: {
298
+ id: generateId(),
299
+ name: 'Space With A Non-DID Controller',
300
+ controller: 'https://example.com/alice'
301
+ },
302
+ rootClient: alice.rootClient
303
+ });
304
+ status = response.status;
305
+ problem = response.data;
306
+ }
307
+ catch (err) {
308
+ status = err.response?.status;
309
+ problem = err.data;
310
+ }
311
+ assert.equal(status, 400);
312
+ assert.equal(problem.type, 'https://wallet.storage/spec#invalid-request-body');
313
+ }
314
+ },
315
+ {
316
+ id: 'repository.create-delegated-201',
317
+ name: "[delegated] a provisioning app creates a Space on Alice's behalf via POST (201)",
318
+ group: 'Spaces Repository API',
319
+ specRefs: [
320
+ 'https://wallet.storage/spec#create-space-operation',
321
+ 'https://wallet.storage/spec#was-authorization-profile-v0-1'
322
+ ],
323
+ run: async (ctx, state) => {
324
+ const { serverUrl, zcapClient, generateId } = ctx;
325
+ const { alice, aliceDelegatedApp } = state;
326
+ if (ctx.onboardingToken) {
327
+ // With an onboarding token configured, the token vouches for
328
+ // provisioning, so the delegated-consent path under test here is
329
+ // legitimately bypassed.
330
+ ctx.skip('onboarding token configured: token vouches for provisioning');
331
+ }
332
+ // Alice delegates a POST /spaces/ capability to her app; the app
333
+ // invokes it, naming Alice as the controller. The invocation is
334
+ // authorized by a delegation chain rooted in the body's controller, so
335
+ // the create succeeds and Alice controls the Space from the start
336
+ // (delegated provisioning).
337
+ const spacesUrl = new URL('/spaces/', serverUrl).toString();
338
+ const aliceAppClient = zcapClient({ signer: aliceDelegatedApp.signer });
339
+ const provisioningCapability = await alice.rootClient.delegate({
340
+ allowedActions: ['POST'],
341
+ invocationTarget: spacesUrl,
342
+ controller: aliceDelegatedApp.did
343
+ });
344
+ const spaceId = generateId();
345
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
346
+ try {
347
+ const response = await aliceAppClient.request({
348
+ url: spacesUrl,
349
+ capability: provisioningCapability,
350
+ method: 'POST',
351
+ action: 'POST',
352
+ json: {
353
+ id: spaceId,
354
+ name: 'Provisioned for Alice',
355
+ controller: alice.did
356
+ }
357
+ });
358
+ assert.equal(response.status, 201);
359
+ assert.equal(response.data.controller, alice.did);
360
+ // The Space exists and is controlled by Alice: her root key reads it.
361
+ const checkResponse = await alice.rootClient.request({
362
+ url: spaceUrl,
363
+ method: 'GET',
364
+ action: 'GET'
365
+ });
366
+ assert.equal(checkResponse.status, 200);
367
+ assert.equal(checkResponse.data.controller, alice.did);
368
+ }
369
+ finally {
370
+ try {
371
+ await alice.rootClient.request({ url: spaceUrl, method: 'DELETE' });
372
+ }
373
+ catch {
374
+ /* best-effort cleanup */
375
+ }
376
+ }
377
+ }
378
+ },
379
+ {
380
+ id: 'repository.error-body-type-and-title',
381
+ name: 'an error response carries both a non-empty `type` and a non-empty `title`',
382
+ group: 'Spaces Repository API',
383
+ specRefs: ['https://wallet.storage/spec#error-handling'],
384
+ run: async (ctx) => {
385
+ const { serverUrl } = ctx;
386
+ // An unauthenticated Create Space is a request/credential failure (401),
387
+ // returned as problem+json. The spec makes `type` and `title` REQUIRED
388
+ // members of every error body.
389
+ const response = await fetch(new URL('/spaces/', serverUrl), {
390
+ method: 'POST'
391
+ });
392
+ assert.equal(response.status, 401);
393
+ assert.match(response.headers.get('content-type'), /application\/problem\+json/);
394
+ const problem = (await response.json());
395
+ assert.equal(typeof problem.type, 'string');
396
+ assert.ok(problem.type.length > 0, 'error `type` must be non-empty');
397
+ assert.equal(typeof problem.title, 'string');
398
+ assert.ok(problem.title.length > 0, 'error `title` must be non-empty');
399
+ }
400
+ },
145
401
  {
146
402
  id: 'space.create-post',
147
403
  name: '[root] create space via POST',
148
404
  group: 'Space API',
405
+ specRefs: ['https://wallet.storage/spec#create-space-operation'],
149
406
  run: async (ctx, state) => {
150
407
  const { serverUrl, createSpace, generateId, withoutCreatedBy } = ctx;
151
408
  const { alice } = state;
@@ -211,6 +468,9 @@ export const spacesApi = {
211
468
  id: 'space.create-put',
212
469
  name: '[root] create space by id via PUT',
213
470
  group: 'Space API',
471
+ specRefs: [
472
+ 'https://wallet.storage/spec#update-or-create-by-id-space-operation'
473
+ ],
214
474
  run: async (ctx, state) => {
215
475
  const { serverUrl } = ctx;
216
476
  const { alice } = state;
@@ -233,10 +493,81 @@ export const spacesApi = {
233
493
  assert.equal(checkResponse.status, 200);
234
494
  }
235
495
  },
496
+ {
497
+ id: 'space.update-controller-swap-404',
498
+ name: "[root] a PUT swapping 'controller', signed by the would-be new controller, yields 404 and does not transfer the Space",
499
+ group: 'Space API',
500
+ specRefs: [
501
+ 'https://wallet.storage/spec#update-or-create-by-id-space-operation',
502
+ 'https://wallet.storage/spec#not-found'
503
+ ],
504
+ run: async (ctx, state) => {
505
+ const { serverUrl, createSpace, generateId } = ctx;
506
+ const { alice, bob } = state;
507
+ // An update MUST be verified against the *stored* controller, not the
508
+ // body's proposed one -- otherwise any caller could seize an existing
509
+ // Space by PUTting its id with themselves as controller. Bob attempts
510
+ // exactly that takeover; as an unauthorized write to an existing
511
+ // Space, it gets the privacy-merged not-found (404) mask.
512
+ const spaceId = generateId();
513
+ await createSpace({
514
+ spaceDescription: {
515
+ id: spaceId,
516
+ name: 'Escalation Target Space',
517
+ controller: alice.did
518
+ },
519
+ rootClient: alice.rootClient
520
+ });
521
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
522
+ try {
523
+ let expectedError;
524
+ try {
525
+ await bob.rootClient.request({
526
+ url: spaceUrl,
527
+ method: 'PUT',
528
+ action: 'PUT',
529
+ json: {
530
+ id: spaceId,
531
+ name: 'Seized Space',
532
+ controller: bob.did
533
+ }
534
+ });
535
+ }
536
+ catch (err) {
537
+ expectedError = err;
538
+ }
539
+ assert.ok(expectedError, 'expected the takeover PUT to fail');
540
+ assert.equal(expectedError.response.status, 404);
541
+ assert.match(expectedError.response.headers.get('content-type'), /application\/problem\+json/);
542
+ assert.equal(expectedError.data.type, 'https://wallet.storage/spec#not-found');
543
+ // The operation must not have been performed: Alice still controls
544
+ // the Space, name and controller unchanged.
545
+ const checkResponse = await alice.rootClient.request({
546
+ url: spaceUrl,
547
+ method: 'GET'
548
+ });
549
+ assert.equal(checkResponse.status, 200);
550
+ assert.equal(checkResponse.data.controller, alice.did);
551
+ assert.equal(checkResponse.data.name, 'Escalation Target Space');
552
+ }
553
+ finally {
554
+ try {
555
+ await alice.rootClient.request({ url: spaceUrl, method: 'DELETE' });
556
+ }
557
+ catch {
558
+ /* best-effort cleanup */
559
+ }
560
+ }
561
+ }
562
+ },
236
563
  {
237
564
  id: 'space.anonymous-read-404',
238
565
  name: 'GET a space with no auth headers falls through to policy and 404s (no public policy)',
239
566
  group: 'Space API',
567
+ specRefs: [
568
+ 'https://wallet.storage/spec#read-space-operation',
569
+ 'https://wallet.storage/spec#read-space-errors'
570
+ ],
240
571
  run: async (ctx, state) => {
241
572
  const { serverUrl } = ctx;
242
573
  const { alice } = state;
@@ -252,6 +583,10 @@ export const spacesApi = {
252
583
  id: 'space.read-missing-404',
253
584
  name: 'GET /space/:spaceId should 404 error on not found space id',
254
585
  group: 'Space API',
586
+ specRefs: [
587
+ 'https://wallet.storage/spec#read-space-operation',
588
+ 'https://wallet.storage/spec#read-space-errors'
589
+ ],
255
590
  run: async (ctx, state) => {
256
591
  const { serverUrl } = ctx;
257
592
  const { alice } = state;
@@ -275,6 +610,7 @@ export const spacesApi = {
275
610
  id: 'space.read-authorized',
276
611
  name: '[root] read space via GET with proper authorization',
277
612
  group: 'Space API',
613
+ specRefs: ['https://wallet.storage/spec#read-space-operation'],
278
614
  run: async (ctx, state) => {
279
615
  const { serverUrl, withoutCreatedBy } = ctx;
280
616
  const { alice } = state;
@@ -300,6 +636,10 @@ export const spacesApi = {
300
636
  id: 'space.read-delegated',
301
637
  name: '[delegated] authorized app should GET /space/:spaceId',
302
638
  group: 'Space API',
639
+ specRefs: [
640
+ 'https://wallet.storage/spec#read-space-operation',
641
+ 'https://wallet.storage/spec#was-authorization-profile-v0-1'
642
+ ],
303
643
  run: async (ctx, state) => {
304
644
  const { serverUrl, zcapClient, withoutCreatedBy } = ctx;
305
645
  const { alice, aliceDelegatedApp } = state;
@@ -332,6 +672,10 @@ export const spacesApi = {
332
672
  id: 'space.cross-user-read-404',
333
673
  name: '[root] Bob should not be able to GET Alice space',
334
674
  group: 'Space API',
675
+ specRefs: [
676
+ 'https://wallet.storage/spec#read-space-operation',
677
+ 'https://wallet.storage/spec#read-space-errors'
678
+ ],
335
679
  run: async (ctx, state) => {
336
680
  const { serverUrl } = ctx;
337
681
  const { alice, bob } = state;
@@ -352,6 +696,7 @@ export const spacesApi = {
352
696
  id: 'space.delete',
353
697
  name: '[root] Alice should be able to DELETE her provisioned space',
354
698
  group: 'Space API',
699
+ specRefs: ['https://wallet.storage/spec#delete-space-operation'],
355
700
  run: async (ctx, state) => {
356
701
  const { serverUrl, createSpace, generateId } = ctx;
357
702
  const { alice } = state;
@@ -380,10 +725,230 @@ export const spacesApi = {
380
725
  assert.equal(checkResponse.status, 404);
381
726
  }
382
727
  },
728
+ {
729
+ id: 'space.create-post-conflict-preserves-original',
730
+ name: '[root] a conflicting POST leaves the original Space untouched (409)',
731
+ group: 'Space API',
732
+ specRefs: [
733
+ 'https://wallet.storage/spec#create-space-errors',
734
+ 'https://wallet.storage/spec#id-conflict'
735
+ ],
736
+ run: async (ctx, state) => {
737
+ const { serverUrl, createSpace, generateId } = ctx;
738
+ const { alice, bob } = state;
739
+ const spaceId = generateId();
740
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
741
+ await createSpace({
742
+ spaceDescription: {
743
+ id: spaceId,
744
+ name: 'Original Space',
745
+ controller: alice.did
746
+ },
747
+ rootClient: alice.rootClient
748
+ });
749
+ try {
750
+ // POST the same id again with a different name and controller. The
751
+ // existence check precedes any provisioning concern, so this is a 409
752
+ // id-conflict; create-or-replace by id is PUT's job. The token path
753
+ // of createSpace() returns the status; the zcap path throws on
754
+ // non-2xx -- capture either shape.
755
+ let status, problem;
756
+ try {
757
+ const response = await createSpace({
758
+ spaceDescription: {
759
+ id: spaceId,
760
+ name: 'Usurping Space',
761
+ controller: bob.did
762
+ },
763
+ rootClient: alice.rootClient
764
+ });
765
+ status = response.status;
766
+ problem = response.data;
767
+ }
768
+ catch (err) {
769
+ status = err.response?.status;
770
+ problem = err.data;
771
+ }
772
+ assert.equal(status, 409);
773
+ assert.equal(problem.type, 'https://wallet.storage/spec#id-conflict');
774
+ // The original Space is untouched: its name and controller are as
775
+ // first created, not the conflicting POST's proposed values.
776
+ const checkResponse = await alice.rootClient.request({
777
+ url: spaceUrl,
778
+ method: 'GET',
779
+ action: 'GET'
780
+ });
781
+ assert.equal(checkResponse.status, 200);
782
+ assert.equal(checkResponse.data.name, 'Original Space');
783
+ assert.equal(checkResponse.data.controller, alice.did);
784
+ }
785
+ finally {
786
+ try {
787
+ await alice.rootClient.request({ url: spaceUrl, method: 'DELETE' });
788
+ }
789
+ catch {
790
+ /* best-effort cleanup */
791
+ }
792
+ }
793
+ }
794
+ },
795
+ {
796
+ id: 'space.create-ignores-body-createdby',
797
+ name: '[root] a body-supplied `createdBy` is ignored by the server',
798
+ group: 'Space API',
799
+ specRefs: ['https://wallet.storage/spec#space-data-model'],
800
+ run: async (ctx, state) => {
801
+ const { serverUrl, createSpace, generateId } = ctx;
802
+ const { alice } = state;
803
+ // `createdBy` is server-managed and read-only: the spec requires a
804
+ // server to ignore a `createdBy` supplied in a request body. The value
805
+ // below is a well-formed but bogus DID that is not the creator.
806
+ const bogusCreatedBy = 'did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJXXXX';
807
+ const spaceId = generateId();
808
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
809
+ await createSpace({
810
+ spaceDescription: {
811
+ id: spaceId,
812
+ name: 'Space With A Bogus createdBy',
813
+ controller: alice.did,
814
+ createdBy: bogusCreatedBy
815
+ },
816
+ rootClient: alice.rootClient
817
+ });
818
+ try {
819
+ const response = await alice.rootClient.request({
820
+ url: spaceUrl,
821
+ method: 'GET',
822
+ action: 'GET'
823
+ });
824
+ assert.equal(response.status, 200);
825
+ // Either absent (token-provisioned create records no createdBy) or
826
+ // set to the real invoker -- never the bogus body value.
827
+ assert.notEqual(response.data.createdBy, bogusCreatedBy, 'the server must ignore a body-supplied createdBy');
828
+ }
829
+ finally {
830
+ try {
831
+ await alice.rootClient.request({ url: spaceUrl, method: 'DELETE' });
832
+ }
833
+ catch {
834
+ /* best-effort cleanup */
835
+ }
836
+ }
837
+ }
838
+ },
839
+ {
840
+ id: 'space.create-put-controller-mismatch-400',
841
+ name: '[root] PUT-creating a new Space, signed by someone other than the body controller, yields controller-mismatch (400)',
842
+ group: 'Space API',
843
+ specRefs: [
844
+ 'https://wallet.storage/spec#update-or-create-by-id-space-operation',
845
+ 'https://wallet.storage/spec#controller-mismatch'
846
+ ],
847
+ run: async (ctx, state) => {
848
+ const { serverUrl, generateId } = ctx;
849
+ const { alice, bob } = state;
850
+ // The create branch of PUT is authorized by the *body's* controller,
851
+ // just like Create Space via POST. Bob signs a PUT that would create a
852
+ // new Space naming Alice as controller, with no delegation chain rooted
853
+ // in her -- the direct signer-mismatch shape (this is the CREATE
854
+ // branch; the update branch is covered by space.update-controller-swap).
855
+ const spaceId = generateId();
856
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
857
+ let expectedError;
858
+ try {
859
+ await bob.rootClient.request({
860
+ url: spaceUrl,
861
+ method: 'PUT',
862
+ action: 'PUT',
863
+ json: {
864
+ id: spaceId,
865
+ name: 'Consentless Space',
866
+ controller: alice.did
867
+ }
868
+ });
869
+ }
870
+ catch (err) {
871
+ expectedError = err;
872
+ }
873
+ assert.ok(expectedError, 'expected the unconsented PUT-create to fail');
874
+ assert.equal(expectedError.response.status, 400);
875
+ assert.equal(expectedError.data.type, 'https://wallet.storage/spec#controller-mismatch');
876
+ // The Space must not have been created: its named controller (Alice)
877
+ // would be able to read it if it had been.
878
+ let checkError;
879
+ try {
880
+ await alice.rootClient.request({
881
+ url: spaceUrl,
882
+ method: 'GET',
883
+ action: 'GET'
884
+ });
885
+ }
886
+ catch (err) {
887
+ checkError = err;
888
+ }
889
+ assert.ok(checkError, 'the rejected Space must not exist');
890
+ assert.equal(checkError.response.status, 404);
891
+ }
892
+ },
893
+ {
894
+ id: 'space.reserved-policy-segment',
895
+ name: "[root] GET /space/:spaceId/policy is served as the policy endpoint, not a collection named 'policy'",
896
+ group: 'Space API',
897
+ specRefs: [
898
+ 'https://wallet.storage/spec#reserved-path-segment-registry',
899
+ 'https://wallet.storage/spec#space-level-reserved-endpoints'
900
+ ],
901
+ run: async (ctx, state) => {
902
+ const { serverUrl, createSpace, generateId } = ctx;
903
+ const { alice } = state;
904
+ const spaceId = generateId();
905
+ const spaceUrl = new URL(`/space/${spaceId}`, serverUrl).toString();
906
+ const policyUrl = new URL(`/space/${spaceId}/policy`, serverUrl).toString();
907
+ await createSpace({
908
+ spaceDescription: {
909
+ id: spaceId,
910
+ name: 'Reserved Segment Space',
911
+ controller: alice.did
912
+ },
913
+ rootClient: alice.rootClient
914
+ });
915
+ try {
916
+ // `policy` is a reserved space-level segment: it addresses the
917
+ // access-control policy resource, and MUST NOT be repurposed as a
918
+ // Collection id. Set a policy, then read it back through the same
919
+ // segment to confirm it is served as the policy endpoint.
920
+ await alice.rootClient.request({
921
+ url: policyUrl,
922
+ method: 'PUT',
923
+ action: 'PUT',
924
+ json: { type: 'PublicCanRead' }
925
+ });
926
+ const response = await alice.rootClient.request({
927
+ url: policyUrl,
928
+ method: 'GET',
929
+ action: 'GET'
930
+ });
931
+ assert.equal(response.status, 200);
932
+ // The response is a policy document -- it carries the policy `type`
933
+ // we stored -- not a Collection listing (which would carry `items`).
934
+ assert.equal(response.data.type, 'PublicCanRead');
935
+ assert.equal(response.data.items, undefined, 'the policy endpoint must not return a Collection listing');
936
+ }
937
+ finally {
938
+ try {
939
+ await alice.rootClient.request({ url: spaceUrl, method: 'DELETE' });
940
+ }
941
+ catch {
942
+ /* best-effort cleanup */
943
+ }
944
+ }
945
+ }
946
+ },
383
947
  {
384
948
  id: 'collections.list-for-space',
385
949
  name: '[root] GET /space/:spaceId/collections/ lists collections for a space',
386
950
  group: 'Collections API',
951
+ specRefs: ['https://wallet.storage/spec#list-all-collections-operation'],
387
952
  run: async (ctx, state) => {
388
953
  const { serverUrl } = ctx;
389
954
  const { alice, collectionId } = state;