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,686 @@
1
+ /**
2
+ * Request handlers for Space operations: get/update/delete a Space, add a
3
+ * Collection to it, list its Collections, and export it.
4
+ */
5
+ import type { FastifyReply, FastifyRequest } from 'fastify'
6
+ import type { Readable } from 'node:stream'
7
+ import { v4 as uuidv4 } from 'uuid'
8
+ import { handleZcapVerify } from '../zcap.js'
9
+ import { buildLinkset } from '../policy.js'
10
+ import {
11
+ fetchSpaceAndAuthorize,
12
+ fetchSpaceAndVerify,
13
+ invalidateSpaceDescription
14
+ } from './spaceContext.js'
15
+ import { verifyBodyControllerConsent } from './controllerConsent.js'
16
+ import { invokerDid } from '../auth-header-hooks.js'
17
+ import { assertValidIds, assertValidId } from '../lib/validateId.js'
18
+ import { assertValidController } from '../lib/validateDid.js'
19
+ import {
20
+ assertSupportedBackend,
21
+ listRegisteredBackends
22
+ } from '../lib/backends.js'
23
+ import { assertSupportedEncryption } from '../lib/encryption.js'
24
+ import { formatEtag } from '../lib/etag.js'
25
+ import {
26
+ spacePath,
27
+ collectionPath,
28
+ collectionsPath,
29
+ exportPath,
30
+ importPath,
31
+ linksetPath,
32
+ backendsPath,
33
+ quotasPath
34
+ } from '../lib/paths.js'
35
+ import {
36
+ ProblemError,
37
+ InvalidSpaceIdError,
38
+ InvalidImportError,
39
+ InvalidRequestBodyError,
40
+ IdConflictError,
41
+ SpaceControllerMismatchError
42
+ } from '../errors.js'
43
+ import type {
44
+ IDID,
45
+ SpaceDescription,
46
+ CollectionsList,
47
+ SpaceQuotaReport
48
+ } from '../types.js'
49
+
50
+ export class SpaceRequest {
51
+ /**
52
+ * GET /space/:spaceId
53
+ * Request handler for "Read Space" request
54
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
55
+ * request.zcap: {
56
+ * keyId, headers, signature, created, expires, invocation, digest
57
+ * }
58
+ *
59
+ * Example Space Description Object:
60
+ * {
61
+ * "id": "6b5be748-5f39-4936-a895-409e393c399c",
62
+ * "type": ["Space"],
63
+ * "name": "Alice's space",
64
+ * "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
65
+ * }
66
+ *
67
+ * @param request {import('fastify').FastifyRequest}
68
+ * @param reply {import('fastify').FastifyReply}
69
+ * @returns {Promise<FastifyReply>}
70
+ */
71
+ static async get(
72
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
73
+ reply: FastifyReply
74
+ ): Promise<FastifyReply> {
75
+ const {
76
+ params: { spaceId }
77
+ } = request
78
+ const requestName = 'Get Space'
79
+
80
+ // Reject path-traversal / non-URL-safe ids before any storage access.
81
+ assertValidIds({ spaceId }, { requestName })
82
+
83
+ // Authorize (capability-or-policy): capability invocation first, then the
84
+ // Space's access-control policy as a fallback (a public-readable Space).
85
+ const { spaceDescription } = await fetchSpaceAndAuthorize({
86
+ request,
87
+ spaceId,
88
+ targetPath: spacePath({ spaceId }),
89
+ requestName
90
+ })
91
+
92
+ // authorized, continue. Advertise the Space's self `url` and linkset (policy
93
+ // discovery); both relative, consistent with the other URL fields the API
94
+ // returns. `type` is served lexically sorted (spec SHOULD).
95
+ const url = spacePath({ spaceId })
96
+ const linkset = linksetPath({ spaceId })
97
+ return reply.status(200).send({
98
+ ...spaceDescription,
99
+ type: [...spaceDescription.type].sort(),
100
+ url,
101
+ linkset
102
+ } satisfies SpaceDescription)
103
+ }
104
+
105
+ /**
106
+ * GET /space/:spaceId/linkset
107
+ * Request handler for the Space's linkset (RFC9264): advertises the Space's
108
+ * access-control `policy` resource for discovery. Readable by whoever may read
109
+ * the Space (capability or fallback policy).
110
+ *
111
+ * @param request {import('fastify').FastifyRequest}
112
+ * @param reply {import('fastify').FastifyReply}
113
+ * @returns {Promise<FastifyReply>}
114
+ */
115
+ static async linkset(
116
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
117
+ reply: FastifyReply
118
+ ): Promise<FastifyReply> {
119
+ const {
120
+ params: { spaceId }
121
+ } = request
122
+ const { storage } = request.server
123
+ const requestName = 'Get Space Linkset'
124
+
125
+ // Reject path-traversal / non-URL-safe ids before any storage access.
126
+ assertValidIds({ spaceId }, { requestName })
127
+
128
+ // Authorize (capability-or-policy): readable by whoever may read the Space.
129
+ await fetchSpaceAndAuthorize({
130
+ request,
131
+ spaceId,
132
+ targetPath: linksetPath({ spaceId }),
133
+ requestName
134
+ })
135
+
136
+ const linkset = await buildLinkset({ storage, spaceId })
137
+ return reply
138
+ .status(200)
139
+ .type('application/linkset+json')
140
+ .send(JSON.stringify(linkset))
141
+ }
142
+
143
+ /**
144
+ * PUT /space/:spaceId
145
+ * Request handler for "Update or Create Space by Id" request
146
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
147
+ * request.zcap: {
148
+ * keyId, headers, signature, created, expires, invocation, digest
149
+ * }
150
+ *
151
+ * @param request {import('fastify').FastifyRequest}
152
+ * @param reply {import('fastify').FastifyReply}
153
+ * @returns {Promise<FastifyReply>}
154
+ */
155
+ static async put(
156
+ request: FastifyRequest<{
157
+ Params: { spaceId: string }
158
+ Body: { id?: string; name?: string; controller: IDID }
159
+ }>,
160
+ reply: FastifyReply
161
+ ): Promise<FastifyReply> {
162
+ const {
163
+ params: { spaceId },
164
+ url,
165
+ method,
166
+ headers,
167
+ body
168
+ } = request
169
+ const { serverUrl, storage } = request.server
170
+ // PUT is a non-safe method, so `requireAuthHeaders` guarantees auth headers
171
+ // were present and `parseAuthHeaders` set `request.zcap` before this handler.
172
+ const { keyId } = request.zcap!
173
+
174
+ // Reject path-traversal / non-URL-safe ids before any storage access.
175
+ assertValidIds({ spaceId }, { requestName: 'Update Space' })
176
+
177
+ // The Space `id` is immutable: when the PUT body carries one, it must match
178
+ // the `{space_id}` in the URL (spec: Update Space `invalid-request-body`).
179
+ if (body?.id !== undefined && body.id !== spaceId) {
180
+ throw new InvalidRequestBodyError({
181
+ requestName: 'Update Space',
182
+ detail: `Space Description "id" (${body.id}) does not match the URL Space id (${spaceId}).`,
183
+ pointer: '#/id'
184
+ })
185
+ }
186
+
187
+ // The Space Description body must carry a controller DID. The `name`
188
+ // property is optional (see spec: Space Description object).
189
+ if (!body?.controller) {
190
+ throw new InvalidRequestBodyError({
191
+ requestName: 'Update Space',
192
+ detail: 'Space Description body requires a "controller" property.',
193
+ pointer: '#/controller'
194
+ })
195
+ }
196
+ // Reject a malformed / non-`did:key` controller before it is stored.
197
+ assertValidController(body.controller, { requestName: 'Update Space' })
198
+
199
+ // Check to see if space already exists (if yes, this will be an Update)
200
+ const existingSpaceDescription = await storage.getSpaceDescription({
201
+ spaceId
202
+ })
203
+
204
+ const [zcapSigningDid] = keyId.split('#')
205
+
206
+ request.log.info(
207
+ `Handling PUT request for spaceId: ${spaceId}, zcapSigningDid: ${zcapSigningDid}, existingSpaceDescription: ${existingSpaceDescription ? 'exists' : 'does not exist'}`
208
+ )
209
+
210
+ // Perform zCap signature verification (throws appropriate errors)
211
+ let spaceUrl
212
+ try {
213
+ spaceUrl = new URL(spacePath({ spaceId }), serverUrl).toString()
214
+ } catch (err) {
215
+ request.log.error(
216
+ `Failed to construct spaceUrl for spaceId: ${spaceId}, serverUrl: ${serverUrl}, error: ${(err as Error).message}`
217
+ )
218
+ throw new InvalidSpaceIdError({ requestName: 'Update Space' })
219
+ }
220
+
221
+ request.log.info(`spaceUrl: ${spaceUrl}, serverUrl: ${serverUrl}`)
222
+ // Important. For existing Spaces, the request must carry authorization
223
+ // matching the *stored* controller (the body's controller is just the
224
+ // proposed new value). On create there is no stored controller yet, so --
225
+ // as with Create Space via POST -- the invocation must be authorized by
226
+ // the *body's* controller: signed directly by it, or via a delegation
227
+ // chain rooted in it (see `verifyBodyControllerConsent`).
228
+ if (existingSpaceDescription) {
229
+ await handleZcapVerify({
230
+ url,
231
+ allowedTarget: spaceUrl,
232
+ allowedAction: 'PUT',
233
+ method,
234
+ headers,
235
+ serverUrl,
236
+ spaceController: existingSpaceDescription.controller,
237
+ logger: request.log,
238
+ revocation: { storage, scope: { spaceId } }
239
+ })
240
+ } else {
241
+ await verifyBodyControllerConsent({
242
+ request,
243
+ controller: body.controller,
244
+ allowedTarget: spaceUrl,
245
+ allowedAction: 'PUT',
246
+ MismatchError: SpaceControllerMismatchError
247
+ })
248
+ }
249
+
250
+ request.log.info('zCap verified')
251
+
252
+ // Compose Space Description object body, new or updated. `name` is
253
+ // optional, so only include it when the request supplies one.
254
+ const spaceDescription = existingSpaceDescription
255
+ ? // Existing: Update only the allowed fields
256
+ {
257
+ ...existingSpaceDescription,
258
+ id: spaceId,
259
+ controller: body.controller,
260
+ ...(body.name !== undefined && { name: body.name })
261
+ }
262
+ : // New Space
263
+ {
264
+ id: spaceId,
265
+ type: ['Space'],
266
+ controller: body.controller,
267
+ ...(body.name !== undefined && { name: body.name })
268
+ }
269
+
270
+ // zCap checks out, continue
271
+ await storage.writeSpace({
272
+ spaceId,
273
+ spaceDescription,
274
+ createdBy: invokerDid(request)
275
+ })
276
+ // Bust any cached (now-stale) description so the next read sees this write.
277
+ invalidateSpaceDescription({ storage, spaceId })
278
+
279
+ reply.header('Location', spaceUrl)
280
+ return existingSpaceDescription
281
+ ? reply.status(204).send() // update
282
+ : reply.status(201).send(spaceDescription) // create
283
+ }
284
+
285
+ /**
286
+ * POST /space/:spaceId/
287
+ * Request handler for "Create Collection" request
288
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
289
+ * request.zcap: {
290
+ * keyId, headers, signature, created, expires, invocation, digest
291
+ * }
292
+ *
293
+ * @param request {import('fastify').FastifyRequest}
294
+ * @param reply {import('fastify').FastifyReply}
295
+ * @returns {Promise<FastifyReply>}
296
+ */
297
+ static async post(
298
+ request: FastifyRequest<{
299
+ Params: { spaceId: string }
300
+ Body: {
301
+ id?: string
302
+ name?: string
303
+ backend?: unknown
304
+ encryption?: unknown
305
+ }
306
+ }>,
307
+ reply: FastifyReply
308
+ ): Promise<FastifyReply> {
309
+ const {
310
+ params: { spaceId },
311
+ body
312
+ } = request
313
+ const { serverUrl, storage } = request.server
314
+ const requestName = 'Create Collection'
315
+
316
+ // Reject path-traversal / non-URL-safe ids before any storage access.
317
+ assertValidIds({ spaceId }, { requestName })
318
+ if (body?.id !== undefined) {
319
+ assertValidId(body.id, { kind: 'collection', requestName })
320
+ }
321
+ // Validate the optional client-side encryption marker (shape only; the
322
+ // server stores it opaquely and never decrypts). Absent => plaintext.
323
+ const encryption = assertSupportedEncryption({
324
+ encryption: body?.encryption,
325
+ requestName
326
+ })
327
+
328
+ // Verify (capability-only): creating a Collection requires a valid
329
+ // capability invocation; no access-control-policy fallback.
330
+ await fetchSpaceAndVerify({
331
+ request,
332
+ spaceId,
333
+ targetPath: spacePath({ spaceId, trailingSlash: true }),
334
+ requestName
335
+ })
336
+
337
+ // zCap checks out, continue.
338
+ // Validate (and default-fill) the selected backend against the Space's
339
+ // backends-available: a bad shape is 400, an unknown id is 409. Checked
340
+ // AFTER verification (it reads the Space's registered backend ids) so an
341
+ // unauthorized caller cannot enumerate registered ids by distinguishing a
342
+ // 409 from the masked 404 -- like the `id-conflict` check below.
343
+ const backend = await assertSupportedBackend({
344
+ storage,
345
+ spaceId,
346
+ backend: body?.backend,
347
+ requestName
348
+ })
349
+ // POST must not replace an existing Collection: spec `id-conflict` (409);
350
+ // create-or-replace by id is PUT's job. Checked after the capability
351
+ // verification so an unauthorized caller cannot probe Collection ids.
352
+ if (
353
+ body?.id !== undefined &&
354
+ (await storage.getCollectionDescription({
355
+ spaceId,
356
+ collectionId: body.id
357
+ }))
358
+ ) {
359
+ throw new IdConflictError({ kind: 'Collection' })
360
+ }
361
+
362
+ // TODO: Protect against .space resource id collision
363
+ const collectionId = body.id || uuidv4()
364
+ // `name` is optional; default it to the Collection id when missing (spec).
365
+ const name = body.name ?? collectionId
366
+ const collectionDescription = {
367
+ id: collectionId,
368
+ type: ['Collection'],
369
+ name,
370
+ backend,
371
+ ...(encryption !== undefined && { encryption })
372
+ }
373
+
374
+ const createdBy = invokerDid(request)
375
+ const written = await storage.writeCollection({
376
+ spaceId,
377
+ collectionId,
378
+ collectionDescription,
379
+ createdBy
380
+ })
381
+
382
+ const createdUrl = new URL(
383
+ collectionPath({ spaceId, collectionId }),
384
+ serverUrl
385
+ ).toString()
386
+ reply.header('Location', createdUrl)
387
+ // Surface the new Collection Description ETag so a client can chain a
388
+ // conditional update (the `key-epochs` conditional-Collection-write feature).
389
+ reply.header('etag', formatEtag(written.version))
390
+ // Echo what was persisted, `createdBy` included, so the create response and
391
+ // a subsequent Get Collection agree. An id already in use was rejected as a
392
+ // 409 above, so this write created the Collection.
393
+ return reply
394
+ .status(201)
395
+ .send({ ...collectionDescription, ...(createdBy && { createdBy }) })
396
+ }
397
+
398
+ /**
399
+ * DELETE /space/:spaceId
400
+ * Request handler for "Delete Space" request
401
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
402
+ * request.zcap: {
403
+ * keyId, headers, signature, created, expires, invocation, digest
404
+ * }
405
+ *
406
+ * @param request {import('fastify').FastifyRequest}
407
+ * @param reply {import('fastify').FastifyReply}
408
+ * @returns {Promise<FastifyReply>}
409
+ */
410
+ static async delete(
411
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
412
+ reply: FastifyReply
413
+ ): Promise<FastifyReply> {
414
+ const {
415
+ params: { spaceId }
416
+ } = request
417
+ const { storage } = request.server
418
+ const requestName = 'Delete Space'
419
+
420
+ // Reject path-traversal / non-URL-safe ids before any storage access.
421
+ assertValidIds({ spaceId }, { requestName })
422
+
423
+ // Verify (capability-only): deleting a Space requires a valid capability
424
+ // invocation; no access-control-policy fallback.
425
+ await fetchSpaceAndVerify({
426
+ request,
427
+ spaceId,
428
+ targetPath: spacePath({ spaceId }),
429
+ requestName
430
+ })
431
+
432
+ // zCap checks out, continue
433
+ await storage.deleteSpace({ spaceId })
434
+ // Bust the cached description so the next read sees the Space as gone (404).
435
+ invalidateSpaceDescription({ storage, spaceId })
436
+
437
+ return reply.status(204).send()
438
+ }
439
+
440
+ /**
441
+ * POST /space/:spaceId/export
442
+ * Request handler for "Export Space" request
443
+ *
444
+ * @param request {import('fastify').FastifyRequest}
445
+ * @param reply {import('fastify').FastifyReply}
446
+ * @returns {Promise<FastifyReply>}
447
+ */
448
+ static async export(
449
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
450
+ reply: FastifyReply
451
+ ): Promise<FastifyReply> {
452
+ const {
453
+ params: { spaceId }
454
+ } = request
455
+ const { storage } = request.server
456
+ const requestName = 'Export Space'
457
+
458
+ // Reject path-traversal / non-URL-safe ids before any storage access.
459
+ assertValidIds({ spaceId }, { requestName })
460
+
461
+ // Verify (capability-only): exporting a Space requires a valid capability
462
+ // invocation; no access-control-policy fallback.
463
+ await fetchSpaceAndVerify({
464
+ request,
465
+ spaceId,
466
+ targetPath: exportPath({ spaceId }),
467
+ requestName
468
+ })
469
+
470
+ // zCap checks out, continue
471
+ const tarFile = await storage.exportSpace({ spaceId })
472
+
473
+ return reply.status(200).type('application/x-tar').send(tarFile)
474
+ }
475
+
476
+ /**
477
+ * POST /space/:spaceId/import
478
+ * Request handler for "Import Space" request (merge from tarball)
479
+ *
480
+ * @param request {import('fastify').FastifyRequest}
481
+ * @param reply {import('fastify').FastifyReply}
482
+ * @returns {Promise<FastifyReply>}
483
+ */
484
+ static async import(
485
+ request: FastifyRequest<{ Params: { spaceId: string }; Body: Readable }>,
486
+ reply: FastifyReply
487
+ ): Promise<FastifyReply> {
488
+ const {
489
+ params: { spaceId }
490
+ } = request
491
+ const { storage } = request.server
492
+ const requestName = 'Import Space'
493
+
494
+ // Reject path-traversal / non-URL-safe ids before any storage access.
495
+ assertValidIds({ spaceId }, { requestName })
496
+
497
+ // Verify (capability-only): importing into a Space requires a valid
498
+ // capability invocation; no access-control-policy fallback.
499
+ await fetchSpaceAndVerify({
500
+ request,
501
+ spaceId,
502
+ targetPath: importPath({ spaceId }),
503
+ requestName
504
+ })
505
+
506
+ try {
507
+ const summary = await storage.importSpace({
508
+ spaceId,
509
+ tarStream: request.body
510
+ })
511
+ return reply.status(200).send(summary)
512
+ } catch (err) {
513
+ // Archive-validation failures already surface as typed ProblemErrors
514
+ // (e.g. InvalidImportError from the manifest checks, or an invalid-id
515
+ // error from a malformed archive id) -- let those through unchanged,
516
+ // preserving their status code and message. Anything else is an
517
+ // unexpected failure decoding the upload: wrap it as a generic
518
+ // invalid-import 400, keeping the original as the `cause`.
519
+ if (err instanceof ProblemError) {
520
+ throw err
521
+ }
522
+ throw new InvalidImportError({ cause: err as Error })
523
+ }
524
+ }
525
+
526
+ /**
527
+ * GET /space/:spaceId/collections/
528
+ * Request handler for "List Collections" request
529
+ *
530
+ * @param request {import('fastify').FastifyRequest}
531
+ * @param reply {import('fastify').FastifyReply}
532
+ * @returns {Promise<FastifyReply>}
533
+ */
534
+ static async listCollections(
535
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
536
+ reply: FastifyReply
537
+ ): Promise<FastifyReply> {
538
+ const {
539
+ params: { spaceId }
540
+ } = request
541
+ const { storage } = request.server
542
+ const requestName = 'List Collections'
543
+
544
+ // Reject path-traversal / non-URL-safe ids before any storage access.
545
+ assertValidIds({ spaceId }, { requestName })
546
+
547
+ // Authorize (capability-or-policy): capability invocation first, then the
548
+ // Space's access-control policy as a fallback (a public-readable Space).
549
+ await fetchSpaceAndAuthorize({
550
+ request,
551
+ spaceId,
552
+ targetPath: collectionsPath({ spaceId }),
553
+ requestName
554
+ })
555
+
556
+ const collections = await storage.listCollections({ spaceId })
557
+ return reply.status(200).send({
558
+ url: collectionsPath({ spaceId }),
559
+ totalItems: collections.length,
560
+ items: collections
561
+ } satisfies CollectionsList)
562
+ }
563
+
564
+ /**
565
+ * GET /space/:spaceId/backends
566
+ * Request handler for the "Space Backends Available" request: the list of
567
+ * storage backends registered for the Space. This reference server ships a
568
+ * single server-configured backend (registered as `default`), so the list has
569
+ * one entry, derived from the active backend's own `describe()`.
570
+ *
571
+ * Authorization is capability-or-policy, the same as List Collections: the
572
+ * backends list is no more sensitive than the Space description, so a
573
+ * public-readable Space may also list its backends.
574
+ *
575
+ * @param request {import('fastify').FastifyRequest}
576
+ * @param reply {import('fastify').FastifyReply}
577
+ * @returns {Promise<FastifyReply>}
578
+ */
579
+ static async listBackends(
580
+ request: FastifyRequest<{ Params: { spaceId: string } }>,
581
+ reply: FastifyReply
582
+ ): Promise<FastifyReply> {
583
+ const {
584
+ params: { spaceId }
585
+ } = request
586
+ const { storage } = request.server
587
+ const requestName = 'List Backends'
588
+
589
+ // Reject path-traversal / non-URL-safe ids before any storage access.
590
+ assertValidIds({ spaceId }, { requestName })
591
+
592
+ // Authorize (capability-or-policy): capability invocation first, then the
593
+ // Space's access-control policy as a fallback (a public-readable Space).
594
+ await fetchSpaceAndAuthorize({
595
+ request,
596
+ spaceId,
597
+ targetPath: backendsPath({ spaceId }),
598
+ requestName
599
+ })
600
+
601
+ return reply
602
+ .status(200)
603
+ .type('application/json')
604
+ .send(await listRegisteredBackends({ storage, spaceId }))
605
+ }
606
+
607
+ /**
608
+ * GET /space/:spaceId/quotas
609
+ * Request handler for the "Quotas" request: the Space's storage report,
610
+ * grouped by backend (spec "Quotas"). This reference server ships a single
611
+ * server-configured backend, so the `backends` array has one entry, measured
612
+ * from the active backend's `reportUsage()`.
613
+ *
614
+ * The per-Collection `usageByCollection` breakdown is opt-in via the spec's
615
+ * `?include=collections` query parameter (omitted otherwise, to keep the
616
+ * hot-path payload lean). Reading that query string on a capability-signed
617
+ * request requires `allowTargetQuery` on the authorization call -- the spec's
618
+ * "Quotas" / "Pagination parameters and authorization" rule that a query
619
+ * parameter selecting a representation does not change the target a capability
620
+ * must match (see `verifyZcap`).
621
+ *
622
+ * Authorization is capability-or-policy, the same as List Collections and the
623
+ * backends list: a caller not authorized to read the report receives a 404
624
+ * (the spec's maximum-privacy invariant), and a public-readable Space may read
625
+ * its quota report. Authorization runs before the query is consulted.
626
+ *
627
+ * @param request {import('fastify').FastifyRequest}
628
+ * @param reply {import('fastify').FastifyReply}
629
+ * @returns {Promise<FastifyReply>}
630
+ */
631
+ static async quotas(
632
+ request: FastifyRequest<{
633
+ Params: { spaceId: string }
634
+ Querystring: { include?: string | string[] }
635
+ }>,
636
+ reply: FastifyReply
637
+ ): Promise<FastifyReply> {
638
+ const {
639
+ params: { spaceId },
640
+ query: { include }
641
+ } = request
642
+ const { storage } = request.server
643
+ const requestName = 'Get Quotas'
644
+
645
+ // Reject path-traversal / non-URL-safe ids before any storage access.
646
+ assertValidIds({ spaceId }, { requestName })
647
+
648
+ // Authorize (capability-or-policy): capability invocation first, then the
649
+ // Space's access-control policy as a fallback (a public-readable Space).
650
+ // `allowTargetQuery` lets the signed-request path tolerate the
651
+ // `?include=collections` query parameter without it changing the capability
652
+ // target.
653
+ await fetchSpaceAndAuthorize({
654
+ request,
655
+ spaceId,
656
+ targetPath: quotasPath({ spaceId }),
657
+ requestName,
658
+ allowTargetQuery: true
659
+ })
660
+
661
+ // The per-Collection breakdown is opt-in via `?include=collections` (spec
662
+ // "Quotas"); `include` is a comma-separated list of optional sections. A
663
+ // repeated `?include=` makes Fastify's default parser yield a string array,
664
+ // so normalize to an array first -- calling `.split` on the array would 500
665
+ // (unauthenticated-reachable on a public-readable Space).
666
+ const includeValues = Array.isArray(include)
667
+ ? include
668
+ : include !== undefined
669
+ ? [include]
670
+ : []
671
+ const includeCollections = includeValues
672
+ .flatMap(value => value.split(','))
673
+ .map(section => section.trim())
674
+ .includes('collections')
675
+
676
+ const usage = await storage.reportUsage({ spaceId, includeCollections })
677
+
678
+ return reply
679
+ .status(200)
680
+ .type('application/json')
681
+ .send({
682
+ respondedAt: new Date().toISOString(),
683
+ backends: [usage]
684
+ } satisfies SpaceQuotaReport)
685
+ }
686
+ }