was-teaching-server 0.9.1 → 0.11.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 (76) hide show
  1. package/CHANGELOG.md +118 -1
  2. package/README.md +23 -11
  3. package/dist/backends/filesystem.d.ts +247 -4
  4. package/dist/backends/filesystem.d.ts.map +1 -1
  5. package/dist/backends/filesystem.js +711 -81
  6. package/dist/backends/filesystem.js.map +1 -1
  7. package/dist/backends/postgres.d.ts +240 -2
  8. package/dist/backends/postgres.d.ts.map +1 -1
  9. package/dist/backends/postgres.js +800 -42
  10. package/dist/backends/postgres.js.map +1 -1
  11. package/dist/backends/postgresSchema.d.ts.map +1 -1
  12. package/dist/backends/postgresSchema.js +69 -57
  13. package/dist/backends/postgresSchema.js.map +1 -1
  14. package/dist/errors.d.ts +26 -9
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +40 -12
  17. package/dist/errors.js.map +1 -1
  18. package/dist/lib/equalityIndex.d.ts +334 -0
  19. package/dist/lib/equalityIndex.d.ts.map +1 -0
  20. package/dist/lib/equalityIndex.js +552 -0
  21. package/dist/lib/equalityIndex.js.map +1 -0
  22. package/dist/lib/exportManifest.d.ts +9 -0
  23. package/dist/lib/exportManifest.d.ts.map +1 -1
  24. package/dist/lib/exportManifest.js +9 -0
  25. package/dist/lib/exportManifest.js.map +1 -1
  26. package/dist/lib/importTar.d.ts +18 -2
  27. package/dist/lib/importTar.d.ts.map +1 -1
  28. package/dist/lib/importTar.js +77 -4
  29. package/dist/lib/importTar.js.map +1 -1
  30. package/dist/lib/paths.d.ts +33 -0
  31. package/dist/lib/paths.d.ts.map +1 -1
  32. package/dist/lib/paths.js +28 -0
  33. package/dist/lib/paths.js.map +1 -1
  34. package/dist/lib/resourceFileName.d.ts +43 -0
  35. package/dist/lib/resourceFileName.d.ts.map +1 -1
  36. package/dist/lib/resourceFileName.js +63 -0
  37. package/dist/lib/resourceFileName.js.map +1 -1
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +5 -2
  40. package/dist/plugin.js.map +1 -1
  41. package/dist/requests/ChunkRequest.d.ts +104 -0
  42. package/dist/requests/ChunkRequest.d.ts.map +1 -0
  43. package/dist/requests/ChunkRequest.js +396 -0
  44. package/dist/requests/ChunkRequest.js.map +1 -0
  45. package/dist/requests/CollectionRequest.d.ts +22 -5
  46. package/dist/requests/CollectionRequest.d.ts.map +1 -1
  47. package/dist/requests/CollectionRequest.js +192 -11
  48. package/dist/requests/CollectionRequest.js.map +1 -1
  49. package/dist/requests/ResourceRequest.d.ts.map +1 -1
  50. package/dist/requests/ResourceRequest.js +20 -0
  51. package/dist/requests/ResourceRequest.js.map +1 -1
  52. package/dist/requests/SpaceRequest.d.ts +1 -0
  53. package/dist/requests/SpaceRequest.d.ts.map +1 -1
  54. package/dist/requests/SpaceRequest.js +21 -1
  55. package/dist/requests/SpaceRequest.js.map +1 -1
  56. package/dist/routes.d.ts.map +1 -1
  57. package/dist/routes.js +21 -0
  58. package/dist/routes.js.map +1 -1
  59. package/dist/types.d.ts +165 -2
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +19 -18
  62. package/src/backends/filesystem.ts +924 -75
  63. package/src/backends/postgres.ts +1114 -32
  64. package/src/backends/postgresSchema.ts +69 -57
  65. package/src/errors.ts +44 -12
  66. package/src/lib/equalityIndex.ts +769 -0
  67. package/src/lib/importTar.ts +107 -4
  68. package/src/lib/paths.ts +48 -0
  69. package/src/lib/resourceFileName.ts +69 -0
  70. package/src/plugin.ts +5 -2
  71. package/src/requests/ChunkRequest.ts +478 -0
  72. package/src/requests/CollectionRequest.ts +221 -12
  73. package/src/requests/ResourceRequest.ts +20 -0
  74. package/src/requests/SpaceRequest.ts +25 -1
  75. package/src/routes.ts +46 -0
  76. package/src/types.ts +175 -1
