was-teaching-server 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 (295) hide show
  1. package/CHANGELOG.md +1432 -0
  2. package/LICENSE +661 -0
  3. package/README.md +305 -0
  4. package/common/css/materialize.1.0.0.min.css +13 -0
  5. package/dist/auth-header-hooks.d.ts +84 -0
  6. package/dist/auth-header-hooks.d.ts.map +1 -0
  7. package/dist/auth-header-hooks.js +136 -0
  8. package/dist/auth-header-hooks.js.map +1 -0
  9. package/dist/authorize.d.ts +45 -0
  10. package/dist/authorize.d.ts.map +1 -0
  11. package/dist/authorize.js +76 -0
  12. package/dist/authorize.js.map +1 -0
  13. package/dist/backends/filesystem.d.ts +1179 -0
  14. package/dist/backends/filesystem.d.ts.map +1 -0
  15. package/dist/backends/filesystem.js +2727 -0
  16. package/dist/backends/filesystem.js.map +1 -0
  17. package/dist/backends/postgres.d.ts +833 -0
  18. package/dist/backends/postgres.d.ts.map +1 -0
  19. package/dist/backends/postgres.js +2164 -0
  20. package/dist/backends/postgres.js.map +1 -0
  21. package/dist/backends/postgresSchema.d.ts +36 -0
  22. package/dist/backends/postgresSchema.d.ts.map +1 -0
  23. package/dist/backends/postgresSchema.js +203 -0
  24. package/dist/backends/postgresSchema.js.map +1 -0
  25. package/dist/config.default.d.ts +360 -0
  26. package/dist/config.default.d.ts.map +1 -0
  27. package/dist/config.default.js +493 -0
  28. package/dist/config.default.js.map +1 -0
  29. package/dist/corsProxy.d.ts +45 -0
  30. package/dist/corsProxy.d.ts.map +1 -0
  31. package/dist/corsProxy.js +278 -0
  32. package/dist/corsProxy.js.map +1 -0
  33. package/dist/digest.d.ts +45 -0
  34. package/dist/digest.d.ts.map +1 -0
  35. package/dist/digest.js +174 -0
  36. package/dist/digest.js.map +1 -0
  37. package/dist/errors.d.ts +654 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +960 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/atomicFile.d.ts +61 -0
  46. package/dist/lib/atomicFile.d.ts.map +1 -0
  47. package/dist/lib/atomicFile.js +150 -0
  48. package/dist/lib/atomicFile.js.map +1 -0
  49. package/dist/lib/backendRegistry.d.ts +59 -0
  50. package/dist/lib/backendRegistry.d.ts.map +1 -0
  51. package/dist/lib/backendRegistry.js +92 -0
  52. package/dist/lib/backendRegistry.js.map +1 -0
  53. package/dist/lib/backendUsage.d.ts +28 -0
  54. package/dist/lib/backendUsage.d.ts.map +1 -0
  55. package/dist/lib/backendUsage.js +55 -0
  56. package/dist/lib/backendUsage.js.map +1 -0
  57. package/dist/lib/backends.d.ts +142 -0
  58. package/dist/lib/backends.d.ts.map +1 -0
  59. package/dist/lib/backends.js +272 -0
  60. package/dist/lib/backends.js.map +1 -0
  61. package/dist/lib/blindedIndex.d.ts +156 -0
  62. package/dist/lib/blindedIndex.d.ts.map +1 -0
  63. package/dist/lib/blindedIndex.js +338 -0
  64. package/dist/lib/blindedIndex.js.map +1 -0
  65. package/dist/lib/collectionDescription.d.ts +33 -0
  66. package/dist/lib/collectionDescription.d.ts.map +1 -0
  67. package/dist/lib/collectionDescription.js +21 -0
  68. package/dist/lib/collectionDescription.js.map +1 -0
  69. package/dist/lib/cursor.d.ts +18 -0
  70. package/dist/lib/cursor.d.ts.map +1 -0
  71. package/dist/lib/cursor.js +50 -0
  72. package/dist/lib/cursor.js.map +1 -0
  73. package/dist/lib/edvEnvelope.d.ts +50 -0
  74. package/dist/lib/edvEnvelope.d.ts.map +1 -0
  75. package/dist/lib/edvEnvelope.js +106 -0
  76. package/dist/lib/edvEnvelope.js.map +1 -0
  77. package/dist/lib/encryption.d.ts +192 -0
  78. package/dist/lib/encryption.d.ts.map +1 -0
  79. package/dist/lib/encryption.js +383 -0
  80. package/dist/lib/encryption.js.map +1 -0
  81. package/dist/lib/etag.d.ts +33 -0
  82. package/dist/lib/etag.d.ts.map +1 -0
  83. package/dist/lib/etag.js +36 -0
  84. package/dist/lib/etag.js.map +1 -0
  85. package/dist/lib/exportManifest.d.ts +29 -0
  86. package/dist/lib/exportManifest.d.ts.map +1 -0
  87. package/dist/lib/exportManifest.js +77 -0
  88. package/dist/lib/exportManifest.js.map +1 -0
  89. package/dist/lib/importTar.d.ts +81 -0
  90. package/dist/lib/importTar.d.ts.map +1 -0
  91. package/dist/lib/importTar.js +301 -0
  92. package/dist/lib/importTar.js.map +1 -0
  93. package/dist/lib/isJson.d.ts +16 -0
  94. package/dist/lib/isJson.d.ts.map +1 -0
  95. package/dist/lib/isJson.js +17 -0
  96. package/dist/lib/isJson.js.map +1 -0
  97. package/dist/lib/keyEpoch.d.ts +38 -0
  98. package/dist/lib/keyEpoch.d.ts.map +1 -0
  99. package/dist/lib/keyEpoch.js +75 -0
  100. package/dist/lib/keyEpoch.js.map +1 -0
  101. package/dist/lib/keyedMutex.d.ts +28 -0
  102. package/dist/lib/keyedMutex.d.ts.map +1 -0
  103. package/dist/lib/keyedMutex.js +45 -0
  104. package/dist/lib/keyedMutex.js.map +1 -0
  105. package/dist/lib/kmsModule.d.ts +62 -0
  106. package/dist/lib/kmsModule.d.ts.map +1 -0
  107. package/dist/lib/kmsModule.js +327 -0
  108. package/dist/lib/kmsModule.js.map +1 -0
  109. package/dist/lib/kmsRecordCipher.d.ts +82 -0
  110. package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
  111. package/dist/lib/kmsRecordCipher.js +226 -0
  112. package/dist/lib/kmsRecordCipher.js.map +1 -0
  113. package/dist/lib/pagination.d.ts +19 -0
  114. package/dist/lib/pagination.d.ts.map +1 -0
  115. package/dist/lib/pagination.js +21 -0
  116. package/dist/lib/pagination.js.map +1 -0
  117. package/dist/lib/paths.d.ts +273 -0
  118. package/dist/lib/paths.d.ts.map +1 -0
  119. package/dist/lib/paths.js +270 -0
  120. package/dist/lib/paths.js.map +1 -0
  121. package/dist/lib/preconditions.d.ts +65 -0
  122. package/dist/lib/preconditions.d.ts.map +1 -0
  123. package/dist/lib/preconditions.js +112 -0
  124. package/dist/lib/preconditions.js.map +1 -0
  125. package/dist/lib/resourceFileName.d.ts +41 -0
  126. package/dist/lib/resourceFileName.d.ts.map +1 -0
  127. package/dist/lib/resourceFileName.js +58 -0
  128. package/dist/lib/resourceFileName.js.map +1 -0
  129. package/dist/lib/revocations.d.ts +49 -0
  130. package/dist/lib/revocations.d.ts.map +1 -0
  131. package/dist/lib/revocations.js +86 -0
  132. package/dist/lib/revocations.js.map +1 -0
  133. package/dist/lib/validateDid.d.ts +19 -0
  134. package/dist/lib/validateDid.d.ts.map +1 -0
  135. package/dist/lib/validateDid.js +35 -0
  136. package/dist/lib/validateDid.js.map +1 -0
  137. package/dist/lib/validateId.d.ts +66 -0
  138. package/dist/lib/validateId.d.ts.map +1 -0
  139. package/dist/lib/validateId.js +126 -0
  140. package/dist/lib/validateId.js.map +1 -0
  141. package/dist/plugin.d.ts +115 -0
  142. package/dist/plugin.d.ts.map +1 -0
  143. package/dist/plugin.js +134 -0
  144. package/dist/plugin.js.map +1 -0
  145. package/dist/policy.d.ts +78 -0
  146. package/dist/policy.d.ts.map +1 -0
  147. package/dist/policy.js +115 -0
  148. package/dist/policy.js.map +1 -0
  149. package/dist/provisioning.d.ts +38 -0
  150. package/dist/provisioning.d.ts.map +1 -0
  151. package/dist/provisioning.js +72 -0
  152. package/dist/provisioning.js.map +1 -0
  153. package/dist/requests/BackendRequest.d.ts +61 -0
  154. package/dist/requests/BackendRequest.d.ts.map +1 -0
  155. package/dist/requests/BackendRequest.js +152 -0
  156. package/dist/requests/BackendRequest.js.map +1 -0
  157. package/dist/requests/CollectionRequest.d.ts +234 -0
  158. package/dist/requests/CollectionRequest.d.ts.map +1 -0
  159. package/dist/requests/CollectionRequest.js +705 -0
  160. package/dist/requests/CollectionRequest.js.map +1 -0
  161. package/dist/requests/KeyRequest.d.ts +115 -0
  162. package/dist/requests/KeyRequest.d.ts.map +1 -0
  163. package/dist/requests/KeyRequest.js +468 -0
  164. package/dist/requests/KeyRequest.js.map +1 -0
  165. package/dist/requests/KeystoreRequest.d.ts +99 -0
  166. package/dist/requests/KeystoreRequest.d.ts.map +1 -0
  167. package/dist/requests/KeystoreRequest.js +247 -0
  168. package/dist/requests/KeystoreRequest.js.map +1 -0
  169. package/dist/requests/PolicyRequest.d.ts +53 -0
  170. package/dist/requests/PolicyRequest.d.ts.map +1 -0
  171. package/dist/requests/PolicyRequest.js +124 -0
  172. package/dist/requests/PolicyRequest.js.map +1 -0
  173. package/dist/requests/ResourceRequest.d.ts +134 -0
  174. package/dist/requests/ResourceRequest.d.ts.map +1 -0
  175. package/dist/requests/ResourceRequest.js +548 -0
  176. package/dist/requests/ResourceRequest.js.map +1 -0
  177. package/dist/requests/RevocationRequest.d.ts +67 -0
  178. package/dist/requests/RevocationRequest.d.ts.map +1 -0
  179. package/dist/requests/RevocationRequest.js +187 -0
  180. package/dist/requests/RevocationRequest.js.map +1 -0
  181. package/dist/requests/SpaceRequest.d.ts +204 -0
  182. package/dist/requests/SpaceRequest.d.ts.map +1 -0
  183. package/dist/requests/SpaceRequest.js +520 -0
  184. package/dist/requests/SpaceRequest.js.map +1 -0
  185. package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
  186. package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
  187. package/dist/requests/SpacesRepositoryRequest.js +159 -0
  188. package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
  189. package/dist/requests/collectionContext.d.ts +21 -0
  190. package/dist/requests/collectionContext.d.ts.map +1 -0
  191. package/dist/requests/collectionContext.js +30 -0
  192. package/dist/requests/collectionContext.js.map +1 -0
  193. package/dist/requests/controllerConsent.d.ts +58 -0
  194. package/dist/requests/controllerConsent.d.ts.map +1 -0
  195. package/dist/requests/controllerConsent.js +73 -0
  196. package/dist/requests/controllerConsent.js.map +1 -0
  197. package/dist/requests/keystoreContext.d.ts +68 -0
  198. package/dist/requests/keystoreContext.d.ts.map +1 -0
  199. package/dist/requests/keystoreContext.js +87 -0
  200. package/dist/requests/keystoreContext.js.map +1 -0
  201. package/dist/requests/resourceInput.d.ts +19 -0
  202. package/dist/requests/resourceInput.d.ts.map +1 -0
  203. package/dist/requests/resourceInput.js +102 -0
  204. package/dist/requests/resourceInput.js.map +1 -0
  205. package/dist/requests/spaceContext.d.ts +127 -0
  206. package/dist/requests/spaceContext.d.ts.map +1 -0
  207. package/dist/requests/spaceContext.js +201 -0
  208. package/dist/requests/spaceContext.js.map +1 -0
  209. package/dist/routes.d.ts +55 -0
  210. package/dist/routes.d.ts.map +1 -0
  211. package/dist/routes.js +306 -0
  212. package/dist/routes.js.map +1 -0
  213. package/dist/server.d.ts +20 -0
  214. package/dist/server.d.ts.map +1 -0
  215. package/dist/server.js +60 -0
  216. package/dist/server.js.map +1 -0
  217. package/dist/start.d.ts +7 -0
  218. package/dist/start.d.ts.map +1 -0
  219. package/dist/start.js +61 -0
  220. package/dist/start.js.map +1 -0
  221. package/dist/storage.d.ts +29 -0
  222. package/dist/storage.d.ts.map +1 -0
  223. package/dist/storage.js +47 -0
  224. package/dist/storage.js.map +1 -0
  225. package/dist/types.d.ts +915 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/views/home.hbs +9 -0
  230. package/dist/views/templates/main.hbs +12 -0
  231. package/dist/zcap.d.ts +267 -0
  232. package/dist/zcap.d.ts.map +1 -0
  233. package/dist/zcap.js +478 -0
  234. package/dist/zcap.js.map +1 -0
  235. package/package.json +109 -0
  236. package/src/auth-header-hooks.ts +163 -0
  237. package/src/authorize.ts +117 -0
  238. package/src/backends/filesystem.ts +3626 -0
  239. package/src/backends/postgres.ts +3073 -0
  240. package/src/backends/postgresSchema.ts +231 -0
  241. package/src/config.default.ts +657 -0
  242. package/src/corsProxy.ts +326 -0
  243. package/src/declarations.d.ts +28 -0
  244. package/src/digest.ts +223 -0
  245. package/src/errors.ts +1122 -0
  246. package/src/index.ts +21 -0
  247. package/src/lib/atomicFile.ts +176 -0
  248. package/src/lib/backendRegistry.ts +139 -0
  249. package/src/lib/backendUsage.ts +79 -0
  250. package/src/lib/backends.ts +351 -0
  251. package/src/lib/blindedIndex.ts +466 -0
  252. package/src/lib/collectionDescription.ts +43 -0
  253. package/src/lib/cursor.ts +52 -0
  254. package/src/lib/edvEnvelope.ts +120 -0
  255. package/src/lib/encryption.ts +472 -0
  256. package/src/lib/etag.ts +40 -0
  257. package/src/lib/exportManifest.ts +115 -0
  258. package/src/lib/importTar.ts +381 -0
  259. package/src/lib/isJson.ts +18 -0
  260. package/src/lib/keyEpoch.ts +89 -0
  261. package/src/lib/keyedMutex.ts +45 -0
  262. package/src/lib/kmsModule.ts +451 -0
  263. package/src/lib/kmsRecordCipher.ts +292 -0
  264. package/src/lib/pagination.ts +22 -0
  265. package/src/lib/paths.ts +382 -0
  266. package/src/lib/preconditions.ts +145 -0
  267. package/src/lib/resourceFileName.ts +69 -0
  268. package/src/lib/revocations.ts +116 -0
  269. package/src/lib/validateDid.ts +41 -0
  270. package/src/lib/validateId.ts +148 -0
  271. package/src/plugin.ts +288 -0
  272. package/src/policy.ts +176 -0
  273. package/src/provisioning.ts +98 -0
  274. package/src/requests/BackendRequest.ts +219 -0
  275. package/src/requests/CollectionRequest.ts +899 -0
  276. package/src/requests/KeyRequest.ts +617 -0
  277. package/src/requests/KeystoreRequest.ts +325 -0
  278. package/src/requests/PolicyRequest.ts +169 -0
  279. package/src/requests/ResourceRequest.ts +649 -0
  280. package/src/requests/RevocationRequest.ts +267 -0
  281. package/src/requests/SpaceRequest.ts +686 -0
  282. package/src/requests/SpacesRepositoryRequest.ts +194 -0
  283. package/src/requests/collectionContext.ts +41 -0
  284. package/src/requests/controllerConsent.ts +108 -0
  285. package/src/requests/keystoreContext.ts +124 -0
  286. package/src/requests/resourceInput.ts +118 -0
  287. package/src/requests/spaceContext.ts +311 -0
  288. package/src/routes.ts +420 -0
  289. package/src/server.ts +69 -0
  290. package/src/start.ts +71 -0
  291. package/src/storage.ts +60 -0
  292. package/src/types.ts +1016 -0
  293. package/src/views/home.hbs +9 -0
  294. package/src/views/templates/main.hbs +12 -0
  295. package/src/zcap.ts +657 -0
