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,194 @@
1
+ /**
2
+ * Request handler for SpacesRepository operations:
3
+ * - POST /spaces/ (Create Space)
4
+ * - GET /spaces/ (List Spaces).
5
+ */
6
+ import type { FastifyReply, FastifyRequest } from 'fastify'
7
+ import { v4 as uuidv4 } from 'uuid'
8
+ import { isRootInvocation, verifyZcap } from '../zcap.js'
9
+ import { invalidateSpaceDescription } from './spaceContext.js'
10
+ import { verifyBodyControllerConsent } from './controllerConsent.js'
11
+ import { invokerDid } from '../auth-header-hooks.js'
12
+ import { assertValidId } from '../lib/validateId.js'
13
+ import { spacePath, spacesPath } from '../lib/paths.js'
14
+ import { assertValidController } from '../lib/validateDid.js'
15
+ import {
16
+ SpaceControllerMismatchError,
17
+ InvalidRequestBodyError,
18
+ IdConflictError
19
+ } from '../errors.js'
20
+ import type { IDID, SpaceSummary, SpaceListing } from '../types.js'
21
+
22
+ export class SpacesRepositoryRequest {
23
+ /**
24
+ * GET /spaces/
25
+ * Request handler for "List Spaces" (spec "List Spaces Operation"): returns
26
+ * `{ url, totalItems, items }` with only the Spaces the caller is authorized
27
+ * to see. An anonymous or unauthorized request is NOT an error -- it gets the
28
+ * empty-items 200, the spec's explicit exception to 404 masking -- so nothing
29
+ * is revealed about which Spaces exist.
30
+ *
31
+ * Authorization is per Space controller: the root capability for `/spaces/`
32
+ * is synthesized with the candidate Space's controller (see `verifyZcap`), so
33
+ * one verification decides visibility for every Space sharing that
34
+ * controller. A bare-root invocation can only verify where the signer *is*
35
+ * the controller, so those candidates are filtered before any signature
36
+ * work; a delegated invocation reveals the Spaces of whichever controller
37
+ * roots its capability chain.
38
+ *
39
+ * @param request {import('fastify').FastifyRequest}
40
+ * @param reply {import('fastify').FastifyReply}
41
+ * @returns {Promise<FastifyReply>}
42
+ */
43
+ static async get(
44
+ request: FastifyRequest,
45
+ reply: FastifyReply
46
+ ): Promise<FastifyReply> {
47
+ const { url, method, headers } = request
48
+ const { serverUrl, storage } = request.server
49
+
50
+ const items: SpaceSummary[] = []
51
+ const listing: SpaceListing = { url: spacesPath(), totalItems: 0, items }
52
+
53
+ // No (complete) authorization presented: authorized to see no Spaces,
54
+ // which is the empty 200, not an error.
55
+ if (!request.zcap?.invocation) {
56
+ return reply.send(listing)
57
+ }
58
+
59
+ const { keyId, invocation } = request.zcap
60
+ const [zcapSigningDid] = keyId.split('#')
61
+ const rootInvocation = isRootInvocation({ invocation })
62
+ const allowedTarget = new URL(spacesPath(), serverUrl).toString()
63
+
64
+ // Visibility is identical across Spaces sharing a controller (the
65
+ // verification depends only on the controller), so verify once per
66
+ // distinct controller. A failed verification just excludes that
67
+ // controller's Spaces -- never an error response.
68
+ const verifiedByController = new Map<IDID, boolean>()
69
+ for (const space of await storage.listSpaces()) {
70
+ const { controller } = space
71
+ if (rootInvocation && controller !== zcapSigningDid) {
72
+ continue // a bare-root invocation cannot verify for another controller
73
+ }
74
+ let authorized = verifiedByController.get(controller)
75
+ if (authorized === undefined) {
76
+ try {
77
+ const result = await verifyZcap({
78
+ url,
79
+ allowedTarget,
80
+ allowedAction: 'GET',
81
+ method,
82
+ headers,
83
+ serverUrl,
84
+ spaceController: controller
85
+ })
86
+ authorized = result.verified === true
87
+ } catch (err) {
88
+ request.log.debug(
89
+ { err },
90
+ 'List Spaces: invocation did not verify for a candidate controller'
91
+ )
92
+ authorized = false
93
+ }
94
+ verifiedByController.set(controller, authorized)
95
+ }
96
+ if (authorized) {
97
+ const item: SpaceSummary = {
98
+ id: space.id,
99
+ url: spacePath({ spaceId: space.id })
100
+ }
101
+ if (space.name !== undefined) {
102
+ item.name = space.name
103
+ }
104
+ items.push(item)
105
+ }
106
+ }
107
+
108
+ listing.totalItems = items.length
109
+ return reply.send(listing)
110
+ }
111
+
112
+ /**
113
+ * POST /spaces/
114
+ * Request handler for "Create Space" request
115
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
116
+ * request.zcap: {
117
+ * keyId, headers, signature, created, expires, invocation, digest
118
+ * }
119
+ *
120
+ * @param request {import('fastify').FastifyRequest}
121
+ * @param reply {import('fastify').FastifyReply}
122
+ * @returns {Promise<FastifyReply>}
123
+ */
124
+ static async post(
125
+ request: FastifyRequest<{
126
+ Body: { id?: string; name?: string; controller: IDID }
127
+ }>,
128
+ reply: FastifyReply
129
+ ): Promise<FastifyReply> {
130
+ const { body } = request
131
+ const { serverUrl, storage } = request.server
132
+
133
+ // The Space Description body must carry a controller DID. The `name`
134
+ // property is optional (see spec: Space Description object).
135
+ if (!body?.controller) {
136
+ throw new InvalidRequestBodyError({
137
+ requestName: 'Create Space',
138
+ detail: 'Space Description body requires a "controller" property.',
139
+ pointer: '#/controller'
140
+ })
141
+ }
142
+ // Reject a malformed / non-`did:key` controller before it is stored.
143
+ assertValidController(body.controller, { requestName: 'Create Space' })
144
+ // Reject a path-traversal / non-URL-safe client-supplied space id.
145
+ if (body.id !== undefined) {
146
+ assertValidId(body.id, { kind: 'space', requestName: 'Create Space' })
147
+ // POST must never replace an existing Space: the signature below is
148
+ // verified against the *body's* controller, so without this check any
149
+ // caller could overwrite a Space (controller included) by POSTing its
150
+ // id. Spec: `id-conflict` (409); create-or-replace by id is PUT's job.
151
+ if (await storage.getSpaceDescription({ spaceId: body.id })) {
152
+ throw new IdConflictError({ kind: 'Space' })
153
+ }
154
+ }
155
+
156
+ const spaceId = body.id || uuidv4()
157
+ const spaceDescription = { ...body, id: spaceId, type: ['Space'] }
158
+
159
+ // The invocation must be *authorized by* the body's controller (spec:
160
+ // Create Space): signed directly by it, or via a delegation chain rooted
161
+ // in it (see `verifyBodyControllerConsent`). Skipped when the provisioning
162
+ // policy already vouched for the request (e.g. a valid onboarding token).
163
+ if (!request.provisioningAuthorized) {
164
+ await verifyBodyControllerConsent({
165
+ request,
166
+ controller: body.controller,
167
+ allowedTarget: new URL(spacesPath(), serverUrl).toString(),
168
+ allowedAction: 'POST',
169
+ MismatchError: SpaceControllerMismatchError,
170
+ requestName: 'Create Space'
171
+ })
172
+ }
173
+
174
+ // zCap checks out, continue. A token-provisioned create carries no
175
+ // invocation, so it records no `createdBy`.
176
+ const createdBy = invokerDid(request)
177
+ await storage.writeSpace({ spaceId, spaceDescription, createdBy })
178
+ // Bust any cached (e.g. negatively cached) description for this id so the
179
+ // next read sees the freshly created Space.
180
+ invalidateSpaceDescription({ storage, spaceId })
181
+
182
+ const createdSpaceUrl = new URL(
183
+ spacesPath({ spaceId }),
184
+ serverUrl
185
+ ).toString()
186
+ reply.header('Location', createdSpaceUrl)
187
+ // Echo what was persisted, `createdBy` included, so the create response and
188
+ // a subsequent Get Space agree. An id already in use was rejected as a 409
189
+ // above, so this write created the Space and its creator is this invoker.
190
+ return reply
191
+ .status(201)
192
+ .send({ ...spaceDescription, ...(createdBy && { createdBy }) })
193
+ }
194
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared handler prelude: fetch a Collection Description or 404 (paralleling
3
+ * spaceContext.ts / keystoreContext.ts). Nearly every Collection- and
4
+ * Resource-level handler repeats the same shape after authorization -- load
5
+ * the Collection Description for context, throw `CollectionNotFoundError`
6
+ * when absent -- so it lives here.
7
+ */
8
+ import { CollectionNotFoundError } from '../errors.js'
9
+ import type { CollectionDescription, StorageBackend } from '../types.js'
10
+
11
+ /**
12
+ * Fetches a Collection Description or throws CollectionNotFoundError (404)
13
+ * when absent.
14
+ * @param options {object}
15
+ * @param options.storage {StorageBackend} the request's storage backend
16
+ * @param options.spaceId {string}
17
+ * @param options.collectionId {string}
18
+ * @param options.requestName {string} human-readable request name, used in
19
+ * error titles
20
+ * @returns {Promise<CollectionDescription & { descriptionVersion?: number }>}
21
+ */
22
+ export async function getCollectionOrThrow({
23
+ storage,
24
+ spaceId,
25
+ collectionId,
26
+ requestName
27
+ }: {
28
+ storage: StorageBackend
29
+ spaceId: string
30
+ collectionId: string
31
+ requestName: string
32
+ }): Promise<CollectionDescription & { descriptionVersion?: number }> {
33
+ const collectionDescription = await storage.getCollectionDescription({
34
+ spaceId,
35
+ collectionId
36
+ })
37
+ if (!collectionDescription) {
38
+ throw new CollectionNotFoundError({ requestName })
39
+ }
40
+ return collectionDescription
41
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared handler prelude for the create-by-consent bootstrap (paralleling
3
+ * spaceContext.ts / keystoreContext.ts): the operations that create a
4
+ * top-level resource owned by a DID named in the request *body* (Create
5
+ * Space, the create form of Update Space, Create Keystore) have no stored
6
+ * controller to verify against yet, so the invocation must be *authorized by*
7
+ * the body's controller -- signed directly by it, or via a delegation chain
8
+ * rooted in it. Verifying against the signer instead would let anyone install
9
+ * an unrelated, non-consenting DID as controller.
10
+ */
11
+ import type { FastifyRequest } from 'fastify'
12
+ import { handleZcapVerify, isRootInvocation } from '../zcap.js'
13
+ import type { ProblemError } from '../errors.js'
14
+ import type { IDID } from '../types.js'
15
+
16
+ /**
17
+ * Verifies that the invocation is authorized by `controller` (the DID named
18
+ * in the request body). For the bare-root invocation form the signer *is* the
19
+ * invoker, so a mismatch is rejected up front, before any signature work. The
20
+ * delegated form instead carries a capability chain, judged by the
21
+ * verification (which synthesizes the root capability with `controller` as
22
+ * its controller): a delegated invocation that fails to verify is a chain not
23
+ * rooted in the body's controller, wrapped in `MismatchError` (spec
24
+ * `controller-mismatch`, 400). Root-form verification failures keep their
25
+ * generic errors -- the signer already matched the controller.
26
+ *
27
+ * @param options {object}
28
+ * @param options.request {FastifyRequest} supplies url, method, headers,
29
+ * logger, `request.zcap`, and `request.server` for serverUrl
30
+ * @param options.controller {IDID} the controller DID named in the request
31
+ * body, which must authorize the invocation
32
+ * @param options.allowedTarget {string} the capability's expected
33
+ * invocationTarget (full URL, including host and port)
34
+ * @param options.allowedAction {string} expected action, e.g. an HTTP verb
35
+ * @param options.MismatchError {new (options) => ProblemError} the
36
+ * operation's `controller-mismatch` error class (constructed with
37
+ * `{ zcapSigningDid, controller, cause? }`)
38
+ * @param [options.requestName] {string} human-readable request name, used
39
+ * in error titles
40
+ * @param [options.maxChainLength] {number} max delegation chain length,
41
+ * root included (see `verifyZcap`)
42
+ * @param [options.maxDelegationTtl] {number} max delegated-zcap TTL in
43
+ * milliseconds (see `verifyZcap`)
44
+ * @returns {Promise<void>}
45
+ */
46
+ export async function verifyBodyControllerConsent({
47
+ request,
48
+ controller,
49
+ allowedTarget,
50
+ allowedAction,
51
+ MismatchError,
52
+ requestName,
53
+ maxChainLength,
54
+ maxDelegationTtl
55
+ }: {
56
+ request: FastifyRequest
57
+ controller: IDID
58
+ allowedTarget: string
59
+ allowedAction: string
60
+ MismatchError: new (options: {
61
+ zcapSigningDid: string
62
+ controller: string
63
+ cause?: Error
64
+ }) => ProblemError
65
+ requestName?: string
66
+ maxChainLength?: number
67
+ maxDelegationTtl?: number
68
+ }): Promise<void> {
69
+ const { url, method, headers } = request
70
+ const { serverUrl } = request.server
71
+ // The strict `requireAuthHeaders` hook guarantees auth headers were present
72
+ // and `parseAuthHeaders` set `request.zcap` before any calling handler.
73
+ const { keyId, invocation } = request.zcap!
74
+ const [zcapSigningDid] = keyId.split('#')
75
+ const rootInvocation = isRootInvocation({ invocation })
76
+ if (rootInvocation && zcapSigningDid !== controller) {
77
+ throw new MismatchError({ zcapSigningDid: zcapSigningDid!, controller })
78
+ }
79
+
80
+ try {
81
+ await handleZcapVerify({
82
+ url,
83
+ allowedTarget,
84
+ allowedAction,
85
+ method,
86
+ headers,
87
+ serverUrl,
88
+ spaceController: controller,
89
+ requestName,
90
+ logger: request.log,
91
+ // Consent verifies a chain rooted in the BODY's controller for a
92
+ // resource that does not exist yet, so there is no keystore or Space
93
+ // scope a revocation could have been stored under.
94
+ revocation: 'no-revocation-scope',
95
+ maxChainLength,
96
+ maxDelegationTtl
97
+ })
98
+ } catch (err) {
99
+ if (!rootInvocation) {
100
+ throw new MismatchError({
101
+ zcapSigningDid: zcapSigningDid!,
102
+ controller,
103
+ cause: err as Error
104
+ })
105
+ }
106
+ throw err
107
+ }
108
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared WebKMS keystore context helper (the `/kms` facet): the keystore
3
+ * analogue of `fetchSpaceAndVerify`
4
+ * (spaceContext.ts), used by the keystore-lifecycle, key-operation, and
5
+ * revocation request handlers.
6
+ */
7
+ import type { FastifyRequest } from 'fastify'
8
+ import { handleZcapVerify } from '../zcap.js'
9
+ import { isUrlSafeSegment } from '../lib/validateId.js'
10
+ import { kmsKeystoresPath } from '../lib/paths.js'
11
+ import {
12
+ KMS_MAX_CHAIN_LENGTH,
13
+ KMS_MAX_DELEGATION_TTL
14
+ } from '../config.default.js'
15
+ import { KeystoreNotFoundError } from '../errors.js'
16
+ import type { KeystoreConfig } from '../types.js'
17
+
18
+ /**
19
+ * Loads a keystore config by its URL param, masking an unknown (or
20
+ * non-URL-safe) id as the 404 `KeystoreNotFoundError` -- the WAS
21
+ * existence-masking convention. The verification-free half of
22
+ * `fetchKeystoreAndVerify`, for handlers that need the config before they can
23
+ * build their expected values (the revocation route).
24
+ *
25
+ * @param options {object}
26
+ * @param options.request {FastifyRequest} supplies `request.server.storage`
27
+ * @param options.keystoreId {string} the keystore's local id (URL param)
28
+ * @param options.requestName {string} request name used in error titles
29
+ * @returns {Promise<KeystoreConfig>} the stored config
30
+ */
31
+ export async function fetchKeystore({
32
+ request,
33
+ keystoreId,
34
+ requestName
35
+ }: {
36
+ request: FastifyRequest
37
+ keystoreId: string
38
+ requestName: string
39
+ }): Promise<KeystoreConfig> {
40
+ // A non-URL-safe id cannot name a stored keystore (ids are server-generated
41
+ // base58 values) and must not reach the filesystem layer: same 404 masking.
42
+ if (!isUrlSafeSegment(keystoreId)) {
43
+ throw new KeystoreNotFoundError({ requestName })
44
+ }
45
+ const config = await request.server.storage.getKeystore({ keystoreId })
46
+ if (!config) {
47
+ throw new KeystoreNotFoundError({ requestName })
48
+ }
49
+ return config
50
+ }
51
+
52
+ /**
53
+ * Loads the keystore config (404 `KeystoreNotFoundError` when absent -- the
54
+ * WAS existence-masking convention) and verifies the capability invocation
55
+ * against the *stored* config's controller, with the keystore URL as the root
56
+ * invocation target. `allowedAction` is the webkms action (`read` / `write` /
57
+ * an operation name), not the HTTP verb. The verification carries the unified
58
+ * `/kms` delegation policy (`KMS_MAX_CHAIN_LENGTH`, `KMS_MAX_DELEGATION_TTL`;
59
+ * the zcap library itself already requires `expires` on every delegated
60
+ * capability) and the keystore's revocation-store chain inspector, so a
61
+ * revoked delegation fails on every keystore-rooted route.
62
+ *
63
+ * @param options {object}
64
+ * @param options.request {FastifyRequest} supplies url, method, headers,
65
+ * logger, and `request.server` for serverUrl + storage
66
+ * @param options.keystoreId {string} the keystore's local id (URL param)
67
+ * @param options.allowedAction {string} expected zcap action
68
+ * @param options.requestName {string} request name used in error titles
69
+ * @param [options.allowTargetAttenuation] {boolean} accept a request URL
70
+ * under the keystore URL (the key routes: every key operation roots in the
71
+ * keystore's capability, with the key URL as an attenuated target -- see
72
+ * `verifyZcap`)
73
+ * @param [options.allowTargetQuery] {boolean} tolerate query parameters on
74
+ * the request URL that are absent from the capability target (the List Keys
75
+ * route's `?limit` / `?cursor` pagination parameters select a page within an
76
+ * already-authorized target; the same treatment as List Keystores)
77
+ * @returns {Promise<{ config: KeystoreConfig, dereferencedChainLength: number }>}
78
+ * the stored config and the verified invocation's chain length, root
79
+ * included (a root invocation is 1) -- the input to the per-key
80
+ * `maxCapabilityChainLength` gate at operation time
81
+ */
82
+ export async function fetchKeystoreAndVerify({
83
+ request,
84
+ keystoreId,
85
+ allowedAction,
86
+ requestName,
87
+ allowTargetAttenuation = false,
88
+ allowTargetQuery = false
89
+ }: {
90
+ request: FastifyRequest
91
+ keystoreId: string
92
+ allowedAction: string
93
+ requestName: string
94
+ allowTargetAttenuation?: boolean
95
+ allowTargetQuery?: boolean
96
+ }): Promise<{ config: KeystoreConfig; dereferencedChainLength: number }> {
97
+ const { serverUrl, storage } = request.server
98
+ const config = await fetchKeystore({ request, keystoreId, requestName })
99
+ const { url, method, headers } = request
100
+ const allowedTarget = new URL(
101
+ kmsKeystoresPath({ keystoreId }),
102
+ serverUrl
103
+ ).toString()
104
+ const zcapVerifyResult = await handleZcapVerify({
105
+ url,
106
+ allowedTarget,
107
+ allowedAction,
108
+ method,
109
+ headers,
110
+ serverUrl,
111
+ spaceController: config.controller,
112
+ requestName,
113
+ logger: request.log,
114
+ allowTargetAttenuation,
115
+ allowTargetQuery,
116
+ revocation: { storage, scope: { keystoreId } },
117
+ maxChainLength: KMS_MAX_CHAIN_LENGTH,
118
+ maxDelegationTtl: KMS_MAX_DELEGATION_TTL
119
+ })
120
+ return {
121
+ config,
122
+ dereferencedChainLength: zcapVerifyResult.dereferencedChain?.length ?? 1
123
+ }
124
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Request-layer adapter: resolves a Fastify request into a transport-neutral
3
+ * `ResourceInput` value object, so that storage backends never depend on
4
+ * Fastify. JSON bodies pass through as parsed values; raw blob bodies and
5
+ * multipart uploads are normalized to a readable byte stream.
6
+ */
7
+ import type { FastifyRequest } from 'fastify'
8
+ import { Readable } from 'node:stream'
9
+ import { isJson } from '../lib/isJson.js'
10
+ import {
11
+ MissingContentTypeError,
12
+ InvalidRequestBodyError,
13
+ PayloadTooLargeError
14
+ } from '../errors.js'
15
+ import type { ResourceInput, StorageBackend } from '../types.js'
16
+
17
+ /**
18
+ * @param request {import('fastify').FastifyRequest}
19
+ * @param [dataBackend] {StorageBackend} the resolved data-plane backend the
20
+ * write targets (the Collection's selected backend); its `maxUploadBytes` and
21
+ * `describe().id` size and label the `payload-too-large` (413) for a multipart
22
+ * upload. Defaults to the server `storage` backend, read lazily only on the
23
+ * multipart path so non-multipart callers need not supply it.
24
+ * @returns {Promise<ResourceInput>}
25
+ */
26
+ export async function resolveResourceInput(
27
+ request: FastifyRequest,
28
+ dataBackend?: StorageBackend
29
+ ): Promise<ResourceInput> {
30
+ const contentType = request.headers['content-type']
31
+ if (!contentType) {
32
+ throw new MissingContentTypeError({ requestName: 'Write Resource' })
33
+ }
34
+
35
+ if (isJson({ contentType })) {
36
+ return { kind: 'json', contentType, data: request.body }
37
+ }
38
+ if (contentType.startsWith('multipart')) {
39
+ // Spec ("Content Types and Representations"): a multipart write MUST carry
40
+ // exactly one file part -- reject zero file parts, or more than one, as
41
+ // `invalid-request-body` (400). Iterate every part rather than calling
42
+ // `request.file()` (which silently takes the first file part and ignores any
43
+ // extras). `toBuffer()` drains each file part's stream, which both lets the
44
+ // iterator advance to detect a second (disallowed) part and yields the exact
45
+ // byte length to pre-flight the upload cap. Buffering the part in memory is
46
+ // acceptable for this multipart convenience path (the HTML form workflow) --
47
+ // large binaries should use the streaming raw-body path. The buffer is
48
+ // bounded by the backend's `maxUploadBytes` (the multipart `fileSize` limit
49
+ // set in `plugin.ts`): `toBuffer()` throws `FST_REQ_FILE_TOO_LARGE` at the
50
+ // boundary, mapped here to `payload-too-large` (413).
51
+ const backend = dataBackend ?? request.server.storage
52
+ let file: { mimetype: string; bytes: Buffer } | undefined
53
+ for await (const part of request.parts()) {
54
+ if (part.type !== 'file') {
55
+ continue
56
+ }
57
+ if (file) {
58
+ throw new InvalidRequestBodyError({
59
+ requestName: 'Write Resource',
60
+ detail: 'multipart request must carry exactly one file part.'
61
+ })
62
+ }
63
+ try {
64
+ file = { mimetype: part.mimetype, bytes: await part.toBuffer() }
65
+ } catch (err) {
66
+ if ((err as { code?: string }).code === 'FST_REQ_FILE_TOO_LARGE') {
67
+ throw new PayloadTooLargeError({
68
+ maxUploadBytes: backend.maxUploadBytes!,
69
+ backendId: backend.describe().id
70
+ })
71
+ }
72
+ throw err
73
+ }
74
+ }
75
+ if (!file) {
76
+ throw new InvalidRequestBodyError({
77
+ requestName: 'Write Resource',
78
+ detail: 'multipart request is missing a file part.'
79
+ })
80
+ }
81
+ return {
82
+ kind: 'binary',
83
+ contentType: file.mimetype,
84
+ stream: Readable.from(file.bytes),
85
+ declaredBytes: file.bytes.length
86
+ }
87
+ }
88
+ // A `text/plain` body arrives already parsed as a **string** (Fastify's
89
+ // built-in text parser, which the `*` catch-all does not shadow); other
90
+ // `text/*` and binary media types arrive as a raw stream. Wrap a string (or
91
+ // Buffer) as a byte stream so it is written verbatim -- piping a string into
92
+ // the backend's `stream.pipeline` would iterate it by UTF-16 code unit (one
93
+ // write per character, splitting astral-plane characters into lone
94
+ // surrogates), corrupting the stored bytes.
95
+ const body = request.body
96
+ if (typeof body === 'string' || Buffer.isBuffer(body)) {
97
+ const bytes = Buffer.from(body as string | Buffer)
98
+ return {
99
+ kind: 'binary',
100
+ contentType,
101
+ stream: Readable.from(bytes),
102
+ declaredBytes: bytes.length
103
+ }
104
+ }
105
+ // A raw (non-multipart) blob body carries its size in `Content-Length` when
106
+ // present; expose it as `declaredBytes` so the backend can pre-flight the
107
+ // quota check before streaming. Ignore an absent or malformed value.
108
+ const contentLength = Number(request.headers['content-length'])
109
+ return {
110
+ kind: 'binary',
111
+ contentType,
112
+ stream: body as Readable,
113
+ declaredBytes:
114
+ Number.isInteger(contentLength) && contentLength >= 0
115
+ ? contentLength
116
+ : undefined
117
+ }
118
+ }