@@ -14,6 +14,12 @@ import { invokerDid } from '../auth-header-hooks.js'
14
14
  import { assertValidIds } from '../lib/validateId.js'
15
15
  import type { CollectionDescription, StorageBackend } from '../types.js'
16
16
  import { parseBlindedIndexQueryBody } from '../lib/blindedIndex.js'
17
+ import {
18
+ assertSupportedIndexes,
19
+ normalizeIndexes,
20
+ parseEqualityQueryBody,
21
+ parseListFilter
22
+ } from '../lib/equalityIndex.js'
17
23
  import {
18
24
  assertSupportedBackend,
19
25
  resolveBackendDescriptor,
@@ -39,8 +45,45 @@ import {
39
45
  InvalidCollectionError,
40
46
  InvalidRequestBodyError,
41
47
  UnsupportedOperationError,
48
+ UniqueAttributeConflictError,
42
49
  rethrowOrWrapStorageError
43
50
  } from '../errors.js'
51
+ import type {
52
+ CollectionIndexDeclaration,
53
+ NormalizedIndexDeclaration
54
+ } from '../types.js'
55
+
56
+ /**
57
+ * The normalized `unique: true` declarations an `indexes` update ADDS -- names
58
+ * that are unique in the incoming declaration but were not unique (declared, or
59
+ * declared without `unique`) in the existing one. These are the claims a
60
+ * declare-time conflict scan must verify against already-stored Resources; an
61
+ * unchanged or removed unique claim needs no scan (it was enforced at write
62
+ * time).
63
+ *
64
+ * @param options {object}
65
+ * @param [options.existing] {Array<string | CollectionIndexDeclaration>} the
66
+ * Collection's previously-stored `indexes`
67
+ * @param [options.incoming] {Array<string | CollectionIndexDeclaration>} the
68
+ * `indexes` about to be persisted
69
+ * @returns {NormalizedIndexDeclaration[]}
70
+ */
71
+ function newlyUniqueDeclarations({
72
+ existing,
73
+ incoming
74
+ }: {
75
+ existing?: Array<string | CollectionIndexDeclaration>
76
+ incoming?: Array<string | CollectionIndexDeclaration>
77
+ }): NormalizedIndexDeclaration[] {
78
+ const existingUnique = new Set(
79
+ normalizeIndexes({ indexes: existing })
80
+ .filter(declaration => declaration.unique)
81
+ .map(declaration => declaration.name)
82
+ )
83
+ return normalizeIndexes({ indexes: incoming }).filter(
84
+ declaration => declaration.unique && !existingUnique.has(declaration.name)
85
+ )
86
+ }
44
87
 
45
88
  export class CollectionRequest {
46
89
  /**
@@ -124,6 +167,14 @@ export class CollectionRequest {
124
167
  headers: request.headers,
125
168
  requestName
126
169
  })
170
+ // When the Collection declares any `unique: true` index entries (the
171
+ // `equality-query` feature), pass the normalized unique entries so the
172
+ // backend enforces the plaintext uniqueness claim atomically with the write
173
+ // (409). Write authorization has already run, so the existence-revealing 409
174
+ // is observable only to a caller authorized to write here.
175
+ const uniqueIndexes = normalizeIndexes({
176
+ indexes: collectionDescription.indexes
177
+ }).filter(declaration => declaration.unique)
127
178
  try {
128
179
  written = await dataBackend.writeResource({
129
180
  spaceId,
@@ -131,7 +182,8 @@ export class CollectionRequest {
131
182
  resourceId,
132
183
  input,
133
184
  createdBy: invokerDid(request),
134
- epoch
185
+ epoch,
186
+ ...(uniqueIndexes.length > 0 && { uniqueIndexes })
135
187
  })
136
188
  response = {
137
189
  id: resourceId,
@@ -174,6 +226,7 @@ export class CollectionRequest {
174
226
  name?: string
175
227
  backend?: unknown
176
228
  encryption?: unknown
229
+ indexes?: unknown
177
230
  }
178
231
  }>,
179
232
  reply: FastifyReply
@@ -210,6 +263,16 @@ export class CollectionRequest {
210
263
  encryption: body.encryption,
211
264
  requestName
212
265
  })
266
+ // Validate the optional `indexes` declaration (shape only); an absent
267
+ // `indexes` validates to `undefined` and leaves the stored declaration
268
+ // untouched, while a supplied array (an empty one clears it) replaces it --
269
+ // `indexes` is updatable, unlike the set-once `encryption` marker. The
270
+ // mutual-exclusion-with-encryption rail and the unique-add conflict scan are
271
+ // enforced below, against the description about to be persisted.
272
+ const suppliedIndexes = assertSupportedIndexes({
273
+ indexes: body.indexes,
274
+ requestName
275
+ })
213
276
 
214
277
  // Verify (capability-only): updating a Collection requires a valid
215
278
  // capability invocation; no access-control-policy fallback.
@@ -268,7 +331,8 @@ export class CollectionRequest {
268
331
  ...(suppliedBackend !== undefined && { backend: suppliedBackend }),
269
332
  ...(suppliedEncryption !== undefined && {
270
333
  encryption: suppliedEncryption
271
- })
334
+ }),
335
+ ...(suppliedIndexes !== undefined && { indexes: suppliedIndexes })
272
336
  }
273
337
  : // New Collection
274
338
  {
@@ -278,8 +342,58 @@ export class CollectionRequest {
278
342
  backend: suppliedBackend ?? { id: DEFAULT_BACKEND_ID },
279
343
  ...(suppliedEncryption !== undefined && {
280
344
  encryption: suppliedEncryption
281
- })
345
+ }),
346
+ ...(suppliedIndexes !== undefined && { indexes: suppliedIndexes })
347
+ }
348
+
349
+ // Mutual exclusion (spec "Collection Data Model"): the description about to
350
+ // be persisted MUST NOT carry both a non-empty `indexes` and an `encryption`
351
+ // marker -- the server cannot extract plaintext attributes from an opaque
352
+ // envelope. Enforced in BOTH directions (adding `indexes` to an encrypted
353
+ // Collection, or `encryption` to an indexed one) against the merged
354
+ // description, so a pre-existing value on the other field is caught too.
355
+ if (
356
+ Array.isArray(collectionDescription.indexes) &&
357
+ collectionDescription.indexes.length > 0 &&
358
+ collectionDescription.encryption !== undefined
359
+ ) {
360
+ throw new InvalidRequestBodyError({
361
+ requestName,
362
+ detail:
363
+ 'Collection "indexes" must not be combined with an "encryption" marker.',
364
+ pointer: '#/indexes'
365
+ })
366
+ }
367
+
368
+ // Adding a `unique: true` claim for a name that was not unique before MUST
369
+ // be rejected if the Collection's already-stored Resources already violate
370
+ // it (spec "Collection Data Model"). Scan for a pre-existing conflict before
371
+ // acknowledging the update, when the data-plane backend supports the scan.
372
+ // Best-effort under concurrency (like the count-quota checks): a Resource
373
+ // write racing this update could still slip a conflicting value in, which
374
+ // the write-time uniqueness check then rejects.
375
+ const newlyUnique = newlyUniqueDeclarations({
376
+ existing: existingCollection?.indexes,
377
+ incoming: collectionDescription.indexes
378
+ })
379
+ if (newlyUnique.length > 0) {
380
+ const dataBackend = await resolveBackend({
381
+ request,
382
+ spaceId,
383
+ collectionId,
384
+ collectionDescription
385
+ })
386
+ if (dataBackend.findEqualityUniqueViolation) {
387
+ const violation = await dataBackend.findEqualityUniqueViolation({
388
+ spaceId,
389
+ collectionId,
390
+ indexes: newlyUnique
391
+ })
392
+ if (violation) {
393
+ throw new UniqueAttributeConflictError({ variant: 'equality' })
282
394
  }
395
+ }
396
+ }
283
397
 
284
398
  // `If-Match` (the `key-epochs` / conditional-Collection-write feature) makes
285
399
  // a Collection Description update a compare-and-swap on its monotonic
@@ -580,6 +694,12 @@ export class CollectionRequest {
580
694
  * the Collection's stored documents, answering `{documents, hasMore,
581
695
  * cursor?}` (matching documents verbatim, opaque-cursor paginated) or
582
696
  * `{count}`.
697
+ * - `equality` -- the plaintext equality query (the `equality-query` backend
698
+ * feature): `{equals | has, count, limit, cursor}` evaluated against the
699
+ * attributes the server extracts from the Collection's Resources per its
700
+ * declared `indexes`, answering `{documents, hasMore, cursor?}` (each
701
+ * document `{id, data?, custom?}`) or `{count}`. Only plaintext Collections
702
+ * serve it; an encrypted Collection answers `unsupported-operation` (501).
583
703
  *
584
704
  * The query parameters ride the signed JSON POST body (covered by the
585
705
  * `Digest`), so no `allowTargetQuery` is needed. A body naming any other
@@ -669,6 +789,32 @@ export class CollectionRequest {
669
789
  .type('application/json')
670
790
  .send(JSON.stringify(result))
671
791
  }
792
+ if (body?.profile === 'equality' && dataBackend.queryByEquality) {
793
+ // The `equality` profile applies only to plaintext Collections: an
794
+ // encrypted Collection's documents are opaque envelopes the server cannot
795
+ // extract attributes from, so it answers `unsupported-operation` (501).
796
+ if (collectionDescription.encryption !== undefined) {
797
+ throw new UnsupportedOperationError({ requestName })
798
+ }
799
+ // Resolve the declared indexes off the control-plane description: an
800
+ // undeclared/empty declaration means every named attribute fails the
801
+ // fail-closed declared-names check (400). Parse/validate the query body
802
+ // against it, then let the backend extract, match, and paginate.
803
+ const indexes = normalizeIndexes({
804
+ indexes: collectionDescription.indexes
805
+ })
806
+ const parsed = parseEqualityQueryBody({ body, indexes, requestName })
807
+ const result = await dataBackend.queryByEquality({
808
+ spaceId,
809
+ collectionId,
810
+ indexes,
811
+ ...parsed
812
+ })
813
+ return reply
814
+ .status(200)
815
+ .type('application/json')
816
+ .send(JSON.stringify(result))
817
+ }
672
818
 
673
819
  // Any other profile, or a backend without the profile's method.
674
820
  throw new UnsupportedOperationError({ requestName })
@@ -816,7 +962,20 @@ export class CollectionRequest {
816
962
 
817
963
  /**
818
964
  * GET /space/:spaceId/:collectionId/ (with trailing slash):
819
- * List Collection items
965
+ * List Collection items.
966
+ *
967
+ * With one or more `filter[<attr>]=<value>` query parameters this becomes the
968
+ * anonymous-cacheable entry point over the same equality machinery as the
969
+ * POST `equality` query profile: the filters map to a single-element `equals`
970
+ * conjunction (string-valued equality only) and the handler answers the same
971
+ * `{documents, hasMore, cursor?}` page. Authorization is the ordinary
972
+ * capability-or-policy GET path (a `PublicCanRead` Collection answers a filter
973
+ * query anonymously, so an HTTP cache can serve it); `allowTargetQuery`
974
+ * already tolerates the query string. Every filter attribute MUST be declared
975
+ * in the Collection's `indexes` (fail-closed 400, which also covers encrypted
976
+ * Collections -- they can never declare `indexes`); the data-plane backend
977
+ * MUST serve `queryByEquality` (else 501). With no `filter[...]` parameter the
978
+ * existing listing behavior is unchanged.
820
979
  *
821
980
  * @param request {import('fastify').FastifyRequest}
822
981
  * @param reply {import('fastify').FastifyReply}
@@ -825,14 +984,19 @@ export class CollectionRequest {
825
984
  static async list(
826
985
  request: FastifyRequest<{
827
986
  Params: { spaceId: string; collectionId: string }
828
- Querystring: { limit?: string; cursor?: string }
987
+ Querystring: Record<string, string | string[] | undefined>
829
988
  }>,
830
989
  reply: FastifyReply
831
990
  ): Promise<FastifyReply> {
832
991
  const {
833
- params: { spaceId, collectionId },
834
- query: { limit, cursor }
992
+ params: { spaceId, collectionId }
835
993
  } = request
994
+ // `limit` / `cursor` are single-valued pagination params; a repeated value
995
+ // (an array) is ignored here and falls back to the backend default.
996
+ const rawLimit = request.query.limit
997
+ const rawCursor = request.query.cursor
998
+ const limit = typeof rawLimit === 'string' ? rawLimit : undefined
999
+ const cursor = typeof rawCursor === 'string' ? rawCursor : undefined
836
1000
  const { storage } = request.server
837
1001
  const requestName = 'List Collection'
838
1002
 
@@ -867,17 +1031,62 @@ export class CollectionRequest {
867
1031
  requestName
868
1032
  })
869
1033
 
870
- // Coerce `limit` (a query string) to a positive integer; a non-numeric or
871
- // `< 1` value is ignored so the backend applies its own default. `cursor` is
872
- // opaque and passed through verbatim -- the backend validates it and rejects
873
- // a malformed one with `invalid-cursor` (400).
874
- // List from the Collection's selected (data-plane) backend.
1034
+ // List (or filter-query) from the Collection's selected (data-plane) backend.
875
1035
  const dataBackend = await resolveBackend({
876
1036
  request,
877
1037
  spaceId,
878
1038
  collectionId,
879
1039
  collectionDescription
880
1040
  })
1041
+
1042
+ // GET equality filter: `filter[<attr>]=<value>` maps to the equality profile
1043
+ // over the same machinery. Present filters take this cacheable path; their
1044
+ // absence leaves the ordinary listing below untouched.
1045
+ const filters = parseListFilter({ query: request.query, requestName })
1046
+ if (filters !== undefined) {
1047
+ // Fail-closed declared-names check, the same rule as the POST profile:
1048
+ // every filter attribute MUST be declared in the Collection's `indexes`
1049
+ // (an encrypted Collection has none, so a filter there is always a 400).
1050
+ const indexes = normalizeIndexes({
1051
+ indexes: collectionDescription.indexes
1052
+ })
1053
+ const declared = new Set(indexes.map(declaration => declaration.name))
1054
+ for (const name of Object.keys(filters)) {
1055
+ if (!declared.has(name)) {
1056
+ throw new InvalidRequestBodyError({
1057
+ requestName,
1058
+ detail: `Filter attribute "${name}" is not declared in the Collection's indexes.`,
1059
+ pointer: `#/filter/${name}`
1060
+ })
1061
+ }
1062
+ }
1063
+ if (!dataBackend.queryByEquality) {
1064
+ throw new UnsupportedOperationError({ requestName })
1065
+ }
1066
+ // The canonical GET semantics: a single-element `equals` conjunction over
1067
+ // string values. Reuse the already-parsed `limit` / `cursor` params and
1068
+ // answer the same page shape as the POST profile.
1069
+ const parsedFilterLimit = limit !== undefined ? Number(limit) : NaN
1070
+ const result = await dataBackend.queryByEquality({
1071
+ spaceId,
1072
+ collectionId,
1073
+ indexes,
1074
+ query: { equals: [{ ...filters }] },
1075
+ ...(Number.isFinite(parsedFilterLimit) && parsedFilterLimit >= 1
1076
+ ? { limit: parsedFilterLimit }
1077
+ : {}),
1078
+ ...(cursor !== undefined && { cursor })
1079
+ })
1080
+ return reply
1081
+ .status(200)
1082
+ .type('application/json')
1083
+ .send(JSON.stringify(result))
1084
+ }
1085
+
1086
+ // Coerce `limit` (a query string) to a positive integer; a non-numeric or
1087
+ // `< 1` value is ignored so the backend applies its own default. `cursor` is
1088
+ // opaque and passed through verbatim -- the backend validates it and rejects
1089
+ // a malformed one with `invalid-cursor` (400).
881
1090
  const parsedLimit = limit !== undefined ? Number(limit) : NaN
882
1091
  const collectionItems = await dataBackend.listCollectionItems({
883
1092
  spaceId,
@@ -13,6 +13,7 @@ import {
13
13
  assertEncryptedMetaConforms
14
14
  } from '../lib/encryption.js'
15
15
  import { assertValidIds } from '../lib/validateId.js'
16
+ import { normalizeIndexes } from '../lib/equalityIndex.js'
16
17
  import { resourcePath, metaPath } from '../lib/paths.js'
17
18
  import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
18
19
  import { parseKeyEpochHeader, parseMetaEpoch } from '../lib/keyEpoch.js'
@@ -171,6 +172,14 @@ export class ResourceRequest {
171
172
  headers: request.headers,
172
173
  requestName
173
174
  })
175
+ // When the Collection declares any `unique: true` index entries (the
176
+ // `equality-query` feature), pass the normalized unique entries so the
177
+ // backend enforces the plaintext uniqueness claim atomically with the write
178
+ // (409). Authorization has already run above, so the existence-revealing 409
179
+ // is observable only to a caller authorized to write here.
180
+ const uniqueIndexes = normalizeIndexes({
181
+ indexes: collectionDescription.indexes
182
+ }).filter(declaration => declaration.unique)
174
183
  // Surface any `If-Match` / `If-None-Match` write precondition to the storage
175
184
  // layer, which evaluates it atomically with the write (returning 412
176
185
  // `precondition-failed` on a mismatch -- rethrown unchanged below).
@@ -183,6 +192,7 @@ export class ResourceRequest {
183
192
  input,
184
193
  createdBy: invokerDid(request),
185
194
  epoch,
195
+ ...(uniqueIndexes.length > 0 && { uniqueIndexes }),
186
196
  ...parseWritePreconditions(request.headers)
187
197
  })
188
198
  } catch (err) {
@@ -551,6 +561,15 @@ export class ResourceRequest {
551
561
  collectionId,
552
562
  collectionDescription
553
563
  })
564
+ // When the Collection declares any `unique: true` index entries (the
565
+ // `equality-query` feature), pass the normalized unique entries so the
566
+ // backend enforces the plaintext uniqueness claim for custom-sourced
567
+ // attributes atomically with this metadata write (409). Authorization has
568
+ // already run, so the existence-revealing 409 is observable only to a caller
569
+ // authorized to write here.
570
+ const uniqueIndexes = normalizeIndexes({
571
+ indexes: collectionDescription.indexes
572
+ }).filter(declaration => declaration.unique)
554
573
  let written
555
574
  try {
556
575
  written = await dataBackend.writeResourceMetadata({
@@ -559,6 +578,7 @@ export class ResourceRequest {
559
578
  resourceId,
560
579
  custom,
561
580
  epoch,
581
+ ...(uniqueIndexes.length > 0 && { uniqueIndexes }),
562
582
  ...parseWritePreconditions(request.headers)
563
583
  })
564
584
  } catch (err) {
@@ -21,6 +21,7 @@ import {
21
21
  listRegisteredBackends
22
22
  } from '../lib/backends.js'
23
23
  import { assertSupportedEncryption } from '../lib/encryption.js'
24
+ import { assertSupportedIndexes } from '../lib/equalityIndex.js'
24
25
  import { formatEtag } from '../lib/etag.js'
25
26
  import {
26
27
  spacePath,
@@ -302,6 +303,7 @@ export class SpaceRequest {
302
303
  name?: string
303
304
  backend?: unknown
304
305
  encryption?: unknown
306
+ indexes?: unknown
305
307
  }
306
308
  }>,
307
309
  reply: FastifyReply
@@ -324,6 +326,27 @@ export class SpaceRequest {
324
326
  encryption: body?.encryption,
325
327
  requestName
326
328
  })
329
+ // Validate the optional `indexes` declaration (the `equality-query`
330
+ // feature) on the same terms as the PUT path, and enforce the
331
+ // mutual-exclusion rail here too: a Collection cannot be born both indexed
332
+ // and encrypted (the server cannot extract plaintext attributes from an
333
+ // opaque envelope).
334
+ const indexes = assertSupportedIndexes({
335
+ indexes: body?.indexes,
336
+ requestName
337
+ })
338
+ if (
339
+ indexes !== undefined &&
340
+ indexes.length > 0 &&
341
+ encryption !== undefined
342
+ ) {
343
+ throw new InvalidRequestBodyError({
344
+ requestName,
345
+ detail:
346
+ 'Collection "indexes" must not be combined with an "encryption" marker.',
347
+ pointer: '#/indexes'
348
+ })
349
+ }
327
350
 
328
351
  // Verify (capability-only): creating a Collection requires a valid
329
352
  // capability invocation; no access-control-policy fallback.
@@ -368,7 +391,8 @@ export class SpaceRequest {
368
391
  type: ['Collection'],
369
392
  name,
370
393
  backend,
371
- ...(encryption !== undefined && { encryption })
394
+ ...(encryption !== undefined && { encryption }),
395
+ ...(indexes !== undefined && { indexes })
372
396
  }
373
397
 
374
398
  const createdBy = invokerDid(request)
package/src/routes.ts CHANGED
@@ -16,6 +16,7 @@ import { SpacesRepositoryRequest } from './requests/SpacesRepositoryRequest.js'
16
16
  import { SpaceRequest } from './requests/SpaceRequest.js'
17
17
  import { handleError } from './errors.js'
18
18
  import { ResourceRequest } from './requests/ResourceRequest.js'
19
+ import { ChunkRequest } from './requests/ChunkRequest.js'
19
20
  import { CollectionRequest } from './requests/CollectionRequest.js'
20
21
  import { PolicyRequest } from './requests/PolicyRequest.js'
21
22
  import { BackendRequest } from './requests/BackendRequest.js'
@@ -351,6 +352,51 @@ export async function initResourceRoutes(
351
352
  '/space/:spaceId/:collectionId/:resourceId/meta',
352
353
  ResourceRequest.putMeta
353
354
  )
355
+
356
+ // Chunked Resource chunks (the `chunked-streams` feature). The member form
357
+ // (`chunks/:chunkIndex`) addresses one stored chunk; the container form
358
+ // (`chunks/`) is the discovery/reassembly listing. Like `meta`, the `chunks`
359
+ // segment sits below the Resource level, so it needs no reserved-id entry.
360
+
361
+ // Store a chunk by index
362
+ app.put(
363
+ '/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex/', // no trailing slash allowed
364
+ redirectStripSlash
365
+ )
366
+ app.put(
367
+ '/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
368
+ ChunkRequest.put
369
+ )
370
+
371
+ // Head Chunk. Declared before the GET route for the same reason as Head
372
+ // Resource: serve Content-Type/Content-Length from stored metadata without
373
+ // opening the byte stream.
374
+ app.head(
375
+ '/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
376
+ ChunkRequest.head
377
+ )
378
+
379
+ // Get a chunk's bytes
380
+ app.get(
381
+ '/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
382
+ ChunkRequest.get
383
+ )
384
+
385
+ // Delete a chunk
386
+ app.delete(
387
+ '/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
388
+ ChunkRequest.delete
389
+ )
390
+
391
+ // List a Resource's chunks (container form; trailing slash is canonical)
392
+ app.get(
393
+ '/space/:spaceId/:collectionId/:resourceId/chunks', // trailing slash required
394
+ redirectAddSlash
395
+ )
396
+ app.get(
397
+ '/space/:spaceId/:collectionId/:resourceId/chunks/',
398
+ ChunkRequest.list
399
+ )
354
400
  }
355
401
 
356
402
  /**