@@ -0,0 +1,899 @@
1
+ /**
2
+ * Request handlers for Collection operations: get/update/delete a Collection,
3
+ * list its items, and add a Resource to it.
4
+ */
5
+ import type { FastifyReply, FastifyRequest } from 'fastify'
6
+ import { v4 as uuidv4 } from 'uuid'
7
+ import type { ChangeDocument } from '@interop/storage-core'
8
+
9
+ import { buildLinkset } from '../policy.js'
10
+ import { fetchSpaceAndAuthorize, fetchSpaceAndVerify } from './spaceContext.js'
11
+ import { getCollectionOrThrow } from './collectionContext.js'
12
+ import { resolveResourceInput } from './resourceInput.js'
13
+ import { invokerDid } from '../auth-header-hooks.js'
14
+ import { assertValidIds } from '../lib/validateId.js'
15
+ import type { CollectionDescription, StorageBackend } from '../types.js'
16
+ import { parseBlindedIndexQueryBody } from '../lib/blindedIndex.js'
17
+ import {
18
+ assertSupportedBackend,
19
+ resolveBackendDescriptor,
20
+ DEFAULT_BACKEND_ID
21
+ } from '../lib/backends.js'
22
+ import {
23
+ assertSupportedEncryption,
24
+ assertEncryptionMarkerTransition,
25
+ assertEncryptedWriteConforms
26
+ } from '../lib/encryption.js'
27
+ import { parseKeyEpochHeader } from '../lib/keyEpoch.js'
28
+ import { resolveBackend } from '../lib/backendRegistry.js'
29
+ import {
30
+ collectionPath,
31
+ resourcePath,
32
+ linksetPath,
33
+ backendPath,
34
+ quotaPath,
35
+ queryPath
36
+ } from '../lib/paths.js'
37
+ import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
38
+ import {
39
+ InvalidCollectionError,
40
+ InvalidRequestBodyError,
41
+ UnsupportedOperationError,
42
+ rethrowOrWrapStorageError
43
+ } from '../errors.js'
44
+
45
+ export class CollectionRequest {
46
+ /**
47
+ * POST /space/:spaceId/:collectionId/
48
+ * Request handler for "Create Resource" request
49
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
50
+ * request.zcap: {
51
+ * keyId, headers, signature, created, expires, invocation, digest
52
+ * }
53
+ *
54
+ * @param request {import('fastify').FastifyRequest}
55
+ * @param reply {import('fastify').FastifyReply}
56
+ * @returns {Promise<FastifyReply>}
57
+ */
58
+ static async post(
59
+ request: FastifyRequest<{
60
+ Params: { spaceId: string; collectionId: string }
61
+ }>,
62
+ reply: FastifyReply
63
+ ): Promise<FastifyReply> {
64
+ const {
65
+ params: { spaceId, collectionId }
66
+ } = request
67
+ const { serverUrl, storage } = request.server
68
+ const requestName = 'Create Resource'
69
+
70
+ // Reject path-traversal / non-URL-safe ids before any storage access.
71
+ assertValidIds({ spaceId, collectionId }, { requestName })
72
+
73
+ // Verify (capability-only): creating a Resource requires a valid capability
74
+ // invocation; no access-control-policy fallback.
75
+ await fetchSpaceAndVerify({
76
+ request,
77
+ spaceId,
78
+ targetPath: collectionPath({
79
+ spaceId,
80
+ collectionId,
81
+ trailingSlash: true
82
+ }),
83
+ requestName
84
+ })
85
+
86
+ // Fetch collection by id
87
+ const collectionDescription = await getCollectionOrThrow({
88
+ storage,
89
+ spaceId,
90
+ collectionId,
91
+ requestName
92
+ })
93
+
94
+ // Fail-closed encryption enforcement: if the Collection declares a recognized
95
+ // `encryption` scheme, the content write MUST be a conforming envelope of it
96
+ // (right media type + envelope shape), else `encryption-scheme-mismatch`
97
+ // (422). Runs after auth (above) and the 404, before the body is resolved --
98
+ // so a wrong content type is rejected without consuming the upload, and the
99
+ // 422 is only observable by a caller already authorized to write here.
100
+ assertEncryptedWriteConforms({
101
+ collectionDescription,
102
+ contentType: request.headers['content-type'],
103
+ body: request.body
104
+ })
105
+
106
+ // zCap checks out, continue
107
+ const resourceId = uuidv4()
108
+ let response: { id: string; 'content-type'?: string; url?: string }
109
+ let written: { version: number }
110
+
111
+ // Route resource bytes to the Collection's selected (data-plane) backend.
112
+ const dataBackend = await resolveBackend({
113
+ request,
114
+ spaceId,
115
+ collectionId,
116
+ collectionDescription
117
+ })
118
+ const input = await resolveResourceInput(request, dataBackend)
119
+ // A content write into an encrypted Collection MAY declare the key epoch it
120
+ // encrypted under via the `WAS-Key-Epoch` header (the `key-epochs` feature);
121
+ // the server stores it opaquely and clears it when absent (the new
122
+ // ciphertext's epoch is unknown). Advisory, non-signature-covered metadata.
123
+ const { epoch } = parseKeyEpochHeader({
124
+ headers: request.headers,
125
+ requestName
126
+ })
127
+ try {
128
+ written = await dataBackend.writeResource({
129
+ spaceId,
130
+ collectionId,
131
+ resourceId,
132
+ input,
133
+ createdBy: invokerDid(request),
134
+ epoch
135
+ })
136
+ response = {
137
+ id: resourceId,
138
+ 'content-type': request.headers['content-type']
139
+ }
140
+ } catch (err) {
141
+ rethrowOrWrapStorageError({ err, requestName })
142
+ }
143
+
144
+ const createdUrl = new URL(
145
+ resourcePath({ spaceId, collectionId, resourceId }),
146
+ serverUrl
147
+ ).toString()
148
+ reply.header('Location', createdUrl)
149
+ // Surface the created Resource's ETag so a client can chain a conditional
150
+ // write (the conditional-writes feature).
151
+ reply.header('etag', formatEtag(written.version))
152
+ response.url = createdUrl
153
+
154
+ return reply.status(201).send(response)
155
+ }
156
+
157
+ /**
158
+ * PUT /space/:spaceId/:collectionId
159
+ * Request handler for "Update (or Create By Id) Collection" request
160
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
161
+ * request.zcap: {
162
+ * keyId, headers, signature, created, expires, invocation, digest
163
+ * }
164
+ *
165
+ * @param request {import('fastify').FastifyRequest}
166
+ * @param reply {import('fastify').FastifyReply}
167
+ * @returns {Promise<FastifyReply>}
168
+ */
169
+ static async put(
170
+ request: FastifyRequest<{
171
+ Params: { spaceId: string; collectionId: string }
172
+ Body: {
173
+ id?: string
174
+ name?: string
175
+ backend?: unknown
176
+ encryption?: unknown
177
+ }
178
+ }>,
179
+ reply: FastifyReply
180
+ ): Promise<FastifyReply> {
181
+ const {
182
+ params: { spaceId, collectionId },
183
+ body
184
+ } = request
185
+ if (!body) {
186
+ throw new InvalidCollectionError()
187
+ }
188
+ const { storage } = request.server
189
+ const requestName = 'Update Collection'
190
+
191
+ // Reject path-traversal / non-URL-safe ids before any storage access.
192
+ assertValidIds({ spaceId, collectionId }, { requestName })
193
+
194
+ // The Collection `id` is immutable: when the PUT body carries one, it must
195
+ // match the `{collection_id}` in the URL (spec spells this out for Update
196
+ // Space; applied here for parity). `invalid-request-body` (400).
197
+ if (body.id !== undefined && body.id !== collectionId) {
198
+ throw new InvalidRequestBodyError({
199
+ requestName,
200
+ detail: `Collection Description "id" (${body.id}) does not match the URL Collection id (${collectionId}).`,
201
+ pointer: '#/id'
202
+ })
203
+ }
204
+ // Validate the optional encryption marker (shape only); an absent
205
+ // `encryption` validates to `undefined` and leaves the existing marker
206
+ // untouched. The set-once immutability transition is enforced below, after
207
+ // the existing Collection is fetched (so an unauthorized caller cannot probe
208
+ // encryption state).
209
+ const suppliedEncryption = assertSupportedEncryption({
210
+ encryption: body.encryption,
211
+ requestName
212
+ })
213
+
214
+ // Verify (capability-only): updating a Collection requires a valid
215
+ // capability invocation; no access-control-policy fallback.
216
+ const { allowedTarget: collectionUrl } = await fetchSpaceAndVerify({
217
+ request,
218
+ spaceId,
219
+ targetPath: collectionPath({ spaceId, collectionId }),
220
+ requestName
221
+ })
222
+
223
+ // zCap checks out, continue.
224
+ // Validate a supplied backend against the Space's backends-available (bad
225
+ // shape 400, unknown id 409). Checked AFTER verification (it reads the
226
+ // Space's registered backend ids) so an unauthorized caller cannot probe
227
+ // which ids are registered by distinguishing a 409 from the masked 404 --
228
+ // like the `id-conflict` / `encryption-immutable` conflict checks. An absent
229
+ // `backend` resolves to undefined so an update leaves the existing selection
230
+ // untouched; a create defaults it to the server default below.
231
+ const suppliedBackend =
232
+ body.backend !== undefined
233
+ ? await assertSupportedBackend({
234
+ storage,
235
+ spaceId,
236
+ backend: body.backend,
237
+ requestName
238
+ })
239
+ : undefined
240
+
241
+ const existingCollection = await storage.getCollectionDescription({
242
+ spaceId,
243
+ collectionId
244
+ })
245
+ // The encryption marker is set-once (an update may declare one on a
246
+ // Collection that lacks it, but may not change/clear an existing one --
247
+ // `encryption-immutable` 409), and the key-epoch safety rails (the
248
+ // `key-epochs` feature) make epochs append-only with a `currentEpoch` that
249
+ // never moves backwards; recipient churn within an epoch stays free.
250
+ // Checked here, after verification, for a clean early rejection --
251
+ // re-evaluated atomically with the write via `assertTransition` below.
252
+ if (suppliedEncryption !== undefined) {
253
+ assertEncryptionMarkerTransition({
254
+ existing: existingCollection?.encryption,
255
+ incoming: suppliedEncryption
256
+ })
257
+ }
258
+ // `name`, `backend`, and `encryption` are optional. On update, only
259
+ // overwrite each when supplied (otherwise keep the existing value); on
260
+ // create, default `name` to the Collection id and `backend` to the server
261
+ // default (spec).
262
+ const collectionDescription = existingCollection
263
+ ? // Existing: Update only the allowed fields
264
+ {
265
+ ...existingCollection,
266
+ id: collectionId,
267
+ ...(body.name !== undefined && { name: body.name }),
268
+ ...(suppliedBackend !== undefined && { backend: suppliedBackend }),
269
+ ...(suppliedEncryption !== undefined && {
270
+ encryption: suppliedEncryption
271
+ })
272
+ }
273
+ : // New Collection
274
+ {
275
+ id: collectionId,
276
+ type: ['Collection'],
277
+ name: body.name ?? collectionId,
278
+ backend: suppliedBackend ?? { id: DEFAULT_BACKEND_ID },
279
+ ...(suppliedEncryption !== undefined && {
280
+ encryption: suppliedEncryption
281
+ })
282
+ }
283
+
284
+ // `If-Match` (the `key-epochs` / conditional-Collection-write feature) makes
285
+ // a Collection Description update a compare-and-swap on its monotonic
286
+ // description version, so two clients concurrently editing the marker (e.g.
287
+ // both adding a recipient) cannot silently clobber one another. Opt-in: an
288
+ // unconditional PUT still upserts as before. Evaluated atomically with the
289
+ // write inside the backend; a stale validator surfaces as 412
290
+ // `precondition-failed` (rethrown unchanged).
291
+ const { ifMatch } = parseWritePreconditions(request.headers)
292
+ let written: { version: number }
293
+ try {
294
+ written = await storage.writeCollection({
295
+ spaceId,
296
+ collectionId,
297
+ collectionDescription,
298
+ createdBy: invokerDid(request),
299
+ ...(ifMatch !== undefined && { ifMatch }),
300
+ // Re-evaluate the encryption-marker rails atomically with the write,
301
+ // against the prior the backend re-reads under its lock: the early
302
+ // check above ran against a pre-lock read, so without this a
303
+ // concurrent marker write in between could be silently clobbered (an
304
+ // appended epoch dropped by this full replacement) even though both
305
+ // writers passed the rails -- the append-only guarantee must hold
306
+ // unconditionally, not just under `If-Match`.
307
+ assertTransition: prior =>
308
+ assertEncryptionMarkerTransition({
309
+ existing: prior?.encryption,
310
+ incoming: collectionDescription.encryption
311
+ })
312
+ })
313
+ } catch (err) {
314
+ // Rethrow a typed ProblemError from the data-plane backend unchanged
315
+ // (e.g. a 507 quota / 412 precondition) rather than flattening it to a
316
+ // 500; wrap anything genuinely unexpected. `handleError` logs the 5xx once.
317
+ rethrowOrWrapStorageError({ err, requestName })
318
+ }
319
+
320
+ reply.header('Location', collectionUrl)
321
+ // Surface the new description ETag so a client can chain a conditional
322
+ // update (read-modify-CAS on the marker).
323
+ reply.header('etag', formatEtag(written.version))
324
+ return existingCollection
325
+ ? reply.status(204).send() // update
326
+ : reply.status(201).send(collectionDescription) // create
327
+ }
328
+
329
+ /**
330
+ * GET /space/:spaceId/:collectionId (no trailing slash): Get Collection details
331
+ *
332
+ * @param request {import('fastify').FastifyRequest}
333
+ * @param reply {import('fastify').FastifyReply}
334
+ * @returns {Promise<FastifyReply>}
335
+ */
336
+ static async get(
337
+ request: FastifyRequest<{
338
+ Params: { spaceId: string; collectionId: string }
339
+ }>,
340
+ reply: FastifyReply
341
+ ): Promise<FastifyReply> {
342
+ const {
343
+ params: { spaceId, collectionId }
344
+ } = request
345
+ const { storage } = request.server
346
+ const requestName = 'Get Collection'
347
+
348
+ // Reject path-traversal / non-URL-safe ids before any storage access.
349
+ assertValidIds({ spaceId, collectionId }, { requestName })
350
+
351
+ // Authorize (capability-or-policy): capability invocation first, then the
352
+ // effective access-control policy as a fallback (a public-readable Collection).
353
+ await fetchSpaceAndAuthorize({
354
+ request,
355
+ spaceId,
356
+ collectionId,
357
+ targetPath: collectionPath({ spaceId, collectionId }),
358
+ requestName
359
+ })
360
+
361
+ // Fetch collection by id
362
+ const collectionDescription = await getCollectionOrThrow({
363
+ storage,
364
+ spaceId,
365
+ collectionId,
366
+ requestName
367
+ })
368
+
369
+ // Advertise the Collection's self `url` and linkset (policy discovery) on
370
+ // the description; both relative, consistent with the other URL fields the
371
+ // API returns.
372
+ const url = collectionPath({ spaceId, collectionId })
373
+ const linkset = linksetPath({ spaceId, collectionId })
374
+
375
+ // Report the selected backend, default-filled for Collections created before
376
+ // the `backend` property existed (spec: an unset backend is `default`).
377
+ const backend = collectionDescription.backend ?? { id: DEFAULT_BACKEND_ID }
378
+
379
+ // The description `version` is the out-of-band ETag validator, not part of
380
+ // the Collection Description wire body, so strip it before serializing and
381
+ // surface it as the `ETag` header (so a client can read-modify-CAS the
382
+ // marker). Present only once the Collection has been written under
383
+ // versioning; a legacy Collection reports none.
384
+ const { descriptionVersion, ...descriptionBody } = collectionDescription
385
+ const getReply = reply.status(200).type('application/json')
386
+ if (descriptionVersion !== undefined) {
387
+ getReply.header('etag', formatEtag(descriptionVersion))
388
+ }
389
+ return getReply.send(
390
+ JSON.stringify({
391
+ ...descriptionBody,
392
+ type: [...collectionDescription.type].sort(),
393
+ backend,
394
+ url,
395
+ linkset
396
+ } satisfies CollectionDescription)
397
+ )
398
+ }
399
+
400
+ /**
401
+ * GET /space/:spaceId/:collectionId/linkset
402
+ * Request handler for the Collection's linkset (RFC9264): advertises the
403
+ * Collection's access-control `policy` and selected `backend` resources for
404
+ * discovery. Readable by whoever may read the Collection (capability or
405
+ * fallback policy).
406
+ *
407
+ * @param request {import('fastify').FastifyRequest}
408
+ * @param reply {import('fastify').FastifyReply}
409
+ * @returns {Promise<FastifyReply>}
410
+ */
411
+ static async linkset(
412
+ request: FastifyRequest<{
413
+ Params: { spaceId: string; collectionId: string }
414
+ }>,
415
+ reply: FastifyReply
416
+ ): Promise<FastifyReply> {
417
+ const {
418
+ params: { spaceId, collectionId }
419
+ } = request
420
+ const { storage } = request.server
421
+ const requestName = 'Get Collection Linkset'
422
+
423
+ // Reject path-traversal / non-URL-safe ids before any storage access.
424
+ assertValidIds({ spaceId, collectionId }, { requestName })
425
+
426
+ // Authorize (capability-or-policy): readable by whoever may read the
427
+ // Collection (capability invocation, else the effective policy).
428
+ await fetchSpaceAndAuthorize({
429
+ request,
430
+ spaceId,
431
+ collectionId,
432
+ targetPath: linksetPath({ spaceId, collectionId }),
433
+ requestName
434
+ })
435
+
436
+ const linkset = await buildLinkset({ storage, spaceId, collectionId })
437
+ return reply
438
+ .status(200)
439
+ .type('application/linkset+json')
440
+ .send(JSON.stringify(linkset))
441
+ }
442
+
443
+ /**
444
+ * GET /space/:spaceId/:collectionId/backend
445
+ * Request handler for "Collection Backend Selected": returns the detailed
446
+ * backend description object for the Collection's selected backend (resolved
447
+ * from the Collection's stored `{ id }` against the Space's backends-available;
448
+ * default-filled for Collections created before the property existed).
449
+ *
450
+ * Authorization is capability-or-policy, the same as Get Collection: the
451
+ * selected backend is no more sensitive than the Collection description, so a
452
+ * public-readable Collection may also read its backend.
453
+ *
454
+ * @param request {import('fastify').FastifyRequest}
455
+ * @param reply {import('fastify').FastifyReply}
456
+ * @returns {Promise<FastifyReply>}
457
+ */
458
+ static async getBackend(
459
+ request: FastifyRequest<{
460
+ Params: { spaceId: string; collectionId: string }
461
+ }>,
462
+ reply: FastifyReply
463
+ ): Promise<FastifyReply> {
464
+ const {
465
+ params: { spaceId, collectionId }
466
+ } = request
467
+ const { storage } = request.server
468
+ const requestName = 'Get Collection Backend'
469
+
470
+ // Reject path-traversal / non-URL-safe ids before any storage access.
471
+ assertValidIds({ spaceId, collectionId }, { requestName })
472
+
473
+ // Authorize (capability-or-policy): readable by whoever may read the
474
+ // Collection (capability invocation, else the effective policy).
475
+ await fetchSpaceAndAuthorize({
476
+ request,
477
+ spaceId,
478
+ collectionId,
479
+ targetPath: backendPath({ spaceId, collectionId }),
480
+ requestName
481
+ })
482
+
483
+ // Fetch collection by id
484
+ const collectionDescription = await getCollectionOrThrow({
485
+ storage,
486
+ spaceId,
487
+ collectionId,
488
+ requestName
489
+ })
490
+
491
+ const backend = await resolveBackendDescriptor({
492
+ storage,
493
+ spaceId,
494
+ collectionDescription
495
+ })
496
+ return reply.status(200).type('application/json').send(backend)
497
+ }
498
+
499
+ /**
500
+ * GET /space/:spaceId/:collectionId/quota
501
+ * Request handler for the per-Collection "Quotas" report (spec "Quotas"):
502
+ * the storage report for a single Collection, scoped to its backend (a single
503
+ * backend-usage entry whose `usageBytes` reflects only this Collection). A
504
+ * backend that cannot account per-Collection yields `unsupported-operation`
505
+ * (501); the filesystem backend supports it.
506
+ *
507
+ * Authorization is capability-or-policy, the same as the Space Quota report:
508
+ * a caller not authorized to read it receives a 404 (maximum-privacy
509
+ * invariant), and a public-readable Collection may read its quota.
510
+ *
511
+ * @param request {import('fastify').FastifyRequest}
512
+ * @param reply {import('fastify').FastifyReply}
513
+ * @returns {Promise<FastifyReply>}
514
+ */
515
+ static async getQuota(
516
+ request: FastifyRequest<{
517
+ Params: { spaceId: string; collectionId: string }
518
+ }>,
519
+ reply: FastifyReply
520
+ ): Promise<FastifyReply> {
521
+ const {
522
+ params: { spaceId, collectionId }
523
+ } = request
524
+ const { storage } = request.server
525
+ const requestName = 'Get Collection Quota'
526
+
527
+ // Reject path-traversal / non-URL-safe ids before any storage access.
528
+ assertValidIds({ spaceId, collectionId }, { requestName })
529
+
530
+ // Authorize (capability-or-policy): readable by whoever may read the
531
+ // Collection (capability invocation, else the effective policy).
532
+ await fetchSpaceAndAuthorize({
533
+ request,
534
+ spaceId,
535
+ collectionId,
536
+ targetPath: quotaPath({ spaceId, collectionId }),
537
+ requestName
538
+ })
539
+
540
+ // Fetch collection by id
541
+ const collectionDescription = await getCollectionOrThrow({
542
+ storage,
543
+ spaceId,
544
+ collectionId,
545
+ requestName
546
+ })
547
+
548
+ // Report against the Collection's selected (data-plane) backend. A backend
549
+ // that cannot account per-Collection omits `reportCollectionUsage`; the spec
550
+ // sanctions a 501 there.
551
+ const dataBackend = await resolveBackend({
552
+ request,
553
+ spaceId,
554
+ collectionId,
555
+ collectionDescription
556
+ })
557
+ if (!dataBackend.reportCollectionUsage) {
558
+ throw new UnsupportedOperationError({ requestName })
559
+ }
560
+
561
+ const usage = await dataBackend.reportCollectionUsage({
562
+ spaceId,
563
+ collectionId
564
+ })
565
+ return reply.status(200).type('application/json').send(usage)
566
+ }
567
+
568
+ /**
569
+ * POST /space/:spaceId/:collectionId/query
570
+ * The reserved Collection `query` endpoint (spec "Collection-level reserved
571
+ * endpoints"). This server serves two profiles, selected by the body's
572
+ * `profile`:
573
+ *
574
+ * - `changes` -- the replication change feed: the Collection's JSON
575
+ * documents and tombstones changed strictly after `checkpoint`, in change
576
+ * order, capped at `limit`.
577
+ * - `blinded-index` -- the EDV blinded-attribute query (the
578
+ * `blinded-index-query` backend feature): `{index, equals | has, count,
579
+ * limit, cursor}` evaluated against the HMAC-blinded `indexed` entries of
580
+ * the Collection's stored documents, answering `{documents, hasMore,
581
+ * cursor?}` (matching documents verbatim, opaque-cursor paginated) or
582
+ * `{count}`.
583
+ *
584
+ * The query parameters ride the signed JSON POST body (covered by the
585
+ * `Digest`), so no `allowTargetQuery` is needed. A body naming any other
586
+ * profile, or a backend without the profile's method, yields
587
+ * `unsupported-operation` (501). Authorization is capability-or-policy, the
588
+ * same read semantics as List Collection: an under-authorized caller
589
+ * receives a 404.
590
+ *
591
+ * @param request {import('fastify').FastifyRequest}
592
+ * @param reply {import('fastify').FastifyReply}
593
+ * @returns {Promise<FastifyReply>}
594
+ */
595
+ static async query(
596
+ request: FastifyRequest<{
597
+ Params: { spaceId: string; collectionId: string }
598
+ Body: {
599
+ profile?: string
600
+ checkpoint?: { id?: unknown; updatedAt?: unknown }
601
+ limit?: unknown
602
+ index?: unknown
603
+ equals?: unknown
604
+ has?: unknown
605
+ count?: unknown
606
+ cursor?: unknown
607
+ }
608
+ }>,
609
+ reply: FastifyReply
610
+ ): Promise<FastifyReply> {
611
+ const {
612
+ params: { spaceId, collectionId },
613
+ body
614
+ } = request
615
+ const { storage } = request.server
616
+ const requestName = 'Collection Query'
617
+
618
+ // Reject path-traversal / non-URL-safe ids before any storage access.
619
+ assertValidIds({ spaceId, collectionId }, { requestName })
620
+
621
+ // Authorize (capability-or-policy): readable by whoever may read the
622
+ // Collection (capability invocation, else the effective policy). The signed
623
+ // body is covered by the Digest, so the bare `/query` target authorizes it.
624
+ await fetchSpaceAndAuthorize({
625
+ request,
626
+ spaceId,
627
+ collectionId,
628
+ targetPath: queryPath({ spaceId, collectionId }),
629
+ requestName
630
+ })
631
+
632
+ // Fetch collection by id
633
+ const collectionDescription = await getCollectionOrThrow({
634
+ storage,
635
+ spaceId,
636
+ collectionId,
637
+ requestName
638
+ })
639
+
640
+ // Serve the query from the Collection's selected (data-plane) backend.
641
+ const dataBackend = await resolveBackend({
642
+ request,
643
+ spaceId,
644
+ collectionId,
645
+ collectionDescription
646
+ })
647
+
648
+ if (body?.profile === 'changes' && dataBackend.changesSince) {
649
+ return CollectionRequest._queryChanges({
650
+ reply,
651
+ dataBackend,
652
+ spaceId,
653
+ collectionId,
654
+ body,
655
+ requestName
656
+ })
657
+ }
658
+ if (body?.profile === 'blinded-index' && dataBackend.queryByBlindedIndex) {
659
+ // Validate/normalize the EDV query body fields (400 on a malformed
660
+ // query), then let the backend evaluate and paginate.
661
+ const parsed = parseBlindedIndexQueryBody({ body, requestName })
662
+ const result = await dataBackend.queryByBlindedIndex({
663
+ spaceId,
664
+ collectionId,
665
+ ...parsed
666
+ })
667
+ return reply
668
+ .status(200)
669
+ .type('application/json')
670
+ .send(JSON.stringify(result))
671
+ }
672
+
673
+ // Any other profile, or a backend without the profile's method.
674
+ throw new UnsupportedOperationError({ requestName })
675
+ }
676
+
677
+ /**
678
+ * The `changes` profile of the Collection `query` endpoint (see `query`
679
+ * above): parses the checkpoint/limit, pulls the page from the backend's
680
+ * change feed, and projects it to the wire shape.
681
+ *
682
+ * @param options {object}
683
+ * @param options.reply {import('fastify').FastifyReply}
684
+ * @param options.dataBackend {StorageBackend}
685
+ * @param options.spaceId {string}
686
+ * @param options.collectionId {string}
687
+ * @param options.body {object} the query POST body
688
+ * @param options.requestName {string}
689
+ * @returns {Promise<FastifyReply>}
690
+ */
691
+ static async _queryChanges({
692
+ reply,
693
+ dataBackend,
694
+ spaceId,
695
+ collectionId,
696
+ body,
697
+ requestName
698
+ }: {
699
+ reply: FastifyReply
700
+ dataBackend: StorageBackend
701
+ spaceId: string
702
+ collectionId: string
703
+ body: {
704
+ checkpoint?: { id?: unknown; updatedAt?: unknown }
705
+ limit?: unknown
706
+ }
707
+ requestName: string
708
+ }): Promise<FastifyReply> {
709
+ // Parse the optional checkpoint: when present it must carry both string
710
+ // fields (a malformed one is a client error, 400). Absent = start of feed.
711
+ let checkpoint: { id: string; updatedAt: string } | undefined
712
+ if (body.checkpoint !== undefined) {
713
+ const { id, updatedAt } = body.checkpoint
714
+ if (typeof id !== 'string' || typeof updatedAt !== 'string') {
715
+ throw new InvalidRequestBodyError({
716
+ requestName,
717
+ detail: 'checkpoint must have string "id" and "updatedAt" fields.',
718
+ pointer: '#/checkpoint'
719
+ })
720
+ }
721
+ checkpoint = { id, updatedAt }
722
+ }
723
+
724
+ // Coerce `limit` (the requested batch size) to a positive integer, else
725
+ // default; the backend clamps an oversized value to its own maximum.
726
+ const DEFAULT_BATCH = 100
727
+ const parsedLimit = Number(body.limit)
728
+ const limit =
729
+ Number.isFinite(parsedLimit) && parsedLimit >= 1
730
+ ? parsedLimit
731
+ : DEFAULT_BATCH
732
+
733
+ const result = await dataBackend.changesSince!({
734
+ spaceId,
735
+ collectionId,
736
+ ...(checkpoint !== undefined && { checkpoint }),
737
+ limit
738
+ })
739
+
740
+ // Project the change feed to the wire shape: a tombstone's `deleted` becomes
741
+ // RxDB's `_deleted`, and the document body stays under `data` (kept out of
742
+ // the user JSON so arbitrary bodies -- not only objects -- round-trip). The
743
+ // user-writable `custom` (the opaque encryption envelope on an encrypted
744
+ // Collection) and its independent `metaVersion` ride along so a metadata-only
745
+ // edit replicates alongside content, as does the server-managed `createdBy`
746
+ // so a replica learns each Resource's creator without a `/meta` fetch per
747
+ // Resource. The RxDB browser adapter does the final reshape into RxDB
748
+ // documents.
749
+ const documents: ChangeDocument[] = result.documents.map(doc => ({
750
+ id: doc.resourceId,
751
+ _deleted: doc.deleted,
752
+ updatedAt: doc.updatedAt,
753
+ version: doc.version,
754
+ ...(doc.metaVersion !== undefined && { metaVersion: doc.metaVersion }),
755
+ ...(doc.createdBy !== undefined && { createdBy: doc.createdBy }),
756
+ ...(doc.data !== undefined && { data: doc.data }),
757
+ ...(doc.custom !== undefined && { custom: doc.custom }),
758
+ // The client-declared key epoch (the `key-epochs` feature) rides the feed
759
+ // so a replicating reader picks the right epoch key without a `/meta` fetch.
760
+ ...(doc.epoch !== undefined && { epoch: doc.epoch })
761
+ }))
762
+
763
+ return reply
764
+ .status(200)
765
+ .type('application/json')
766
+ .send(JSON.stringify({ documents, checkpoint: result.checkpoint }))
767
+ }
768
+
769
+ /**
770
+ * DELETE /space/:spaceId/:collectionId
771
+ * Request handler for "Delete Collection" request
772
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
773
+ * request.zcap: {
774
+ * keyId, headers, signature, created, expires, invocation, digest
775
+ * }
776
+ *
777
+ * @param request {import('fastify').FastifyRequest}
778
+ * @param reply {import('fastify').FastifyReply}
779
+ * @returns {Promise<FastifyReply>}
780
+ */
781
+ static async delete(
782
+ request: FastifyRequest<{
783
+ Params: { spaceId: string; collectionId: string }
784
+ }>,
785
+ reply: FastifyReply
786
+ ): Promise<FastifyReply> {
787
+ const {
788
+ params: { spaceId, collectionId }
789
+ } = request
790
+ const { storage } = request.server
791
+ const requestName = 'Delete Collection'
792
+
793
+ // Reject path-traversal / non-URL-safe ids before any storage access.
794
+ assertValidIds({ spaceId, collectionId }, { requestName })
795
+
796
+ // Verify (capability-only): deleting a Collection requires a valid
797
+ // capability invocation; no access-control-policy fallback.
798
+ await fetchSpaceAndVerify({
799
+ request,
800
+ spaceId,
801
+ targetPath: collectionPath({ spaceId, collectionId }),
802
+ requestName
803
+ })
804
+
805
+ try {
806
+ await storage.deleteCollection({ spaceId, collectionId })
807
+ } catch (err) {
808
+ // Rethrow a typed ProblemError from the data-plane backend unchanged
809
+ // (e.g. a 507 quota / 412 precondition) rather than flattening it to a
810
+ // 500; wrap anything genuinely unexpected. `handleError` logs the 5xx once.
811
+ rethrowOrWrapStorageError({ err, requestName })
812
+ }
813
+
814
+ return reply.status(204).send()
815
+ }
816
+
817
+ /**
818
+ * GET /space/:spaceId/:collectionId/ (with trailing slash):
819
+ * List Collection items
820
+ *
821
+ * @param request {import('fastify').FastifyRequest}
822
+ * @param reply {import('fastify').FastifyReply}
823
+ * @returns {Promise<FastifyReply>}
824
+ */
825
+ static async list(
826
+ request: FastifyRequest<{
827
+ Params: { spaceId: string; collectionId: string }
828
+ Querystring: { limit?: string; cursor?: string }
829
+ }>,
830
+ reply: FastifyReply
831
+ ): Promise<FastifyReply> {
832
+ const {
833
+ params: { spaceId, collectionId },
834
+ query: { limit, cursor }
835
+ } = request
836
+ const { storage } = request.server
837
+ const requestName = 'List Collection'
838
+
839
+ // Reject path-traversal / non-URL-safe ids before any storage access.
840
+ assertValidIds({ spaceId, collectionId }, { requestName })
841
+
842
+ // Authorize (capability-or-policy): capability invocation first, then the
843
+ // effective access-control policy as a fallback (a public-readable Collection).
844
+ // `allowTargetQuery` lets the signed-request path tolerate the `?limit`/
845
+ // `cursor` pagination query parameters: per the spec they select a page
846
+ // within an already-authorized target and do not change the capability
847
+ // target. Authorization still runs before any cursor validation below, so an
848
+ // under-authorized caller gets the merged 404 -- never an `invalid-cursor`.
849
+ await fetchSpaceAndAuthorize({
850
+ request,
851
+ spaceId,
852
+ collectionId,
853
+ targetPath: collectionPath({
854
+ spaceId,
855
+ collectionId,
856
+ trailingSlash: true
857
+ }),
858
+ requestName,
859
+ allowTargetQuery: true
860
+ })
861
+
862
+ // Fetch collection by id
863
+ const collectionDescription = await getCollectionOrThrow({
864
+ storage,
865
+ spaceId,
866
+ collectionId,
867
+ requestName
868
+ })
869
+
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.
875
+ const dataBackend = await resolveBackend({
876
+ request,
877
+ spaceId,
878
+ collectionId,
879
+ collectionDescription
880
+ })
881
+ const parsedLimit = limit !== undefined ? Number(limit) : NaN
882
+ const collectionItems = await dataBackend.listCollectionItems({
883
+ spaceId,
884
+ collectionId,
885
+ // Pass the control-plane description: a data-plane (external) backend does
886
+ // not hold it, and the listing's `name`/`type`/encryption flag come from it.
887
+ collectionDescription,
888
+ ...(Number.isFinite(parsedLimit) && parsedLimit >= 1
889
+ ? { limit: parsedLimit }
890
+ : {}),
891
+ ...(cursor !== undefined && { cursor })
892
+ })
893
+
894
+ return reply
895
+ .status(200)
896
+ .type('application/json')
897
+ .send(JSON.stringify(collectionItems))
898
+ }
899
+ }