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,649 @@
1
+ /**
2
+ * Request handlers for Resource operations: create-by-id, get, and delete a
3
+ * Resource (JSON object or binary blob).
4
+ */
5
+ import type { FastifyReply, FastifyRequest } from 'fastify'
6
+ import { fetchSpaceAndAuthorize, fetchSpaceAndVerify } from './spaceContext.js'
7
+ import { getCollectionOrThrow } from './collectionContext.js'
8
+ import { resolveResourceInput } from './resourceInput.js'
9
+ import { invokerDid } from '../auth-header-hooks.js'
10
+ import { resolveBackend } from '../lib/backendRegistry.js'
11
+ import {
12
+ assertEncryptedWriteConforms,
13
+ assertEncryptedMetaConforms
14
+ } from '../lib/encryption.js'
15
+ import { assertValidIds } from '../lib/validateId.js'
16
+ import { resourcePath, metaPath } from '../lib/paths.js'
17
+ import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
18
+ import { parseKeyEpochHeader, parseMetaEpoch } from '../lib/keyEpoch.js'
19
+ import {
20
+ InvalidRequestBodyError,
21
+ ResourceNotFoundError,
22
+ rethrowOrWrapStorageError
23
+ } from '../errors.js'
24
+ import type { ResourceMetadataCustom } from '../types.js'
25
+
26
+ /**
27
+ * Validates and extracts the user-writable `custom` object from an Update
28
+ * Resource Metadata request body on a **plaintext** Collection. The body MUST be
29
+ * a JSON object; any top-level property other than `custom` is ignored (so a
30
+ * client may GET-modify-PUT the whole Metadata object). A missing `custom`
31
+ * clears all user-writable properties (returns `{}`). Throws
32
+ * `InvalidRequestBodyError` (400) when the body or `custom` shape is wrong.
33
+ *
34
+ * On an **encrypted** Collection this shape check does not apply -- `custom` is
35
+ * the opaque encryption envelope, validated structurally by
36
+ * {@link assertEncryptedMetaConforms} instead (a `422` on non-conformance).
37
+ * `putMeta` branches on the Collection's `encryption` marker after authorization.
38
+ * @param body {unknown} the parsed request body
39
+ * @returns {ResourceMetadataCustom}
40
+ */
41
+ function parseCustomMetadata(body: unknown): ResourceMetadataCustom {
42
+ const requestName = 'Update Resource Metadata'
43
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
44
+ throw new InvalidRequestBodyError({
45
+ requestName,
46
+ detail: 'Request body must be a JSON object.'
47
+ })
48
+ }
49
+ const { custom } = body as Record<string, unknown>
50
+ if (custom === undefined) {
51
+ return {}
52
+ }
53
+ if (typeof custom !== 'object' || custom === null || Array.isArray(custom)) {
54
+ throw new InvalidRequestBodyError({
55
+ requestName,
56
+ detail: 'The `custom` property must be a JSON object.',
57
+ pointer: '/custom'
58
+ })
59
+ }
60
+ const { name, tags } = custom as Record<string, unknown>
61
+ if (name !== undefined && typeof name !== 'string') {
62
+ throw new InvalidRequestBodyError({
63
+ requestName,
64
+ detail: 'The `custom.name` property must be a string.',
65
+ pointer: '/custom/name'
66
+ })
67
+ }
68
+ if (
69
+ tags !== undefined &&
70
+ (typeof tags !== 'object' || tags === null || Array.isArray(tags))
71
+ ) {
72
+ throw new InvalidRequestBodyError({
73
+ requestName,
74
+ detail: 'The `custom.tags` property must be a JSON object.',
75
+ pointer: '/custom/tags'
76
+ })
77
+ }
78
+ // Tag values MUST be strings (spec: values SHOULD be strings; the wire type
79
+ // models them as `Record<string, string>`).
80
+ if (
81
+ tags !== undefined &&
82
+ Object.values(tags as Record<string, unknown>).some(
83
+ value => typeof value !== 'string'
84
+ )
85
+ ) {
86
+ throw new InvalidRequestBodyError({
87
+ requestName,
88
+ detail: 'Every `custom.tags` value must be a string.',
89
+ pointer: '/custom/tags'
90
+ })
91
+ }
92
+ return {
93
+ ...(name !== undefined && { name }),
94
+ ...(tags !== undefined && { tags: tags as Record<string, string> })
95
+ }
96
+ }
97
+
98
+ export class ResourceRequest {
99
+ /**
100
+ * PUT /space/:spaceId/:collectionId/:resourceId
101
+ * Request handler for "Create (or Update) Resource by Id" request
102
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
103
+ * request.zcap: {
104
+ * keyId, headers, signature, created, expires, invocation, digest
105
+ * }
106
+ *
107
+ * @param request {import('fastify').FastifyRequest}
108
+ * @param reply {import('fastify').FastifyReply}
109
+ * @returns {Promise<FastifyReply>}
110
+ */
111
+ static async put(
112
+ request: FastifyRequest<{
113
+ Params: { spaceId: string; collectionId: string; resourceId: string }
114
+ }>,
115
+ reply: FastifyReply
116
+ ): Promise<FastifyReply> {
117
+ const {
118
+ params: { spaceId, collectionId, resourceId }
119
+ } = request
120
+ const { storage } = request.server
121
+ const requestName = 'Put Resource'
122
+
123
+ // Reject path-traversal / non-URL-safe ids before any storage access.
124
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
125
+
126
+ // Verify (capability-only): creating/updating a Resource requires a valid
127
+ // capability invocation; no access-control-policy fallback.
128
+ await fetchSpaceAndVerify({
129
+ request,
130
+ spaceId,
131
+ targetPath: resourcePath({ spaceId, collectionId, resourceId }),
132
+ requestName
133
+ })
134
+
135
+ // zCap checks out, continue
136
+
137
+ // Fetch collection by id
138
+ const collectionDescription = await getCollectionOrThrow({
139
+ storage,
140
+ spaceId,
141
+ collectionId,
142
+ requestName
143
+ })
144
+
145
+ // Fail-closed encryption enforcement: if the Collection declares a recognized
146
+ // `encryption` scheme, the content write MUST be a conforming envelope of it
147
+ // (right media type + envelope shape), else `encryption-scheme-mismatch`
148
+ // (422). Runs after auth (above) and the 404, before the body is resolved --
149
+ // so a wrong content type is rejected without consuming the upload, and the
150
+ // 422 is only observable by a caller already authorized to write here.
151
+ assertEncryptedWriteConforms({
152
+ collectionDescription,
153
+ contentType: request.headers['content-type'],
154
+ body: request.body
155
+ })
156
+
157
+ // Route resource bytes to the Collection's selected (data-plane) backend.
158
+ const dataBackend = await resolveBackend({
159
+ request,
160
+ spaceId,
161
+ collectionId,
162
+ collectionDescription
163
+ })
164
+ const input = await resolveResourceInput(request, dataBackend)
165
+ // A content write into an encrypted Collection MAY declare the key epoch it
166
+ // encrypted under via the `WAS-Key-Epoch` header (the `key-epochs` feature);
167
+ // the server stores it opaquely and clears it when absent (the new
168
+ // ciphertext's epoch is unknown -- a stale stamp is worse than none).
169
+ // Advisory, non-signature-covered metadata.
170
+ const { epoch } = parseKeyEpochHeader({
171
+ headers: request.headers,
172
+ requestName
173
+ })
174
+ // Surface any `If-Match` / `If-None-Match` write precondition to the storage
175
+ // layer, which evaluates it atomically with the write (returning 412
176
+ // `precondition-failed` on a mismatch -- rethrown unchanged below).
177
+ let written: { version: number }
178
+ try {
179
+ written = await dataBackend.writeResource({
180
+ spaceId,
181
+ collectionId,
182
+ resourceId,
183
+ input,
184
+ createdBy: invokerDid(request),
185
+ epoch,
186
+ ...parseWritePreconditions(request.headers)
187
+ })
188
+ } catch (err) {
189
+ rethrowOrWrapStorageError({ err, requestName })
190
+ }
191
+ // Return the new ETag so a client can chain a subsequent conditional write.
192
+ return reply.status(204).header('etag', formatEtag(written.version)).send()
193
+ }
194
+
195
+ /**
196
+ * GET /space/:spaceId/:collectionId/:resourceId
197
+ * Request handler for "Get Resource" request
198
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
199
+ * request.zcap: {
200
+ * keyId, headers, signature, created, expires, invocation, digest
201
+ * }
202
+ *
203
+ * @param request {import('fastify').FastifyRequest}
204
+ * @param reply {import('fastify').FastifyReply}
205
+ * @returns {Promise<FastifyReply>}
206
+ */
207
+ static async get(
208
+ request: FastifyRequest<{
209
+ Params: { spaceId: string; collectionId: string; resourceId: string }
210
+ }>,
211
+ reply: FastifyReply
212
+ ): Promise<FastifyReply> {
213
+ const {
214
+ params: { spaceId, collectionId, resourceId }
215
+ } = request
216
+ const { storage } = request.server
217
+ const requestName = 'Get Resource'
218
+
219
+ // Reject path-traversal / non-URL-safe ids before any storage access.
220
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
221
+
222
+ // Authorize (capability-or-policy): capability invocation first, then fall
223
+ // back to the effective access-control policy (e.g. a world-readable
224
+ // Resource). Throws on denial.
225
+ await fetchSpaceAndAuthorize({
226
+ request,
227
+ spaceId,
228
+ collectionId,
229
+ resourceId,
230
+ targetPath: resourcePath({ spaceId, collectionId, resourceId }),
231
+ requestName
232
+ })
233
+
234
+ // authorized, continue
235
+
236
+ // Fetch collection by id
237
+ const collectionDescription = await getCollectionOrThrow({
238
+ storage,
239
+ spaceId,
240
+ collectionId,
241
+ requestName
242
+ })
243
+
244
+ // Read the bytes from the Collection's selected (data-plane) backend.
245
+ const dataBackend = await resolveBackend({
246
+ request,
247
+ spaceId,
248
+ collectionId,
249
+ collectionDescription
250
+ })
251
+ const contentType = request.headers['content-type']
252
+ let result
253
+ try {
254
+ result = await dataBackend.getResource({
255
+ spaceId,
256
+ collectionId,
257
+ resourceId,
258
+ contentType
259
+ })
260
+ } catch (err) {
261
+ // Rethrow a typed ProblemError unchanged -- `getResource`'s
262
+ // ResourceNotFoundError (404) for an absent resource, or a typed fault a
263
+ // data-plane backend raises -- and wrap anything unexpected as a 500.
264
+ rethrowOrWrapStorageError({ err, requestName })
265
+ }
266
+
267
+ const getReply = reply.status(200).type(result.storedResourceType)
268
+ // Surface the ETag validator (the conditional-writes feature) when the
269
+ // backend tracks a version for this Resource.
270
+ if (result.version !== undefined) {
271
+ getReply.header('etag', formatEtag(result.version))
272
+ }
273
+ return getReply.send(result.resourceStream)
274
+ }
275
+
276
+ /**
277
+ * HEAD /space/:spaceId/:collectionId/:resourceId
278
+ * Request handler for "Head Resource" request: the same authorization as Get
279
+ * Resource but with no response body. Per spec "Content Types and
280
+ * Representations", the response `Content-Type` and `Content-Length`
281
+ * correspond to the `contentType` and `size` of the Resource's Metadata object
282
+ * (the bytes a GET would return). HEAD is a safe method, authorized as a read
283
+ * (capability-or-policy), the same as GET; it reads only the Metadata so it
284
+ * never opens the resource byte stream.
285
+ *
286
+ * Registered explicitly (ahead of the GET route) rather than relying on
287
+ * Fastify's auto-exposed HEAD, which would share the GET handler and stream
288
+ * the whole body -- yielding no `Content-Length` for a streamed representation.
289
+ *
290
+ * @param request {import('fastify').FastifyRequest}
291
+ * @param reply {import('fastify').FastifyReply}
292
+ * @returns {Promise<FastifyReply>}
293
+ */
294
+ static async head(
295
+ request: FastifyRequest<{
296
+ Params: { spaceId: string; collectionId: string; resourceId: string }
297
+ }>,
298
+ reply: FastifyReply
299
+ ): Promise<FastifyReply> {
300
+ const {
301
+ params: { spaceId, collectionId, resourceId }
302
+ } = request
303
+ const { storage } = request.server
304
+ const requestName = 'Head Resource'
305
+
306
+ // Reject path-traversal / non-URL-safe ids before any storage access.
307
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
308
+
309
+ // Authorize (capability-or-policy): the same read decision as Get Resource,
310
+ // against the same target (a HEAD reveals nothing a GET would not).
311
+ await fetchSpaceAndAuthorize({
312
+ request,
313
+ spaceId,
314
+ collectionId,
315
+ resourceId,
316
+ targetPath: resourcePath({ spaceId, collectionId, resourceId }),
317
+ requestName
318
+ })
319
+
320
+ // authorized, continue
321
+
322
+ // Fetch collection by id
323
+ const collectionDescription = await getCollectionOrThrow({
324
+ storage,
325
+ spaceId,
326
+ collectionId,
327
+ requestName
328
+ })
329
+
330
+ // Read Metadata from the Collection's selected (data-plane) backend.
331
+ const dataBackend = await resolveBackend({
332
+ request,
333
+ spaceId,
334
+ collectionId,
335
+ collectionDescription
336
+ })
337
+ let metadata
338
+ try {
339
+ metadata = await dataBackend.getResourceMetadata({
340
+ spaceId,
341
+ collectionId,
342
+ resourceId
343
+ })
344
+ } catch (err) {
345
+ // Rethrow a typed ProblemError from the data-plane backend unchanged;
346
+ // wrap anything unexpected as a 500.
347
+ rethrowOrWrapStorageError({ err, requestName })
348
+ }
349
+ if (!metadata) {
350
+ throw new ResourceNotFoundError({ requestName })
351
+ }
352
+
353
+ // Set the payload headers a GET would send, but send no body. Fastify keeps
354
+ // a manually-set `Content-Length` on a bodyless send (it is not recomputed
355
+ // to 0).
356
+ const headReply = reply
357
+ .status(200)
358
+ .type(metadata.contentType)
359
+ .header('content-length', metadata.size)
360
+ if (metadata.version !== undefined) {
361
+ headReply.header('etag', formatEtag(metadata.version))
362
+ }
363
+ return headReply.send()
364
+ }
365
+
366
+ /**
367
+ * GET /space/:spaceId/:collectionId/:resourceId/meta
368
+ * Request handler for "Read Resource Metadata" request. Returns the REQUIRED
369
+ * server-managed fields (`contentType`, `size`), the OPTIONAL `createdAt` /
370
+ * `updatedAt` timestamps, and the user-writable `custom` object (omitted when
371
+ * empty). Authorization is capability-or-policy, the same as Get Resource:
372
+ * metadata reveals nothing beyond what a GET of the resource itself exposes via
373
+ * Content-Type / Content-Length, so a `PublicCanRead` policy also grants
374
+ * metadata reads.
375
+ *
376
+ * @param request {import('fastify').FastifyRequest}
377
+ * @param reply {import('fastify').FastifyReply}
378
+ * @returns {Promise<FastifyReply>}
379
+ */
380
+ static async getMeta(
381
+ request: FastifyRequest<{
382
+ Params: { spaceId: string; collectionId: string; resourceId: string }
383
+ }>,
384
+ reply: FastifyReply
385
+ ): Promise<FastifyReply> {
386
+ const {
387
+ params: { spaceId, collectionId, resourceId }
388
+ } = request
389
+ const { storage } = request.server
390
+ const requestName = 'Get Resource Metadata'
391
+
392
+ // Reject path-traversal / non-URL-safe ids before any storage access.
393
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
394
+
395
+ // Authorize (capability-or-policy): the capability's `invocationTarget` is
396
+ // the full `/meta` URL (matching the request URL), and the policy level
397
+ // resolves at the resource as for Get Resource.
398
+ await fetchSpaceAndAuthorize({
399
+ request,
400
+ spaceId,
401
+ collectionId,
402
+ resourceId,
403
+ targetPath: metaPath({ spaceId, collectionId, resourceId }),
404
+ requestName
405
+ })
406
+
407
+ // authorized, continue
408
+
409
+ // Fetch collection by id
410
+ const collectionDescription = await getCollectionOrThrow({
411
+ storage,
412
+ spaceId,
413
+ collectionId,
414
+ requestName
415
+ })
416
+
417
+ // Read Metadata from the Collection's selected (data-plane) backend.
418
+ const dataBackend = await resolveBackend({
419
+ request,
420
+ spaceId,
421
+ collectionId,
422
+ collectionDescription
423
+ })
424
+ let metadata
425
+ try {
426
+ metadata = await dataBackend.getResourceMetadata({
427
+ spaceId,
428
+ collectionId,
429
+ resourceId
430
+ })
431
+ } catch (err) {
432
+ // Rethrow a typed ProblemError from the data-plane backend unchanged;
433
+ // wrap anything unexpected as a 500.
434
+ rethrowOrWrapStorageError({ err, requestName })
435
+ }
436
+ if (!metadata) {
437
+ throw new ResourceNotFoundError({ requestName })
438
+ }
439
+
440
+ // `version` (content) and `metaVersion` (metadata) are out-of-band ETag
441
+ // validators, not part of the Resource Metadata wire body, so strip both
442
+ // before serializing. The `/meta` sub-resource carries its OWN ETag
443
+ // (`metaVersion`, V2) so a metadata-only edit does not disturb the content
444
+ // ETag; it is present only once metadata has been written.
445
+ const { version: _version, metaVersion, ...metadataBody } = metadata
446
+ const metaReply = reply.status(200).type('application/json')
447
+ if (metaVersion !== undefined) {
448
+ metaReply.header('etag', formatEtag(metaVersion))
449
+ }
450
+ return metaReply.send(JSON.stringify(metadataBody))
451
+ }
452
+
453
+ /**
454
+ * PUT /space/:spaceId/:collectionId/:resourceId/meta
455
+ * Request handler for "Update Resource Metadata" request. A full replacement
456
+ * of the Metadata object's user-writable `custom` object (any property omitted
457
+ * is cleared; a body with no `custom` clears them all). Server-managed
458
+ * properties are untouched, and any top-level property other than `custom` in
459
+ * the body is ignored (so a client may GET-modify-PUT the whole object). Does
460
+ * NOT create: a `PUT` to the `/meta` of a nonexistent Resource is a 404.
461
+ * Authorization is capability-only (the `PUT` action), the same as Put
462
+ * Resource. Returns 204.
463
+ *
464
+ * @param request {import('fastify').FastifyRequest}
465
+ * @param reply {import('fastify').FastifyReply}
466
+ * @returns {Promise<FastifyReply>}
467
+ */
468
+ static async putMeta(
469
+ request: FastifyRequest<{
470
+ Params: { spaceId: string; collectionId: string; resourceId: string }
471
+ Body: unknown
472
+ }>,
473
+ reply: FastifyReply
474
+ ): Promise<FastifyReply> {
475
+ const {
476
+ params: { spaceId, collectionId, resourceId }
477
+ } = request
478
+ const { storage } = request.server
479
+ const requestName = 'Update Resource Metadata'
480
+
481
+ // Reject path-traversal / non-URL-safe ids before any storage access.
482
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
483
+
484
+ // Pre-auth body shape (400): the body MUST be a JSON object. The deeper
485
+ // `custom` shape check is deferred until after authorization, where the
486
+ // Collection's `encryption` marker decides whether `custom` is a plaintext
487
+ // `{ name, tags }` (validated by `parseCustomMetadata`) or an opaque envelope
488
+ // (validated structurally by `assertEncryptedMetaConforms`) -- neither is
489
+ // knowable before reading the Collection Description, and gating the check on
490
+ // auth keeps a 422/400 observable only to a caller authorized to write here.
491
+ if (
492
+ typeof request.body !== 'object' ||
493
+ request.body === null ||
494
+ Array.isArray(request.body)
495
+ ) {
496
+ throw new InvalidRequestBodyError({
497
+ requestName,
498
+ detail: 'Request body must be a JSON object.'
499
+ })
500
+ }
501
+
502
+ // Verify (capability-only): writing metadata requires a valid capability
503
+ // invocation (the `PUT` action); no access-control-policy fallback.
504
+ await fetchSpaceAndVerify({
505
+ request,
506
+ spaceId,
507
+ targetPath: metaPath({ spaceId, collectionId, resourceId }),
508
+ requestName
509
+ })
510
+
511
+ // zCap checks out, continue
512
+
513
+ // Fetch collection by id
514
+ const collectionDescription = await getCollectionOrThrow({
515
+ storage,
516
+ spaceId,
517
+ collectionId,
518
+ requestName
519
+ })
520
+
521
+ // Branch on the Collection's encryption marker. On an encrypted Collection
522
+ // the `custom` value MUST be a conforming envelope of the scheme (stored
523
+ // opaquely, `422` on a plaintext/malformed value); on a plaintext Collection
524
+ // it MUST be a well-formed `{ name, tags }` object (`400` otherwise).
525
+ let custom: ResourceMetadataCustom | Record<string, unknown>
526
+ if (collectionDescription.encryption?.scheme !== undefined) {
527
+ const rawCustom = (request.body as Record<string, unknown>).custom
528
+ assertEncryptedMetaConforms({ collectionDescription, custom: rawCustom })
529
+ custom = rawCustom as Record<string, unknown>
530
+ } else {
531
+ custom = parseCustomMetadata(request.body)
532
+ }
533
+
534
+ // The key-epoch stamp (the `key-epochs` feature) MAY also be declared here
535
+ // as a top-level `epoch` member (a sibling of `custom`). Unlike `custom`
536
+ // (full-replace), an omitted `epoch` PRESERVES the stored value -- it
537
+ // describes the content write, not the metadata write. A present value must
538
+ // be a non-empty string (400).
539
+ const { epoch } = parseMetaEpoch({
540
+ body: request.body as Record<string, unknown>,
541
+ requestName
542
+ })
543
+
544
+ // Write Metadata to the Collection's selected (data-plane) backend. An
545
+ // `If-Match` / `If-None-Match` precondition (the `conditional-writes`
546
+ // feature) is evaluated on the `/meta` `metaVersion` atomically with the
547
+ // write; a mismatch surfaces as 412 `precondition-failed` (rethrown unchanged).
548
+ const dataBackend = await resolveBackend({
549
+ request,
550
+ spaceId,
551
+ collectionId,
552
+ collectionDescription
553
+ })
554
+ let written
555
+ try {
556
+ written = await dataBackend.writeResourceMetadata({
557
+ spaceId,
558
+ collectionId,
559
+ resourceId,
560
+ custom,
561
+ epoch,
562
+ ...parseWritePreconditions(request.headers)
563
+ })
564
+ } catch (err) {
565
+ rethrowOrWrapStorageError({ err, requestName })
566
+ }
567
+ // A Metadata object cannot exist apart from its Resource: a PUT to the
568
+ // `/meta` of a nonexistent Resource is a 404 (this operation does not create).
569
+ if (!written) {
570
+ throw new ResourceNotFoundError({ requestName })
571
+ }
572
+
573
+ // Return the new `/meta` ETag (`metaVersion`) so a client can chain a
574
+ // subsequent conditional metadata write.
575
+ return reply
576
+ .status(204)
577
+ .header('etag', formatEtag(written.metaVersion))
578
+ .send()
579
+ }
580
+
581
+ /**
582
+ * DELETE /space/:spaceId/:collectionId/:resourceId
583
+ * Request handler for "Delete Resource" request
584
+ * Before this, `parseAuthHeaders()` hook executed, resulting in:
585
+ * request.zcap: {
586
+ * keyId, headers, signature, created, expires, invocation, digest
587
+ * }
588
+ *
589
+ * @param request {import('fastify').FastifyRequest}
590
+ * @param reply {import('fastify').FastifyReply}
591
+ * @returns {Promise<FastifyReply>}
592
+ */
593
+ static async delete(
594
+ request: FastifyRequest<{
595
+ Params: { spaceId: string; collectionId: string; resourceId: string }
596
+ }>,
597
+ reply: FastifyReply
598
+ ): Promise<FastifyReply> {
599
+ const {
600
+ params: { spaceId, collectionId, resourceId }
601
+ } = request
602
+ const { storage } = request.server
603
+ const requestName = 'Delete Resource'
604
+
605
+ // Reject path-traversal / non-URL-safe ids before any storage access.
606
+ assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
607
+
608
+ // Verify (capability-only): deleting a Resource requires a valid capability
609
+ // invocation; no access-control-policy fallback.
610
+ await fetchSpaceAndVerify({
611
+ request,
612
+ spaceId,
613
+ targetPath: resourcePath({ spaceId, collectionId, resourceId }),
614
+ requestName
615
+ })
616
+
617
+ // Fetch collection by id
618
+ const collectionDescription = await getCollectionOrThrow({
619
+ storage,
620
+ spaceId,
621
+ collectionId,
622
+ requestName
623
+ })
624
+
625
+ // zCap checks out, continue. An `If-Match` precondition is evaluated by the
626
+ // storage layer atomically with the removal; a mismatch surfaces as 412
627
+ // `precondition-failed` (rethrown unchanged below).
628
+ const { ifMatch } = parseWritePreconditions(request.headers)
629
+ // Delete from the Collection's selected (data-plane) backend.
630
+ const dataBackend = await resolveBackend({
631
+ request,
632
+ spaceId,
633
+ collectionId,
634
+ collectionDescription
635
+ })
636
+ try {
637
+ await dataBackend.deleteResource({
638
+ spaceId,
639
+ collectionId,
640
+ resourceId,
641
+ ifMatch
642
+ })
643
+ } catch (err) {
644
+ rethrowOrWrapStorageError({ err, requestName })
645
+ }
646
+
647
+ return reply.status(204).send()
648
+ }
649
+ }