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