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
package/CHANGELOG.md ADDED
@@ -0,0 +1,1432 @@
1
+ # History
2
+
3
+ ## 0.9.1 - 2026-07-12
4
+
5
+ ### Added
6
+
7
+ - **Offline re-encryption tool for at-rest WebKMS key records**
8
+ (`pnpm reencrypt-kms-records [--dry-run] [--data-dir <path>]`,
9
+ `scripts/reencrypt-kms-records.ts`). With the server stopped, it walks every
10
+ stored key record, decrypts each through the configured KEK registry
11
+ (plaintext records pass through), and rewrites it in place under the current
12
+ KEK -- or back to plaintext under `KMS_RECORD_CURRENT_KEK=none`. This is what
13
+ makes a rotation finishable: records written before encryption was enabled (or
14
+ under a since-rotated KEK) can now be re-wrapped, so an old KEK can finally be
15
+ retired from `KMS_RECORD_KEKS`, and the decrypt-only wind-down can end with
16
+ the KEK variables dropped entirely. Idempotent (records already in the target
17
+ form are untouched); filesystem backend only (refuses to run when
18
+ `DATABASE_URL` is set).
19
+ - **ZCap revocation on the WAS route families.** A capability delegated from a
20
+ Space's root capability can now be revoked, via
21
+ `POST /space/:spaceId/zcaps/revocations/:revocationId` -- the sibling of the
22
+ existing `/kms` revocation endpoint, and the same wire contract
23
+ (`:revocationId` is the to-be-revoked capability's id, URL-encoded; the body
24
+ is that capability, verbatim; success is 204). Previously the only lever
25
+ against a leaked Space or Collection capability was a short `expires` and
26
+ waiting it out. The submission is authorized under the dual-root rule: an
27
+ invocation rooted in the Space, or in the revocation URL itself, whose
28
+ synthesized root is controlled by every controller in the to-be-revoked
29
+ capability's chain -- so a delegee can revoke its own capability without
30
+ holding a separate one. A root capability is never revocable; resubmitting a
31
+ stored revocation is a 400.
32
+ - A revoked capability is rejected from then on wherever a Space-rooted chain is
33
+ verified: the write and privileged routes, and the capability leg of the read
34
+ routes. Because access-control policies are permissive, revoking a capability
35
+ withdraws only what that capability granted -- access a policy already grants
36
+ everyone (a public-readable Space) is unaffected.
37
+ - Space exports carry the Space's revocation records (top-level `revocations/`
38
+ entries in the archive, on both backends), and imports restore them under the
39
+ destination Space's scope -- so an export/import round-trip (backup/restore,
40
+ backend migration) does not resurrect a revoked capability. Archives from
41
+ servers that predate this carry none and import as before.
42
+ - **Multi-recipient encrypted Collections and key epochs.** An encrypted
43
+ Collection's `encryption` marker may now carry per-epoch wrapped collection
44
+ keys -- `epochs` (each an `{ id, recipients }`, where a recipient is the JWE
45
+ recipients-entry shape: a `header` with `kid` / `alg` plus a wrapped
46
+ `encrypted_key`) and a `currentEpoch`. Each app holds its own key-agreement
47
+ key and gets the epoch key wrapped to it, so adding a reader wraps the current
48
+ key to it and removing one appends a fresh epoch that excludes it. The server
49
+ never holds a key: it validates only the marker's shape (recipients are
50
+ well-formed, `currentEpoch` names an epoch that exists) and enforces two
51
+ safety rails on update -- `epochs` is append-only and `currentEpoch` never
52
+ moves backwards -- rejecting a violation with `invalid-request-body` (400).
53
+ Recipient churn within an epoch stays a free update (the set-once check is
54
+ still scheme-only).
55
+ - A Resource may declare the key epoch its content was encrypted under, so a
56
+ reader picks the right key before attempting decryption: a `WAS-Key-Epoch`
57
+ request header on a content write (working uniformly for JSON, raw-stream, and
58
+ multipart writes), or a top-level `epoch` member on a `PUT .../meta` body. The
59
+ value is advisory client-declared metadata -- the server stores it opaquely,
60
+ never computing or verifying it, and requires only that a present value be a
61
+ non-empty string (400 otherwise). A content write stores the header's value
62
+ and clears the stamp when absent (the new ciphertext's epoch is unknown); a
63
+ metadata write preserves the stored value unless the body supplies a new one.
64
+ The stamp is returned by `GET .../meta`, rides each List Collection item and
65
+ each `changes`-feed document, and survives a Space export/import round-trip,
66
+ on both backends.
67
+ - **Conditional Collection Description writes.**
68
+ `PUT /space/:spaceId/:collectionId` now honors an `If-Match` precondition
69
+ against the Collection Description's monotonic version, evaluated atomically
70
+ with the write (a stale validator is `precondition-failed`, 412), and every
71
+ create/update surfaces the new version as an `ETag` header -- as does `GET` of
72
+ the Collection. This gives recipient edits a compare-and-swap so two clients
73
+ concurrently changing the marker cannot silently clobber one another; an
74
+ unconditional `PUT` still works exactly as before. Both backends advertise the
75
+ `key-epochs` feature token.
76
+ - **Multi-KEK registration for at-rest key-record encryption.** An operator can
77
+ now register more than one key-encryption key (KEK) for the WebKMS key-record
78
+ cipher, so a rotation adds a new KEK while keeping the old one available to
79
+ decrypt records already written under it. Two new env variables join the
80
+ existing `KMS_RECORD_KEK` (which stays the single-KEK alias):
81
+ `KMS_RECORD_KEKS` takes a comma-separated list of AES-256 Multikey KEKs -- the
82
+ first entry is the current KEK by default, so a rotation is "prepend the new
83
+ KEK, keep the old one behind it"; `KMS_RECORD_CURRENT_KEK` optionally names
84
+ which registered KEK wraps new records (a `urn:kek:sha256:` id, a multibase
85
+ value, or the literal `none`). `none` selects a decrypt-only posture: existing
86
+ encrypted records still read, but new key records are written plaintext -- the
87
+ path for winding encryption back down. Malformed or ambiguous configuration
88
+ (both single and list forms set, a current-KEK reference that matches nothing,
89
+ a duplicate list entry) fails fast at startup without echoing any secret.
90
+
91
+ ### Changed
92
+
93
+ - The revocation store (`StorageBackend.insertRevocation` / `isRevoked`) is now
94
+ keyed on a `RevocationScope` -- a keystore or a Space -- rather than a
95
+ keystore id. A revocation stored under one scope has no effect on the other;
96
+ Space revocations live outside the Space's own tree and are deleted with the
97
+ Space.
98
+
99
+ ### Fixed
100
+
101
+ - **Space exports are now byte-reproducible.** Every entry in an export archive
102
+ is written with a fixed `mtime` (the Unix epoch) instead of the wall-clock
103
+ time it was packed, on both backends. Previously two exports of an unchanged
104
+ Space differed whenever they straddled a one-second boundary, which made
105
+ byte-for-byte comparisons of consecutive exports intermittently fail. An
106
+ export is now a pure function of the Space's contents -- content-addressable
107
+ and diff-stable. The timestamp carried no meaning on import.
108
+
109
+ ## 0.9.0 - 2026-07-09
110
+
111
+ ### Added
112
+
113
+ - **Server-managed `createdBy` provenance on Spaces, Collections, and
114
+ Resources.** A read-only `createdBy` (a `did:key` DID string) now records who
115
+ created each Space, Collection, and Resource: taken from the invoker of the
116
+ _first_ write (the capability's signing key, fragment stripped) and preserved
117
+ verbatim on every later write, so it names the creator rather than the last
118
+ writer. It is server-authoritative -- a `createdBy` supplied in a client's
119
+ request body is always dropped, never honored -- and is omitted/absent when
120
+ there was no resolved invoker (a token-provisioned Space create, or data
121
+ written before this change). Recorded only when a write _creates_ the record:
122
+ an object created without an invoker keeps an absent `createdBy` forever,
123
+ rather than a later writer being backfilled as its creator. A soft-deleted
124
+ Resource keeps its `createdBy` on its tombstone, so re-creating it under a
125
+ different invoker still reports the original creator; export/import
126
+ round-trips the value. Surfaced in `GET /space/:spaceId`,
127
+ `GET /space/:spaceId/:collectionId`, and
128
+ `GET /space/:spaceId/:collectionId/:resourceId/meta`, and echoed in the 201
129
+ body of Create Space / Create Collection so a create response and a subsequent
130
+ GET agree. The `changes` query profile carries it on live documents and on
131
+ tombstones, so a replica learns each Resource's creator from the feed rather
132
+ than fetching `/meta` per Resource.
133
+
134
+ ### Changed
135
+
136
+ - The `changes` query profile's response documents are now typed by the shared
137
+ `ChangeDocument` / `ChangesPage` wire shapes from `@interop/storage-core`,
138
+ rather than an inline shape. The `StorageBackend.changesSince` port keeps its
139
+ own (differently named) document shape: it is the storage contract, not the
140
+ wire model.
141
+
142
+ - **Blinded-index EDV query (the `blinded-index` query profile /
143
+ `blinded-index-query` feature).** The reserved Collection
144
+ `POST /space/{s}/{c}/query` endpoint gains a second profile alongside
145
+ `changes`: the EDV blinded-attribute query. A body of
146
+ `{ profile: 'blinded-index', index, equals | has, count?, limit?, cursor? }`
147
+ is evaluated against the HMAC-blinded `indexed` entries of the Collection's
148
+ stored documents (the envelopes `@interop/edv-client`'s IndexHelper produces):
149
+ `equals` is an OR across array elements of an AND within each element's
150
+ blinded `{name: value}` pairs, scoped to the `index` HMAC key id; `has`
151
+ requires every named blinded attribute be present. Matching is opaque string
152
+ comparison -- the server performs no cryptography and never sees plaintext
153
+ attribute names or values, so it is agnostic to the client's
154
+ attribute-blinding version. The response is `{ documents, hasMore, cursor? }`
155
+ -- the matching stored documents verbatim, ascending by resource id -- or
156
+ `{ count }` for a count query. Pagination closes the known EDV protocol gap
157
+ (`limit` + `hasMore` with no way to resume) by reusing WAS's opaque cursor
158
+ convention: `cursor` is present iff `hasMore`, and echoing it back in the next
159
+ query body resumes the scan. Query parameters ride the signed JSON POST body
160
+ (covered by the `Digest`); authorization is capability-or-policy with the same
161
+ read semantics as List Collection (an under-authorized caller receives a 404).
162
+ A malformed query body is a 400 `invalid-request-body`; a malformed cursor is
163
+ a 400 `invalid-cursor`; an unknown profile, or a backend without the
164
+ affordance, stays 501 `unsupported-operation`.
165
+
166
+ Implemented as an optional `StorageBackend.queryByBlindedIndex` method, served
167
+ by both first-party backends through a shared evaluator
168
+ (`src/lib/blindedIndex.ts` -- validation, matching, ordering, and cursor
169
+ pagination in one place, so the backends cannot drift), with a full-scan
170
+ strategy deliberate for these teaching backends. Both backends now advertise
171
+ `features: [..., 'blinded-index-query']`. Covered by a new backend-contract
172
+ block (run against filesystem and Postgres) and a `blinded-index-query-api`
173
+ integration suite.
174
+
175
+ - **`unique: true` blinded-attribute enforcement on write.** The other half of
176
+ the EDV blinded-index affordance: a Resource write whose `indexed` blinded
177
+ attributes include one marked `unique: true` is rejected with **409**
178
+ (`UniqueAttributeConflictError`, reusing the `id-conflict` problem type like
179
+ the WebKMS conflicts) when another live JSON document in the same Collection
180
+ already claims the same triple. Semantics match the EDV reference servers
181
+ exactly: a conflict requires `unique: true` on **both** sides (an existing
182
+ document carrying the same pair without `unique` does not conflict), the claim
183
+ is keyed on the full **(HMAC key id, name, value)** triple (the same pair
184
+ under a different HMAC key is no conflict), a document keeping its own unique
185
+ attribute across an update never self-conflicts, enforcement applies to create
186
+ and update alike, and a tombstoned holder frees its claim. The check is atomic
187
+ with the write: only unique-carrying JSON writes pay for it -- the filesystem
188
+ backend serializes them per Collection on its keyed mutex (nested outside the
189
+ ordinary per-Resource lock), and the Postgres backend takes a per-Collection
190
+ transaction-scoped advisory lock (held to commit, so of N racing claimants
191
+ exactly one wins) -- so plain writes keep their existing locking. The conflict
192
+ scan lives with the query evaluator in `src/lib/blindedIndex.ts`
193
+ (`collectUniqueBlindedTerms` / `assertNoUniqueBlindedConflict`), shared by
194
+ both backends. When a unique conflict and a failing `If-Match` both apply, the
195
+ 409 wins over the 412 (both backends agree; pinned by the contract suite).
196
+ Space import (tar merge) bypasses `writeResource` and therefore does not
197
+ enforce the invariant -- imports restore a controller's own export, matching
198
+ the check's write-path scope. Covered by a new backend-contract block
199
+ (including an N-concurrent- claimants race) and an API-level 409 test.
200
+
201
+ - **Config validation & fail-fast startup.** The whole env surface
202
+ (`SERVER_URL`, `PORT`, `DATABASE_URL`, `STORAGE_LIMIT_PER_SPACE`,
203
+ `MAX_UPLOAD_BYTES`, `WAS_ENABLED_BACKENDS`, `KMS_RECORD_KEK`,
204
+ `WAS_ONBOARDING_TOKEN`) is now read and validated in one place,
205
+ `loadConfigFromEnv()` (`src/config.default.ts`), consumed by `start.ts`.
206
+ `SERVER_URL` is required (unset previously started a server whose ZCap
207
+ matching silently failed on every request) and must be an absolute
208
+ `http:`/`https:` URL with no path, query, or fragment -- a sub-path
209
+ `SERVER_URL` would silently break every delegated invocation and `Location`
210
+ header (the URL-join sites drop a base path), so it is rejected at startup
211
+ instead. `PORT` is validated as an integer in 1-65535 (default 3002). The
212
+ `fastifyWas` plugin applies the same `serverUrl` shape check at registration
213
+ (`assertValidServerUrl`), so a downstream composition inherits the fail-fast
214
+ behavior; omitting `serverUrl` there remains allowed (test compositions).
215
+
216
+ - **Provisioning gate (`authorizeProvisioning` / `WAS_ONBOARDING_TOKEN`).** A
217
+ new seam that lets a deployment gate the two open provisioning endpoints
218
+ (`POST /spaces/` and `POST /kms/keystores`) without touching the rest of the
219
+ ZCap surface. The `fastifyWas` plugin gains an `authorizeProvisioning`
220
+ callback (`{ request }` in, `'verify'` / `'grant'` / `'deny'` out -- or a
221
+ thrown `ProblemError`) plus a built-in, off-by-default onboarding-token check
222
+ (`onboardingToken` option / `WAS_ONBOARDING_TOKEN` env), implemented as a
223
+ stock authorizer over that same seam (`onboardingTokenAuthorizer`, exported):
224
+ when a token is set, the two endpoints require an
225
+ `Authorization: Bearer <token>` header (timing-safe compared), which then
226
+ substitutes for ZCap verification on that request while every other operation
227
+ keeps its normal capability-invocation path. The two options are mutually
228
+ exclusive (rejected at startup). Default behavior (neither configured) is
229
+ unchanged -- provisioning stays open, authorized by proving control of the
230
+ body's `controller` DID (the teaching default). Adds `src/provisioning.ts`.
231
+ The conformance suite's high-level `WasClient` suites now provision Spaces
232
+ through the token path when `TEST_ONBOARDING_TOKEN` is set (a shared
233
+ `provisionSpace` helper), so the full suite passes against a token-gated
234
+ server:
235
+ `WAS_ONBOARDING_TOKEN=abc TEST_ONBOARDING_TOKEN=abc pnpm conformance:local`.
236
+
237
+ - **WebKMS List Keys (`GET /kms/keystores/:keystoreId/keys`).** A fork extension
238
+ beyond upstream webkms-switch (which has no key list): enumerates a keystore's
239
+ public key descriptions — the Get Key Description projection per key
240
+ (`describeKmsKey`, never a secret field) plus `keyUrl`, the key's canonical
241
+ invocation URL (`<keystoreId>/keys/<localId>`), which the `publicAlias` /
242
+ `publicAliasTemplate` override otherwise erases from `id` — exactly the handle
243
+ a recovery client needs to rediscover — sorted by local id and paginated with
244
+ the standard opaque cursor (`KEY_LIST_LIMIT` per page; an empty keystore
245
+ returns `{ results: [] }`, not 404). Capability-verified as `read` against the
246
+ keystore controller with `<keystoreId>/keys` accepted as an attenuated target,
247
+ so a `sign`-scoped delegation on one key URL still cannot enumerate the
248
+ keystore. Adds `listKeys` to the `StorageBackend` contract (both backends) and
249
+ `KeyRequest.list`.
250
+
251
+ - **PostgreSQL storage backend (`DATABASE_URL`).** A second first-party
252
+ `StorageBackend` (`src/backends/postgres.ts`, schema in
253
+ `src/backends/postgresSchema.ts`), implementing the full WAS + WebKMS surface
254
+ over rows and selected by setting `DATABASE_URL` (unset keeps the default
255
+ filesystem backend). Four deliberate design departures: **transactional quota
256
+ accounting** (`spaces.usage_bytes`, maintained in the same transaction as
257
+ every content write/delete -- the per-Space capacity is now a _hard_ limit
258
+ under concurrency, closing the filesystem backend's documented soft-limit
259
+ caveat); **row-lock concurrency** (`SELECT ... FOR UPDATE` transactions
260
+ replace the single-process `KeyedMutex`, so conditional writes stay correct
261
+ across multiple server processes sharing one database); **buffered `bytea`
262
+ blobs** (bounded by `maxUploadBytes`, which defaults to a 64 MiB cap on this
263
+ backend rather than "unbounded"; chunked-row streaming is a planned
264
+ follow-up); and **cross-backend export/import** (the same tar archive dialect
265
+ as the filesystem backend, so a Space exported from one imports into the other
266
+ -- the migration path in both directions -- with the Postgres apply loop
267
+ additionally atomic in one transaction). Embedded idempotent migrations run at
268
+ startup under an advisory lock; `ORDER BY` / keyset columns are `COLLATE "C"`
269
+ so pagination and change-feed ordering match the filesystem's code-unit order
270
+ exactly. New optional `StorageBackend.init()` / `close()` lifecycle hooks
271
+ (wired by the plugin), `PostgresBackend` exported from the package root, and a
272
+ new shared backend-contract test suite (`test/storage-backend-contract.ts`)
273
+ run against both backends -- opt-in for Postgres via `WAS_TEST_DATABASE_URL`
274
+ (`pnpm test:pg`), with per-suite throwaway schemas for isolation. Shared logic
275
+ extracted so the two backends cannot drift: precondition evaluation
276
+ (`src/lib/preconditions.ts`), quota report derivation
277
+ (`src/lib/backendUsage.ts`), export manifest building
278
+ (`src/lib/exportManifest.ts`), and page-size clamping
279
+ (`src/lib/pagination.ts`).
280
+
281
+ - **At-rest encryption of WebKMS key records (`KMS_RECORD_KEK`).** The optional,
282
+ schema-compatible hardening increment: when a record KEK is configured, the
283
+ secret-bearing fields of a stored `/kms` key record (`privateKeyMultibase` /
284
+ `secret`, and anything not on the plaintext allowlist) are envelope-encrypted
285
+ -- a fresh per-record `A256GCM` content-encryption key wrapped `A256KW` under
286
+ a config-supplied AES-256 KEK -- before the record reaches storage, so a
287
+ disk/database dump exposes only ciphertext and a `kekId`. It is **off by
288
+ default** (plaintext when unset, the teaching default), needs **no schema
289
+ migration** to enable (old plaintext records stay readable via an
290
+ unconditional decrypt pass-through, and are never retroactively rewritten),
291
+ and supports **KEK rotation without re-encryption** (each record keeps the
292
+ `kekId` it was written under). The wire projection (`KmsKeyDescription`) is
293
+ untouched: secrets never crossed the wire and still don't. New pure,
294
+ backend-agnostic cipher `src/lib/kmsRecordCipher.ts`, applied at the KMS
295
+ orchestration seam in `src/requests/KeyRequest.ts` (encrypt before
296
+ `insertKey`, single decrypt funnel after `getKey`). See the `KMS_RECORD_KEK`
297
+ environment variable in the README.
298
+
299
+ - **Library entry point (`src/index.ts`) and package `exports` map.** The
300
+ package is now consumable as a dependency, not only runnable standalone:
301
+ `import { fastifyWas, createApp, FileSystemBackend, defaultBackend } from 'was-teaching-server'`,
302
+ plus the `StorageBackend` contract (and the rest of the domain types) and the
303
+ typed protocol errors. The entry point also loads the Fastify module
304
+ augmentation, so a consumer sees the decorated `FastifyInstance` typed. `tsc`
305
+ now emits declarations (`declaration` / `declarationMap`), and `package.json`
306
+ gains `main` / `types` / `exports` pointing at `dist/`, plus a `files`
307
+ allowlist (`dist`, `common`, `src` -- so the emitted source/declaration maps
308
+ resolve -- and `CHANGELOG.md`) so the published tarball excludes the test
309
+ suites. Usage (including composing a minimal or hardened server from the
310
+ plugin) is documented in `docs/consuming-server-as-library.md`.
311
+
312
+ ### Security
313
+
314
+ - **Default-on limits.** The server no longer runs unbounded out of the box:
315
+ - `MAX_UPLOAD_BYTES` now defaults to **64 MiB** on both backends (previously
316
+ unset meant no cap on the filesystem backend, and the catch-all binary
317
+ content-type parser hands the handler a raw stream that bypasses Fastify's
318
+ `bodyLimit` entirely -- so a raw blob PUT/POST had no size limit at all, and
319
+ an unbounded multipart part could buffer without limit). Set
320
+ `MAX_UPLOAD_BYTES=unlimited` to opt out explicitly (filesystem backend only:
321
+ the Postgres backend buffers each upload in memory as a single `bytea` and
322
+ rejects `unlimited` at startup).
323
+ - New **count quotas**, default-on: `MAX_SPACES_PER_CONTROLLER` (100),
324
+ `MAX_COLLECTIONS_PER_SPACE` (100), and `MAX_RESOURCES_PER_SPACE` (10000,
325
+ live Resources across a Space's Collections; tombstones don't count). A
326
+ create beyond a limit is rejected with a 507 `quota-exceeded` problem
327
+ (`CountQuotaExceededError`, exported); overwrites never trip a count check,
328
+ deletes free slots, and the tar import path enforces the Collection/Resource
329
+ counts identically on both backends. Enforcement is transactional (hard
330
+ under concurrency) on Postgres -- Space counts serialize on a per-controller
331
+ advisory lock, and the `spaces` table gains a backfilled, indexed
332
+ `controller` column (automatic migration) -- and soft on the filesystem
333
+ backend, matching the byte quota's posture. Each accepts `unlimited` to opt
334
+ out. Matching `fastifyWas` plugin options (`maxSpacesPerController` /
335
+ `maxCollectionsPerSpace` / `maxResourcesPerSpace`) apply to the default
336
+ backend; both backend constructors take them directly. Pinned by the shared
337
+ backend contract suite for both backends.
338
+ - `STORAGE_LIMIT_PER_SPACE` stays unlimited when unset, but startup now logs a
339
+ **warning** prompting an explicit choice;
340
+ `STORAGE_LIMIT_PER_SPACE=unlimited` acknowledges and silences it (the
341
+ warning lives in `start.ts` only, so library and test compositions stay
342
+ silent). A startup warning is also logged when the per-upload cap is
343
+ explicitly disabled.
344
+ - **`StorageError` responses no longer leak internal fault details.** The 500
345
+ `storage-error` problem+json body copied the underlying cause message into its
346
+ `title` and `detail`, so filesystem paths, errnos, or SQL fragments from a
347
+ failed backend operation could reach clients. The wire body is now a generic
348
+ "An internal storage error occurred."; the underlying `cause` still goes to
349
+ the server log via `handleError`. Pinned by a no-leak regression test.
350
+ - **`/api/cors` proxy is no longer an open SSRF vector.** The proxy now only
351
+ fetches `http`/`https` URLs and refuses any host that resolves to a private,
352
+ loopback, or link-local address (RFC 1918, `127.0.0.0/8`, `169.254.0.0/16`
353
+ cloud-metadata, CGNAT, IPv6 ULA/link-local, and IPv4-mapped forms), so a
354
+ request like `?url=http://169.254.169.254/...` is rejected before any fetch.
355
+ Redirects are now followed manually (capped at 5 hops) with every hop
356
+ re-validated the same way, closing the bypass where an allowed public URL 302s
357
+ the proxy to an internal address. (Adding a lightweight auth gate remains a
358
+ reasonable follow-up.)
359
+ - **`/api/cors` proxy hardening: DNS-rebinding protection and a response size
360
+ cap.** Two remaining gaps in the CORS proxy are closed. The SSRF host check
361
+ validated the resolved addresses but the subsequent `fetch` re-resolved DNS
362
+ independently, so a rebinding attacker could pass validation with a public IP
363
+ and have the connection land on a private one (a TOCTOU); the proxy now pins
364
+ each hop's connection to exactly the addresses it validated -- a per-request
365
+ undici `Agent` whose `connect.lookup` resolves only from that pin map -- while
366
+ keeping the original hostname on the wire so TLS certificate validation and
367
+ SNI still work. The relayed response body is now capped at 10 MiB, enforced
368
+ both from a declared `content-length` (rejected before reading the body) and
369
+ incrementally while streaming (the upstream stream is cancelled once the cap
370
+ is exceeded); an oversized response is a 502. Uses undici's `fetch` so the
371
+ dispatcher and fetch share one build.
372
+ - **Streamed and binary request bodies are now bound to their signed `Digest`.**
373
+ Previously only JSON/text bodies were recomputed and compared; an
374
+ `application/octet-stream`, image, multipart, or tar body could be swapped
375
+ under a valid signed `Digest`. Such bodies now pass through a hashing
376
+ transform that verifies the digest incrementally at end-of-stream, failing the
377
+ write on a mismatch (the partial file is removed).
378
+ - **KMS "unsupported key type / operation" gate is no longer bypassable via
379
+ prototype-chain names.** `generateKmsKey` and `runKeyOperation` now guard with
380
+ `Object.hasOwn`, so a client-supplied `type`/operation of `constructor`,
381
+ `toString`, etc. is rejected as unsupported rather than resolving to an
382
+ inherited `Object` member.
383
+ - **Backend-id enumeration is closed on Collection create/update.** The
384
+ backends-available check now runs after capability verification (like the
385
+ `id-conflict` / `encryption-immutable` conflicts), so an unauthorized caller
386
+ can no longer probe a Space's registered backend ids by distinguishing a `409`
387
+ from the masked `404`.
388
+
389
+ ### Fixed
390
+
391
+ - **`FileSystemBackend.deleteSpace()` is idempotent.** Deleting an absent Space
392
+ resolves instead of rejecting with `ENOENT`, matching the documented
393
+ `StorageBackend` delete contract (pinned by the new backend-contract suite).
394
+ - **Slash/no-slash redirects now send a followable `Location`.** The
395
+ canonicalization redirects emitted the literal route template (e.g.
396
+ `Location: /space/:spaceId`) with a `302`; they now build the concrete request
397
+ path (trailing slash toggled, query string preserved) and use `308` so a
398
+ redirected POST/PUT replays its method and body. The List-Collections no-slash
399
+ redirect is also registered as `GET` (it was mistakenly a `PUT`, so
400
+ `GET /space/:id/collections` fell through to a `409`).
401
+ - **A description-less Collection directory no longer 500s the whole Space
402
+ listing.** `listCollections` / `listCollectionItems` fall back to the
403
+ directory name instead of dereferencing an undefined description.
404
+ - **`text/plain` (and other string-parsed) resource bodies are stored
405
+ byte-for-byte.** A string body is written as its UTF-8 bytes rather than
406
+ iterated per character, so multi-byte / astral-plane content is no longer
407
+ corrupted.
408
+ - **Collection delete is idempotent.** Deleting an absent (or already-deleted)
409
+ Collection resolves `204` instead of surfacing an `ENOENT` as a `500`.
410
+ - **Space import inherits the write-path guards.** Import now enforces the
411
+ per-upload size cap (413) and fail-closed encryption conformance (422) on
412
+ every staged resource, and no longer resurrects a tombstoned resource (it
413
+ checks the metadata sidecar, not only the content file, before writing).
414
+ - **External-backend Collection listing no longer 500s.** `listCollectionItems`
415
+ accepts the caller's control-plane Collection description, which a selected
416
+ data-plane backend does not hold.
417
+ - **Malformed `Authorization` (missing `headers="..."`) is a clean `400`**
418
+ rather than an unhandled `500`.
419
+ - **Repeated `?include=` on `/quotas` no longer 500s** (an unauthenticated-
420
+ reachable fault on a public-readable Space); the value is normalized when the
421
+ query parser yields an array.
422
+ - Typed backend errors are no longer flattened to `500` (or double-logged) by
423
+ the Collection/Resource read/delete handlers; a memoized external-backend
424
+ adapter logs with the stable instance logger rather than the first request's;
425
+ a revocation with an unparseable `expires` no longer throws a `RangeError`;
426
+ and zcap verification failures log the underlying error (object-first) instead
427
+ of dropping it.
428
+
429
+ ### Changed
430
+
431
+ - **Atomic, durable writes in the filesystem backend.** Every write the
432
+ `FileSystemBackend` performs now goes through one audited helper module
433
+ (`src/lib/atomicFile.ts`): bytes are staged into a `.tmp-<uuid>` file in the
434
+ same directory, fsync'd, `rename`d (or hard-`link`ed, for the create-only
435
+ `wx`-style key/revocation inserts, preserving their EEXIST conflict semantics)
436
+ onto the final path, and the containing directory is fsync'd -- so a crash or
437
+ power loss mid-write can no longer leave a torn JSON document or a truncated
438
+ blob under a Resource's name. Previously, JSON resource bodies and
439
+ `importSpace` writes went directly to the final path, and blob uploads
440
+ streamed into it (with cleanup only on pipeline error, not crash); blobs now
441
+ stream into the temp file and are committed whole. Descriptions, sidecars,
442
+ policies, and keystore configs (previously written via fs-json-store, which
443
+ was atomic per-file but never fsync'd the directory) route through the same
444
+ helper; their read paths are unchanged. (The Postgres backend is unaffected --
445
+ durability there is the database's WAL.)
446
+ - **The `conformance/` suite no longer pins the default backend's display name**
447
+ (`Server Filesystem`); it asserts a non-empty `name` instead, so the suite
448
+ passes against any conforming WAS server -- including a Postgres-backed one
449
+ (`DATABASE_URL=... pnpm conformance:local`).
450
+ - **The WAS protocol surface is now a registerable Fastify plugin, `fastifyWas`
451
+ (`src/plugin.ts`).** The plugin owns the `serverUrl` / `storage` /
452
+ backend-provider decorations, CORS, multipart, the content-type parsers, and
453
+ the route groups (the four WAS groups plus the WebKMS `/kms` facet) --
454
+ everything from `routes.ts` down, including the per-group auth/digest hook
455
+ chains and error handler. `createApp()` (`src/server.ts`) is now the thin
456
+ community-edition composition: it registers `fastifyWas` (passing its options
457
+ through unchanged) plus the teaching-server extras (static assets, welcome
458
+ page, `/health`, the CORS proxy). No wire-behavior change; this is the
459
+ enabling refactor for the two-codebase strategy -- a hardened downstream
460
+ server can register the same plugin around its own persistence and policy
461
+ plugins. Adds `fastify-plugin` as a dependency (the decorations/parsers land
462
+ on the root instance, as before).
463
+
464
+ ## 0.8.0 - 2026-07-02
465
+
466
+ ### Added
467
+
468
+ - **Space-rooted delegated capabilities on the space-family routes.** Every
469
+ `/space/:spaceId/...` route now also accepts a delegated capability chain
470
+ rooted at the _Space's_ root capability with RESTful target attenuation -- the
471
+ same shape the WebKMS keystore routes already allowed. A controller can
472
+ delegate one capability for the whole Space (or, by attenuating the
473
+ `invocationTarget` at delegation time, for a single Collection under it) and
474
+ the delegate can invoke it against any URL underneath: resources, listings,
475
+ `/meta`, the changes-query endpoint, `/quotas` (composing with the existing
476
+ query-parameter attenuation). Root invocations of the exact target's own root
477
+ capability verify unchanged, so this only widens what the Space controller can
478
+ delegate, never who can access. Implemented as `verifyZcap`'s new
479
+ `attenuatedRootTarget` option, threaded through `authorize()` and the
480
+ `spaceContext` preludes; exercised by the new `test/session-zcaps.test.ts`
481
+ suite (the browser-session-key shape: space-wide read + per-collection
482
+ read/write, negative cases included -- notably that a collection-scoped
483
+ capability cannot PUT the Space Description, and that only the Space root is
484
+ accepted as a chain root).
485
+ - **WebKMS `/kms` facet: zcap revocations + delegation policy.** Delegated
486
+ capabilities on the `/kms` facet can now be revoked, and every keystore-rooted
487
+ verification enforces a unified delegation policy; driven end-to-end by
488
+ `@interop/webkms-client`'s `revokeCapability` in the new
489
+ `test/kms-revocation-api.test.ts` suite:
490
+ - `POST /kms/keystores/:keystoreId/zcaps/revocations/:revocationId`
491
+ (`:revocationId` = the URL-encoded id of the capability being revoked, which
492
+ is also the request body; 204 on success). Authorization follows
493
+ ezcap-express's `authorizeZcapRevocation` dual-root rule: the invocation may
494
+ root in the keystore, or in the revocation URL itself, whose synthesized
495
+ root capability is controlled by _every controller in the to-be-revoked
496
+ capability's fully verified chain_ -- so a delegee can revoke its own zcap
497
+ without holding a separate capability. The submitted chain must root in the
498
+ keystore being posted to; root zcaps cannot be revoked (400); a chain
499
+ containing an already-revoked link (resubmissions included) is a 400, with
500
+ the 409 duplicate reserved for a write race.
501
+ - Revocations are checked on every subsequent keystore-rooted invocation via
502
+ the zcap library's `inspectCapabilityChain` hook (`src/lib/revocations.ts`;
503
+ written route-family-agnostic so a later WAS-side store can reuse it).
504
+ Storage is the new insert-once `StorageBackend` pair `insertRevocation` /
505
+ `isRevoked`, unique on `(delegator, capability.id)` per keystore under
506
+ `data/keystores/<localId>/revocations/`; records lapse one day past the
507
+ capability's own `expires` (the filesystem analogue of a TTL index, pruned
508
+ lazily on read).
509
+ - Unified delegation policy on all `/kms` verifications: max chain length 10
510
+ (`KMS_MAX_CHAIN_LENGTH`) and a single 90-day max delegation TTL
511
+ (`KMS_MAX_DELEGATION_TTL`). The per-key `maxCapabilityChainLength` stored at
512
+ generate time is now enforced at operation time.
513
+ - `handleZcapVerify` now returns the verification result (the dereferenced
514
+ chain feeds the per-key gate), and `verifyZcap` grew optional
515
+ `inspectCapabilityChain` / `maxChainLength` / `maxDelegationTtl`
516
+ pass-throughs -- absent on the WAS route families, whose behavior is
517
+ unchanged. New runtime dependencies `@interop/zcap` and
518
+ `@interop/jsonld-signatures` (both already in the tree transitively) power
519
+ the revocation route's standalone `CapabilityDelegation` chain verification.
520
+
521
+ - **WebKMS `/kms` facet: local KMS module + key operations.** The `/kms` facet
522
+ now generates and uses server-held keys, driven end-to-end by
523
+ `@interop/webkms-client`'s `KeystoreAgent` and all four key classes
524
+ (`AsymmetricKey`, `KeyAgreementKey`, `Hmac`, `Kek`) in the new
525
+ `test/kms-key-api.test.ts` suite:
526
+ - `POST /kms/keystores/:keystoreId/keys` (`GenerateKeyOperation`, responding
527
+ 200 + `Location` + `{ keyId, keyDescription }` per webkms-switch),
528
+ `POST .../keys/:keyId` (operation dispatch by envelope `type`), and
529
+ `GET .../keys/:keyId` (public key description; capability-verified with the
530
+ `read` action). Every key route roots its capability in the **keystore** URL
531
+ with the key URL accepted as an attenuated target (a new
532
+ `allowTargetAttenuation` mode in `verifyZcap`); the expected zcap action is
533
+ the decapitalized operation name (`generateKey`, `sign`, ...).
534
+ - The single in-process `local-v1` module (`src/lib/kmsModule.ts`): Ed25519
535
+ sign (via `@interop/ed25519-verification-key`), X25519 `deriveSecret` (via
536
+ `@interop/x25519-key-agreement-key`, a new runtime dependency; raw ECDH, no
537
+ KDF), HMAC-SHA-256 sign/verify and AES-256-KW wrap/unwrap (node crypto).
538
+ Custody draws the line on what is served: asymmetric verify is client-local
539
+ by design and requesting it is a clean 400 not-supported. `publicAlias` /
540
+ `publicAliasTemplate` are applied to the description `id` at generate time
541
+ and re-applied stably on every read.
542
+ - Fixed: a wrong-length HMAC `signatureValue` is an ordinary
543
+ `{ verified: false }` (not an uncaught `timingSafeEqual` throw), and a
544
+ failed AES-KW unwrap resolves `{ unwrappedKey: null }`. A mismatched
545
+ `deriveSecret` `publicKey.type` is likewise a clean 400.
546
+ - Key records are stored using the `{ keystoreId, localId, meta, key }` shape
547
+ (secret material included, plaintext this increment; `controller`
548
+ deliberately not stored -- it is read live from the keystore config), as
549
+ opaque units behind new insert-once `StorageBackend` methods (`insertKey` /
550
+ `getKey`, with the protocol's 409 duplicate conflict) under
551
+ `data/keystores/<localId>/keys/<keyLocalId>.json`. Wire projections never
552
+ include secret fields.
553
+
554
+ - **WebKMS `/kms` facet: keystore lifecycle.** The server now hosts the first
555
+ slice of the Interop TypeScript analogue of Digital Bazaar's WebKMS system,
556
+ interoperating with `@interop/webkms-client@14.4.2` (whose `KmsClient` drives
557
+ the new test suite, `test/kms-keystore-api.test.ts` -- the client is the
558
+ conformance suite for this wire contract):
559
+ - `POST /kms/keystores` (Create Keystore, 201 + `Location` + bare config;
560
+ authorization is the chain-to-body-controller bootstrap rule, mirroring
561
+ Create Space), `GET /kms/keystores?controller=<did>` (list-by-controller,
562
+ `{ results }` capped at 100 -- you can only list your own),
563
+ `GET /kms/keystores/:keystoreId` (bare config), and
564
+ `POST /kms/keystores/:keystoreId` (update: `sequence` must be exactly
565
+ previous+1 and `kmsModule` is immutable -- both gated atomically in storage
566
+ with a 409 state conflict -- responding with the `{ config }` wrapper).
567
+ Local ids follow webkms-switch's scheme (multibase base58btc,
568
+ multihash-framed 128-bit random, via `@digitalcredentials/bnid`, now a
569
+ runtime dependency).
570
+ - The `/kms` route group is the first to use the strict `requireAuthHeaders`
571
+ hook: every webkms route -- GETs included -- is zcap-invoked, with `read` /
572
+ `write` zcap actions (not HTTP verbs); the protocol has no public reads.
573
+ Unknown keystores and failed authorization stay masked as `not-found` (404),
574
+ the server-wide convention.
575
+ - Storage grows a keystore tree (`data/keystores/<localId>/config.json`, a
576
+ sibling of `spaces/` -- the facet is deliberately separable) behind new
577
+ `StorageBackend` methods (`writeKeystore` / `getKeystore` / `updateKeystore`
578
+ / `listKeystoresByController`).
579
+
580
+ - **Operational `/health` liveness endpoint.** A public, unauthenticated,
581
+ side-effect-free `GET /health` returns `200` with an `application/health+json`
582
+ body (`{ "status": "pass", "version": ... }`, following the IETF
583
+ `draft-inadarei-api-health-check` shape) so load balancers, uptime monitors,
584
+ and orchestrators can poll cheaply; `HEAD /health` serves a bodyless `200` for
585
+ probes that don't want a body. This is an operational affordance, not a WAS
586
+ protocol feature. `scripts/conformance-local.ts` now polls `/health` for
587
+ startup readiness instead of the HTML welcome page.
588
+
589
+ - **Encryption Scheme Registry enforcement (spec "Encryption Scheme
590
+ Registry").** The server now provides the fail-closed guarantee that plaintext
591
+ can never land in a Collection marked encrypted with a recognized scheme:
592
+ - A Collection `encryption` marker is gated against a recognized-scheme
593
+ registry (`SUPPORTED_ENCRYPTION_SCHEMES`; v1 has `edv` ->
594
+ `application/json`). An unrecognized `scheme` is now rejected with the new
595
+ `unsupported-encryption-scheme` (400) error rather than stored opaquely.
596
+ - A Resource **content** write into a recognized-scheme Collection is
597
+ structurally validated (`Create Resource` `POST`, `Put Resource` `PUT`): the
598
+ request `Content-Type` MUST be the scheme's registered media type and the
599
+ body MUST be a structurally valid envelope. For `edv` that is an **EDV
600
+ Encrypted Document** -- a JSON object whose `jwe` member is a JWE in JSON
601
+ serialization (`isValidEdvDocument` wrapping `isValidEdvEnvelope`, shape
602
+ only, never decrypted) -- carried as `application/json`, matching what the
603
+ EDV codec actually stores (the earlier bare-`application/jose+json` JWE
604
+ profile was corrected to this before release). A non-conforming body (a
605
+ plaintext object, or a bare JWE with no `jwe` wrapper) is the new
606
+ `encryption-scheme-mismatch` (422). Checked after capability verification,
607
+ so an under-authorized caller still receives the privacy-merged `not-found`
608
+ (404).
609
+ - Requires `@interop/storage-core` ^0.3.1 (adds the two new problem types).
610
+
611
+ - **Encrypted Resource Metadata profile (spec "Encrypted Collections").** On a
612
+ Collection with a recognized `encryption` marker, a Resource's user-writable
613
+ `custom` metadata is now stored as an encryption **envelope** (the same
614
+ EDV-Document profile as content), symmetric with how content is stored:
615
+ - `PUT .../meta` on an encrypted Collection validates `custom` structurally as
616
+ a conforming envelope (`assertEncryptedMetaConforms`), rejecting a plaintext
617
+ `{ name, tags }` (or any non-envelope) with `encryption-scheme-mismatch`
618
+ (422), checked after auth + 404. The metadata document itself stays
619
+ `application/json`; only its `custom` sub-value is the envelope. The server
620
+ stores it opaquely and never decrypts. Plaintext Collections are unaffected
621
+ (`{ name, tags }` validated as before).
622
+ - The `/meta` sub-resource now carries its **own** monotonic `metaVersion`
623
+ ETag (V2 metadata versioning), independent of the content `version`: a
624
+ metadata-only write bumps `metaVersion` (and `updatedAt`) but leaves the
625
+ content ETag untouched, and honors `If-Match` / `If-None-Match` on
626
+ `metaVersion` (412 on mismatch), evaluated atomically under the per-Resource
627
+ write lock. `GET .../meta` returns the `metaVersion` as its `ETag`.
628
+ - The replication change feed (`changesSince`) now carries `metaVersion` and
629
+ the `custom` envelope, so a metadata-only edit replicates alongside content
630
+ (re-surfacing the Resource with a bumped `updatedAt` / `metaVersion` but
631
+ unchanged `version` / `data`).
632
+ - List Collection omits `name` from item summaries on an encrypted Collection
633
+ (the server cannot project a name out of an opaque envelope).
634
+
635
+ ### Fixed
636
+
637
+ - **Top-level falsy JSON Resources no longer 500.** Storing a bare top-level
638
+ JSON `null` (and likewise `false`, `0`, `""`) into a plaintext Collection
639
+ failed with a 500: the filesystem backend wrote JSON via `fs-json-store`,
640
+ whose `write` re-reads through `readExisting`, which treats a falsy
641
+ round-tripped value as "file does not exist". The backend now serializes and
642
+ writes JSON Resource bodies directly (creating the Collection dir as needed),
643
+ so any top-level JSON value -- object, array, or bare primitive -- round-trips
644
+ intact.
645
+
646
+ ## 0.7.0 - 2026-06-28
647
+
648
+ ### Added
649
+
650
+ - **Collection client-side encryption marker (spec "Encrypted Collections").** A
651
+ Collection may now carry a non-secret `encryption` marker (e.g.
652
+ `{ "scheme": "edv" }`) declaring its Resources client-side encrypted. The
653
+ server stores it opaquely (it never decrypts), validates only its shape
654
+ (`invalid-request-body` 400 on a malformed marker), and enforces **set-once**
655
+ immutability: declaring a marker on a Collection that lacks one is allowed,
656
+ but changing its `scheme` or clearing it on an existing Collection is rejected
657
+ with the new `encryption-immutable` (409) error. Accepted on Create Collection
658
+ (`POST`) and Update Collection (`PUT`) and echoed in the Collection
659
+ Description, so any authorized reader -- including a delegated consumer -- can
660
+ discover it. New helper `src/lib/encryption.ts` (mirrors the `backend`
661
+ validate/resolve pattern).
662
+ - **Per-Collection backend resolver (registered backends are now selectable).**
663
+ A Collection may now **select** a registered `external` backend as its
664
+ `backend`, and its **data plane** (resource bytes, metadata, listings, change
665
+ feed) is routed to that backend's adapter, while the server `default` backend
666
+ keeps the **control plane** (Space/Collection descriptions, policies, backend
667
+ registry records). A new resolver (`src/lib/backendRegistry.ts`) maps a
668
+ Collection's selected `backend.id` to a memoized `StorageBackend` adapter
669
+ built from an injected **provider registry** (`createApp({ providers })`); for
670
+ the `default` selection it short-circuits to the server backend, so existing
671
+ behavior is unchanged. The production provider registry ships **empty**, so a
672
+ Collection that selects an `external` backend with no registered provider
673
+ adapter fails closed with `unsupported-backend` (409) on its data-plane
674
+ operations until a provider is wired (stages 4-5: Google OAuth, Drive adapter,
675
+ at-rest secret encryption). Still provider-agnostic plumbing -- no Google
676
+ dependency.
677
+ - **Backend registration allowlist (`WAS_ENABLED_BACKENDS`).** An operator may
678
+ restrict which backend `provider`s a wallet can register via a comma-separated
679
+ env list; a registration whose `provider` is not listed is rejected fast with
680
+ `unsupported-backend` (409). Unset means **permissive** (any provider may be
681
+ registered), preserving prior behavior.
682
+
683
+ - **Backend registration write endpoints.** A wallet can now register an
684
+ `external` ("Bring Your Own Storage") backend against a Space via
685
+ `POST /space/{id}/backends`, replace it with
686
+ `PUT /space/{id}/backends/{backendId}` (upsert), and deregister it with
687
+ `DELETE /space/{id}/backends/{backendId}`. Authorization is the Space
688
+ controller's capability (capability-only, like Create Collection / Delete
689
+ Space). The write body is secret-bearing (a generic `provider` + `connection`
690
+ envelope), but every response and the `GET /space/{id}/backends` listing
691
+ return a **sanitized** descriptor whose `connection` is reduced to public
692
+ fields (`kind` / `status` / ...) -- the secret connection material is
693
+ reachable only via the internal `getBackend` storage method. The
694
+ `StorageBackend` contract gains `writeBackend` / `getBackend` / `listBackends`
695
+ / `deleteBackend`.
696
+
697
+ This increment is provider-agnostic **plumbing**: a registered backend is
698
+ listed but **not yet selectable** as a Collection's `backend` (Collection
699
+ create/select still resolves only `default`), at-rest secret encryption is
700
+ deferred, and registration records do **not** travel in a Space export (after
701
+ import the user re-registers). The live token exchange and provider adapter
702
+ are future work.
703
+
704
+ ### Fixed
705
+
706
+ - **Raw (non-multipart) binary blob writes now work for any content-type.** A
707
+ `PUT`/`POST` carrying a raw body of an arbitrary media type --
708
+ `application/octet-stream`, `application/jsonl`, `image/png`, etc. -- was
709
+ rejected with `415 Unsupported Media Type` before reaching the handler,
710
+ because Fastify ships content-type parsers only for `application/json` and
711
+ `text/plain`. A catch-all parser now passes any other media type through to
712
+ the handler as a raw byte stream (the `kind: 'binary'` storage path), so only
713
+ multipart uploads were possible before. More specific parsers (the built-in
714
+ JSON/text parsers, the `application/*+json` parser, `@fastify/multipart`, and
715
+ the `application/x-tar` import parser) still take precedence.
716
+ - **Dotted resource ids (e.g. `index.html`, `photo.png`) no longer mis-parse.**
717
+ The on-disk filename `r.<id>.<encodedContentType>.<ext>` was split naively on
718
+ `.`, so an id containing a dot was read back under the wrong id and
719
+ content-type (and broke Collection-listing keysets). The id and content-type
720
+ filename segments are now dot-escaped (`%2E`) so the separators are
721
+ unambiguous; dot-free ids and types are byte-identical to before.
722
+ - **`application/jsonl` (and `json5` / `json-seq`) are no longer mistaken for
723
+ JSON.** The `isJson` content-type test matched any media type merely
724
+ containing the substring `json`, routing JSON-Lines bodies through the JSON
725
+ path (corrupting them). The `json` token is now anchored to the end of the
726
+ media type, so only `application/json` and `application/<prefix>+json` are
727
+ treated as JSON.
728
+
729
+ ## 0.6.0 - 2026-06-15
730
+
731
+ ### Added
732
+
733
+ - **Replication change feed: the `changes` query profile.** The reserved
734
+ Collection query endpoint `POST /space/{id}/{cid}/query` now serves a
735
+ `changes` profile -- the basis for replicating a WAS Collection into a
736
+ local-first in-browser database such as RxDB. The signed JSON body carries
737
+ `{ profile: "changes", checkpoint?, limit? }`; the response is the
738
+ Collection's JSON documents and tombstones changed strictly after
739
+ `checkpoint`, in change order (`(updatedAt, resourceId)`), each as
740
+ `{ id, _deleted, updatedAt, version, data? }`, plus the next `checkpoint`
741
+ (`null` when nothing changed). A tombstone is surfaced as `_deleted: true`
742
+ with no `data`; binary (non-JSON) Resources are excluded. The default
743
+ filesystem backend advertises the new `changes-query` feature token; a backend
744
+ without the change feed (or any other profile) yields `unsupported-operation`
745
+ (501). The `StorageBackend` contract gains an optional `changesSince` method.
746
+
747
+ ### Changed
748
+
749
+ - **Resource deletes are now soft deletes (tombstones).** Deleting a Resource
750
+ drops its content representation but keeps its metadata sidecar as a tombstone
751
+ (`deleted: true`, a bumped `version` and `updatedAt`, the last-known
752
+ `contentType` retained), so the change feed (for replication) can surface the
753
+ delete until clients catch up. With no content file left, a tombstone is
754
+ invisible to every normal read path (`GET` / metadata / List Collection all
755
+ 404 or skip it), so the change is transparent to the existing API. Re-creating
756
+ a deleted id continues its monotonic `version`, and an export/import roundtrip
757
+ carries tombstones across. Garbage-collection of old tombstones is future
758
+ work.
759
+
760
+ ## 0.5.0 - 2026-06-15
761
+
762
+ ### Added
763
+
764
+ - **`GET /space/{id}/quotas?include=collections` opt-in.** The Space Quota
765
+ report now omits the per-Collection `usageByCollection` breakdown by default
766
+ and includes it only when `?include=collections` is requested (spec "Quotas"),
767
+ keeping the hot-path payload lean. Previously the breakdown was returned
768
+ unconditionally because a query string broke ZCap `invocationTarget` matching;
769
+ the handler now threads the scoped `allowTargetQuery` flag (introduced for
770
+ pagination) so the signed request authorizes against the bare `/quotas` target
771
+ despite the query. The high-level client requests the breakdown via
772
+ `space.quotas({ includeCollections: true })` (`@interop/was-client@^0.7.1`).
773
+
774
+ ### Changed
775
+
776
+ - The `StorageBackend.reportUsage` contract gains an `includeCollections?`
777
+ option; the breakdown is computed/returned only when set.
778
+
779
+ accepts optional `?limit` and `?cursor` query parameters and returns a `next`
780
+ link (a ready-to-follow URL with the opaque cursor and limit baked in) when a
781
+ further page may follow; the absence of `next` is the authoritative
782
+ end-of-list signal. Items are returned in a stable total order (ascending
783
+ `resourceId`, which also fixes the prior nondeterministic listing order, since
784
+ glob v13 does not sort). The filesystem backend reads `.meta` sidecars only
785
+ for the items on the page (previously one per Resource on every list). A
786
+ malformed or un-honorable cursor yields the new `invalid-cursor` (400) problem
787
+ type; authorization is checked before cursor validation, so an
788
+ under-authorized caller still gets the privacy-merged `404`. Pagination is
789
+ OPTIONAL: a request with neither parameter returns the first page (or
790
+ everything, if it fits). The opaque cursor codec lives in `src/lib/cursor.ts`.
791
+ ZCap verification gained a scoped `allowTargetQuery` flag (List Collection
792
+ only) so a capability for the bare Collection authorizes the query-bearing
793
+ `next` URL, honoring the spec's "Pagination parameters and authorization" rule
794
+ (the `limit`/`cursor` parameters select a page within an already-authorized
795
+ target and do not change the target a capability must match). Uses the new
796
+ `invalid-cursor` problem type and `CollectionResourcesList.next` field from
797
+ `@interop/storage-core@^0.2.3`.
798
+
799
+ - **Conditional writes (`conditional-writes` feature).** The filesystem backend
800
+ now exposes each Resource's monotonic `version` as an HTTP `ETag` strong
801
+ validator (returned on `GET` / `HEAD` / `GET .../meta` and on the `PUT` /
802
+ `POST` write responses) and honors the RFC9110 write preconditions:
803
+ - `If-Match: "<etag>"` -- update-if-unchanged: the write proceeds only if the
804
+ Resource's current ETag matches, else `412 precondition-failed`.
805
+ - `If-None-Match: *` -- create-if-absent: the write proceeds only if the
806
+ Resource does not yet exist, else `412 precondition-failed`. `DELETE` honors
807
+ `If-Match` the same way. The precondition is evaluated atomically with the
808
+ write under a per-Resource in-process mutex (ported from
809
+ `@interop/edv-server`), so two concurrent writers cannot both observe the
810
+ same prior version and both succeed -- the lost-update guard. Authorization
811
+ is checked _before_ the precondition, so a `412` is only ever observable by
812
+ a caller already authorized to write the target (an under-authorized caller
813
+ gets the privacy-merged `404`). This is single-instance write locking only;
814
+ horizontally-scaled locking is out of scope. Uses the new
815
+ `precondition-failed` (412) problem type from
816
+ `@interop/storage-core@^0.2.2`.
817
+
818
+ - The default filesystem backend descriptor now carries a `features` array
819
+ (surfaced at `GET /space/{id}/backends` and `GET /space/{id}/{cid}/backend`),
820
+ using the `features` field added in `@interop/storage-core@^0.2.0`. It
821
+ advertises `['conditional-writes']` (above); the remaining _server
822
+ affordances_ (`blinded-index-query`, `chunked-streams`) are added as each
823
+ lands.
824
+
825
+ - Accept `application/<suffix>+json` request bodies (e.g. `application/edv+json`
826
+ for EDV-over-WAS encrypted documents, `application/ld+json`) by parsing them
827
+ as JSON. Fastify's built-in parser only matches `application/json` exactly, so
828
+ structured-suffix JSON media types previously returned 415; a root-level
829
+ content-type parser now handles them (the suffix content type is preserved on
830
+ read). `isJson()` already treated `+json` as JSON downstream.
831
+
832
+ - `HEAD /space/{id}/{cid}/{rid}` (Head Resource). Returns the same headers a
833
+ `GET` would, with no body: a bodyless 200 whose `Content-Type` and
834
+ `Content-Length` are set from the Resource Metadata's `contentType`/`size`
835
+ (spec "Content Types and Representations"). Authorized as a read
836
+ (capability-or-policy, the same decision as `GET`), and reads only the
837
+ Metadata so it never opens the resource byte stream. Registered explicitly
838
+ ahead of the `GET` route to override Fastify's auto-exposed HEAD, which would
839
+ share the streaming GET handler and yield no `Content-Length`.
840
+
841
+ - Space and Collection Description objects now carry a relative `url` property
842
+ (`/space/{id}` and `/space/{id}/{cid}`) in their `GET` responses, alongside
843
+ the existing `linkset` (spec: the server populates the description `url`),
844
+ consistent with the `url` already present on List Spaces / List Collections
845
+ items.
846
+
847
+ - Reject a multipart write that does not carry exactly one file part with
848
+ `invalid-request-body` (400) (spec "Content Types and Representations").
849
+ `resolveResourceInput` now iterates `request.parts()` rather than taking the
850
+ first file part and silently ignoring extras, so both zero file parts and more
851
+ than one are rejected. The multipart `files` limit was raised from 1 to 2 so a
852
+ disallowed second part reaches the handler to be rejected with this 400 (a
853
+ `files: 1` limit instead has busboy silently drop it and raise its own
854
+ `FST_FILES_LIMIT` 413). The single permitted part is buffered in memory
855
+ bounded by the backend's `maxUploadBytes` (a multipart `fileSize` limit), so
856
+ an oversize multipart upload is rejected with `payload-too-large` (413) before
857
+ it is fully buffered. Large binaries should use the streaming raw-body upload
858
+ path; multipart is a convenience for the HTML-form workflow (see the README).
859
+
860
+ - Live `@interop/was-client` conformance coverage for storage introspection:
861
+ `space.backends()` / `space.quotas()` (the backend list and the per-backend
862
+ quota report with its per-collection breakdown) and `collection.backend()` /
863
+ `collection.quota()` (the Collection's selected backend descriptor and its
864
+ backend-scoped usage report). The collection-level tests require a published
865
+ client that exposes those methods.
866
+
867
+ ### Changed
868
+
869
+ - The Space and Collection `id` is now immutable on update: a `PUT /space/{id}`
870
+ (resp. `PUT /space/{id}/{cid}`) whose body carries an `id` that does not match
871
+ the id in the URL is rejected with `invalid-request-body` (400, pointer
872
+ `#/id`), before any controller/capability check. (The spec spells this out for
873
+ Update Space; it is applied to Update Collection for parity.)
874
+
875
+ - Space and Collection Description `type` arrays are served lexically sorted
876
+ (spec SHOULD).
877
+
878
+ ## 0.4.0 - 2026-06-13
879
+
880
+ ### Added
881
+
882
+ - Enforce Request Body Integrity via the `Digest` header (spec "Request Body
883
+ Integrity"). Any request carrying a `Content-Type` must cover the `digest`
884
+ header in its HTTP Signature (MUST) and present a `Digest` header; for
885
+ JSON/text bodies the server now independently recomputes the body's multihash
886
+ and compares it to the header (SHOULD), so a body cannot be swapped without
887
+ invalidating the signature. A missing, uncovered, malformed, or non-matching
888
+ digest is rejected with `invalid-authorization-header` (400). Implemented as
889
+ two route-group hooks (`src/digest.ts`): `captureRawBody` (preParsing) tees
890
+ the exact body bytes onto `request.rawBody` so the digest is checked against
891
+ precisely what the client signed (re-serializing the parsed JSON is not
892
+ guaranteed byte-identical); `verifyBodyDigest` (preValidation) performs the
893
+ checks before capability verification. Streamed bodies (multipart uploads, tar
894
+ import) are left unbuffered and get the covered-header + presence checks only.
895
+ Adds the `@interop/http-digest-header` dependency (`verifyHeaderValue`).
896
+
897
+ - Implement Update Resource Metadata, `PUT /space/{id}/{cid}/{rid}/meta` (spec
898
+ "Update Resource Metadata"), replacing the previous `unsupported-operation`
899
+ (501) stub. A `PUT` is a full replacement of the Metadata object's
900
+ user-writable `custom` object (`name`, `tags`): any property omitted is
901
+ cleared, and a body with no `custom` (e.g. `{}`) clears them all.
902
+ Server-managed properties are untouched and any top-level property other than
903
+ `custom` in the body is ignored, so a client may GET-modify-PUT the whole
904
+ object. The operation does not create -- a `PUT` to the `/meta` of a
905
+ nonexistent Resource is a 404 -- and returns 204; a malformed body or `custom`
906
+ shape is `invalid-request-body` (400). Authorization is capability-only (the
907
+ `PUT` action), like Put Resource. Read Resource Metadata (`GET .../meta`) now
908
+ also returns the OPTIONAL `createdAt` / `updatedAt` timestamps and the
909
+ `custom` object, and a Resource's `custom.name` is surfaced as its `name` in
910
+ List Collection results. Metadata is persisted in a per-Resource sidecar
911
+ (`.meta.<resourceId>.json`, the same dot-file convention as `.policy.`) via
912
+ the new `StorageBackend` `writeResourceMetadata()`; the sidecar is
913
+ created/maintained on Resource writes, swept on delete, and carried through
914
+ Space export/import.
915
+
916
+ - Implement the per-Collection Quota report, `GET /space/{id}/{cid}/quota` (spec
917
+ "Quotas"). Returns a single backend-usage entry scoped to the Collection:
918
+ `usageBytes` reflects only that Collection's consumption, while `state` /
919
+ `limit` / `restrictedActions` describe the backend's overall condition (the
920
+ quota is a per-backend limit). Backed by the new optional
921
+ `StorageBackend.reportCollectionUsage()` (filesystem: the Collection's slice
922
+ of the existing one-pass `du` breakdown); a backend that cannot account
923
+ per-Collection omits the method and the endpoint returns
924
+ `unsupported-operation` (501). Authorization is capability-or-policy, so an
925
+ unauthorized caller gets the maximum-privacy 404.
926
+
927
+ - Implement the per-upload `maxUploadBytes` constraint and `payload-too-large`
928
+ (413) enforcement (spec "Quotas"). A new `MAX_UPLOAD_BYTES` env var (parsed by
929
+ `parseMaxUploadBytes`, threaded through `createApp` to the default backend)
930
+ caps the size of a single Create/Update Resource write, distinct from the
931
+ cumulative per-Space quota: an oversized JSON or blob write is rejected with
932
+ `payload-too-large` (413) -- via an up-front size pre-flight plus a streaming
933
+ `_uploadCapGuard` for bodies whose size is not declared -- while smaller
934
+ uploads still succeed. The cap is advertised in both the Space and
935
+ per-Collection quota reports under `constraints.maxUploadBytes`. This wires
936
+ the previously type-only `PayloadTooLargeError`.
937
+
938
+ - Advertise the remaining quota/backend linkset relations (spec linksets): the
939
+ Collection linkset now carries the `quota` relation
940
+ (`https://wallet.storage/spec#quota`, alongside the `backend` relation), and
941
+ the Space linkset now carries `backends-available`
942
+ (`https://wallet.storage/spec#backends-available`) and `quotas`
943
+ (`https://wallet.storage/spec#quotas`), all unconditionally (their endpoints
944
+ always exist).
945
+
946
+ - Implement Collection backend selection (spec "Collection Backend Selected" /
947
+ "Backends"). A Collection Description now carries an optional `backend` object
948
+ (`{ "id": "default" }` by default); Create Collection (POST) and Update/Create
949
+ Collection (PUT) accept a body `backend`, validate its `id` against the
950
+ Space's backends-available, and persist the normalized value. An unknown id is
951
+ rejected with `unsupported-backend` (409) -- the error registry's previously
952
+ type-only entry now has its emit site -- and a malformed `backend` (not an
953
+ object with a string `id`) is `invalid-request-body` (400). The new
954
+ `GET /space/{id}/{cid}/backend` ("Collection Backend Selected") returns the
955
+ full backend descriptor for the Collection's selection, and the Collection
956
+ linkset now advertises the `backend` relation
957
+ (`https://wallet.storage/spec#backend`). Get Collection reports the selected
958
+ backend, default-filled for Collections created before the property existed.
959
+
960
+ - Implement the List Spaces operation, `GET /spaces/` (spec "List Spaces
961
+ Operation"), replacing the hardcoded 501. The response is
962
+ `{ url, totalItems, items }` with only the Spaces the caller is authorized to
963
+ see; each item carries `id`, `name` (when set), and the relative `url`
964
+ (`/space/{id}`). An anonymous or unauthorized request is not an error -- it
965
+ gets the empty-items `200`, the spec's explicit exception to 404 masking, so
966
+ the listing reveals nothing about which Spaces exist (the SpacesRepository
967
+ route group now installs `requireAuthHeadersOrPublicRead` so the read can
968
+ reach the handler; Create Space still 401s without auth headers). Candidates
969
+ come from the new `StorageBackend.listSpaces()` (filesystem backend: the
970
+ subdirectories of the spaces root), and authorization is decided once per
971
+ distinct Space controller: a bare-root invocation lists the signer's own
972
+ Spaces, while a delegated invocation lists the Spaces of whichever controller
973
+ roots its capability chain.
974
+
975
+ - Complete the spec's Error Type Registry (`src/problem-types.ts`): added the
976
+ three missing problem kinds and matching error classes -- `reserved-id` (409,
977
+ `ReservedIdError`), `unsupported-backend` (409, `UnsupportedBackendError`),
978
+ and `payload-too-large` (413, `PayloadTooLargeError`). `reserved-id` is
979
+ enforced immediately: the id sanitizer (`src/lib/validateId.ts`) now carries
980
+ the spec's full Reserved Path Segment Registry, per id position -- Collection
981
+ ids may not be `backends` / `collections` / `export` / `linkset` / `policy` /
982
+ `query` / `quotas` (plus this server's non-spec `import`), and Resource ids
983
+ may not be `backend` / `linkset` / `policy` / `query` / `quota` -- and a
984
+ collision is rejected with the spec's `reserved-id` (409) instead of the
985
+ previous `invalid-id` (400), which also only covered `policy` and `linkset`.
986
+ This closes a routing hole on verbs without a static route: for example,
987
+ `PUT /space/:spaceId/export` used to fall through to the parametric Create
988
+ Collection route and happily create a Collection literally named `export`,
989
+ shadowing the export endpoint. The other two new kinds have no emit sites yet:
990
+ `unsupported-backend` awaits the Collection `backend` property, and
991
+ `payload-too-large` awaits the per-upload `maxUploadBytes` constraint.
992
+
993
+ - Support the spec's delegated Create Space chain of authorization. The
994
+ invocation on `POST /spaces/` (and on create-via-`PUT /space/:spaceId`, see
995
+ Fixed below) must be _authorized by_ the body's `controller`: signed directly
996
+ by it (the common case), or signed by another DID presenting a delegation
997
+ chain rooted in it -- enabling provisioning services that create a Space on a
998
+ user's behalf, with the user's DID as `controller` from the start. Previously
999
+ `POST /spaces/` rejected any signer other than the body's `controller` with
1000
+ `controller-mismatch` _before_ verification, which blocked the delegated form.
1001
+ The friendly pre-verification 400 is kept for bare-root invocations (where the
1002
+ signer is the invoker, so it must be the controller); a delegated invocation
1003
+ is instead judged by the capability-chain verification (which synthesizes the
1004
+ root capability with the body's `controller` as its controller), and a chain
1005
+ not rooted in the body's `controller` is rejected with the spec's
1006
+ `controller-mismatch` (400). New `isRootInvocation()` helper in `src/zcap.ts`
1007
+ distinguishes the two `Capability-Invocation` header forms;
1008
+ `SpaceControllerMismatchError` now carries the spec's "signed by it, or via a
1009
+ delegation chain rooted in it" detail (and an optional `cause`).
1010
+
1011
+ - Enforce a per-Space storage quota (spec "Quotas"). A new
1012
+ `STORAGE_LIMIT_PER_SPACE` environment variable sets each Space's capacity in
1013
+ bytes; when configured, writes that would push a Space over its limit are
1014
+ rejected with a new `quota-exceeded` (507) error. Enforcement lives in
1015
+ `FileSystemBackend.writeResource` and `importSpace`: a cheap `du`-based
1016
+ pre-flight rejects writes whose size is known up front (JSON bodies and blobs
1017
+ with a `Content-Length`), and a byte-counting streaming guard hard-caps blobs
1018
+ whose size is not declared (cleaning up the partial file on overflow). The
1019
+ limit is a soft cap under concurrency (two simultaneous writes can each pass
1020
+ the pre-flight and jointly overshoot); the streaming guard still bounds each
1021
+ individual write. Unset (`STORAGE_LIMIT_PER_SPACE` absent) leaves every Space
1022
+ unlimited, as before, with zero enforcement overhead. The per-upload
1023
+ `maxUploadBytes` / `payload-too-large` (413) constraint remains future work.
1024
+
1025
+ - Implement the `GET /space/:spaceId/quotas` ("Quotas") endpoint. Returns the
1026
+ Space's storage report grouped by backend (spec "Quotas"); this reference
1027
+ server ships one server-configured backend, so the `backends` array has a
1028
+ single entry. Each entry combines the backend's identity (`id` / `name` /
1029
+ `managedBy`, from its `describe()`) with measured `usageBytes`, a derived
1030
+ `state` (`ok` / `near-limit` / `over-quota`), the configured `limit`
1031
+ (`isUnlimited` by default -- the filesystem backend reports an unlimited quota
1032
+ unless a `capacityBytes` is configured), `restrictedActions`
1033
+ (`["POST", "PUT"]` once `over-quota`), a `measuredAt` timestamp, and a
1034
+ per-Collection `usageByCollection` breakdown. Usage is measured with `du` (GNU
1035
+ coreutils) on the Space's storage directory. Authorization is
1036
+ capability-or-policy, the same as List Collections and the backends list (a
1037
+ caller not authorized to read the report receives a 404, per the spec's
1038
+ maximum-privacy invariant). The new `StorageBackend.reportUsage()` port method
1039
+ backs the endpoint, and `FileSystemBackend` gains an optional `capacityBytes`
1040
+ constructor option that drives the `state` thresholds.
1041
+
1042
+ _Known limitation:_ the spec makes the per-Collection breakdown opt-in via
1043
+ `?include=collections`, but a query string in the request URL currently breaks
1044
+ ZCap `invocationTarget` matching (the signed root capability target would
1045
+ include the query and no longer match the bare `/quotas` path). For now the
1046
+ breakdown is returned unconditionally and the query parameter is not
1047
+ consulted, pending an upstream fix in the ezcap client. The per-Collection
1048
+ `/quota` endpoint (`GET /space/:spaceId/:collectionId/quota`) is not yet
1049
+ implemented.
1050
+
1051
+ - Implement the `GET /space/:spaceId/backends` ("Space Backends Available")
1052
+ endpoint. Returns the list of storage backends registered for the Space; this
1053
+ reference server ships one server-configured backend, so the list has a single
1054
+ entry derived from the active backend's own `describe()`. Each entry is a
1055
+ Backend description object (spec "Backend Data Model"): `id`, `name`,
1056
+ `managedBy`, `storageMode`, and `persistence`. Authorization is
1057
+ capability-or-policy, the same as List Collections (a public-readable Space
1058
+ may list its backends).
1059
+ - Implement `/meta` resource endpoint.
1060
+ - Observability on the access-control policy authorization path. A
1061
+ policy-granted read now emits an info log -- "Access granted by access-control
1062
+ policy." with `spaceId` / `collectionId` / `resourceId` / `action` /
1063
+ `policyType` -- so public-access decisions are auditable, and an unrecognized
1064
+ policy `type` now emits a warn as it fail-closes in `policyGrants`. The
1065
+ decision logic is unchanged; this is purely diagnostics.
1066
+ - Test + CI tooling:
1067
+ - Added a `test:coverage` script (`vitest run --coverage`) and wired coverage
1068
+ into CI: the `test` job now runs `pnpm run test:coverage` and uploads the
1069
+ v8/lcov `coverage/` report (reporters already configured in
1070
+ `vite.config.ts`) as a downloadable build artifact.
1071
+ - Added direct unit tests for the previously integration-only core --
1072
+ `test/zcap.test.ts` (the `handleZcapVerify` error contract:
1073
+ `UnauthorizedError` on a failed invocation vs `AuthVerificationError` +
1074
+ `cause` + logging on a verification error), `test/auth-header-hooks.test.ts`
1075
+ (header gating and `request.zcap` parsing), and `test/importTar.test.ts`
1076
+ (manifest validation, merge-plan building, tar extraction, and the
1077
+ id-traversal guards). Coverage of all three modules is now ~94-100%.
1078
+ - Memoized the Space Description lookup. Every authorized handler reads the
1079
+ Space Description through `getSpaceDescriptionOrThrow` in
1080
+ `src/requests/spaceContext.ts`, so it is now cached per storage backend via
1081
+ `@interop/lru-memoize` (a `WeakMap`-scoped `LruCache` per backend, short TTL
1082
+ from `SPACE_DESCRIPTION_CACHE_TTL`). Writes invalidate explicitly through the
1083
+ new `invalidateSpaceDescription()` -- wired into Space create
1084
+ (`POST /spaces/`), update (`PUT /space/:spaceId`), and delete -- so a read
1085
+ after a write never serves a stale description; the TTL is a backstop that
1086
+ also bounds staleness across multiple server processes sharing one backend. No
1087
+ API change.
1088
+
1089
+ ### Fixed
1090
+
1091
+ - Close the create-via-PUT controller consent gap. `PUT /space/:spaceId`
1092
+ creating a _new_ Space used to verify the invocation against the signer
1093
+ itself, never tying it to the body's `controller` -- so any signer could
1094
+ create a Space whose stored controller is an unrelated, non-consenting DID
1095
+ (squatting meaningful ids "in their name", or burning per-controller
1096
+ onboarding allowances). Creates now apply the same
1097
+ authorized-by-body-controller rule as `POST /spaces/` (signed by the body's
1098
+ `controller` directly, or via a delegation chain rooted in it; violations are
1099
+ `controller-mismatch`, 400). Updates of an existing Space are unchanged: they
1100
+ verify against the _stored_ controller, with the body's `controller` as just
1101
+ the proposed new value.
1102
+ - Dead-code and error-handling cleanup:
1103
+ - `src/lib/importTar.ts` now throws the typed `InvalidImportError` for the
1104
+ five archive-validation failures instead of generic `Error`, and the
1105
+ invalid-YAML case chains the underlying parse error as its `cause`
1106
+ (`InvalidImportError` gained an optional `cause`). The `Import Space`
1107
+ handler no longer flattens every failure into a fresh `InvalidImportError`:
1108
+ typed `ProblemError`s now propagate unchanged (preserving status code and
1109
+ message), and only an unexpected decode failure is wrapped -- keeping the
1110
+ original as the `cause`.
1111
+ - `resolveResourceInput` (`src/requests/resourceInput.ts`) guards the
1112
+ multipart parse: a `multipart` request with no file part now returns a clean
1113
+ `400` instead of throwing a raw `TypeError` on a non-null assertion.
1114
+ - Validate the Space `controller` DID at the request layer. `POST /spaces/`
1115
+ (Create Space) and `PUT /space/:spaceId` (Update Space) now reject a body
1116
+ whose `controller` is not a syntactically valid Ed25519 `did:key` with a typed
1117
+ 400 (`InvalidControllerError`, pointer `#/controller`) on the way in -- rather
1118
+ than storing a malformed controller that only fails later, at
1119
+ capability-verification time. New `src/lib/validateDid.ts` exports
1120
+ `assertValidController` / `isValidController`.
1121
+ - Reject `POST /spaces/` (Create Space) with an `id` that already exists,
1122
+ instead of silently overwriting the existing Space. The handler now checks for
1123
+ an existing Space Description before anything else and rejects with the spec's
1124
+ `id-conflict` (409) error type (new `ProblemTypes.ID_CONFLICT` catalog entry
1125
+ and `IdConflictError` class, pointer `#/id`). Create-or-replace at a
1126
+ client-chosen id remains available via the idempotent `PUT /space/:spaceId`.
1127
+ Create Collection (`POST /space/:spaceId/`) gets the same `id-conflict` (409)
1128
+ check instead of silently overwriting -- there it runs _after_ the capability
1129
+ verification, so an unauthorized caller cannot probe Collection ids;
1130
+ create-or-replace remains the idempotent `PUT /space/:spaceId/:collectionId`.
1131
+
1132
+ ### Changed
1133
+
1134
+ - Adopt `@interop/storage-core` for the shared WAS wire model and error
1135
+ vocabulary, replacing the in-repo declarations that were drifting from the
1136
+ `was-client` copies. `src/types.ts` now imports/re-exports the data-model
1137
+ shapes (`SpaceDescription`, `CollectionDescription`, `BackendDescriptor`,
1138
+ `BackendUsage`, `ResourceMetadata`, etc.) from core and keeps only the
1139
+ server-local contracts (`StorageBackend`, `ResourceInput`, `ResourceResult`,
1140
+ `ParsedZcap`, the Fastify augmentation); `src/problem-types.ts` is removed in
1141
+ favor of core's `ProblemTypes` / `ProblemType`, and `errors.ts` re-exports the
1142
+ wire `Problem` shape from core. Two listing types were renamed to remove a
1143
+ cross-repo collision: the resources-in-a-collection listing is now
1144
+ `CollectionResourcesList` (was `CollectionListing`). The unified types also
1145
+ tightened two producer behaviors: `FileSystemBackend.describe()` now returns
1146
+ the always-populated `Required<BackendDescriptor>`, and `PUT .../meta` now
1147
+ rejects non-string `custom.tags` values (400), matching the spec's string-tag
1148
+ guidance.
1149
+ - Tighten `PUT .../policy` body validation: a policy `type` that is empty or
1150
+ whitespace-only is now rejected with a 400 (`InvalidPolicyError`) instead of
1151
+ being stored. The recognized-types set stays intentionally open (an unknown
1152
+ `type` is stored and fail-closes at evaluation time), so this is a shape check
1153
+ only, not a known-types allowlist.
1154
+ - Centralize the relative URL path templates. New `src/lib/paths.ts` exposes
1155
+ `spacePath` / `collectionPath` / `resourcePath` / `policyPath` / `linksetPath`
1156
+ builders that mirror the route shapes in `routes.ts`; the policy and linkset
1157
+ code (`src/policy.ts`, `PolicyRequest`, and the Space/Collection linkset
1158
+ handlers) now builds those paths through the helpers rather than re-deriving
1159
+ them inline. The linkset relation URI moved from `src/policy.ts` into
1160
+ `config.default.ts` (`POLICY_LINK_RELATION`). The follow-up full sweep then
1161
+ routed _every_ remaining inline path template through the module: the
1162
+ trailing-slash container forms via a `trailingSlash` option on `spacePath` /
1163
+ `collectionPath`, plus new `spacesPath` (SpacesRepository container and member
1164
+ -- the latter being the create `Location`, deliberately `/spaces/:id`),
1165
+ `collectionsPath`, `exportPath`, and `importPath` builders. All `*Request.ts`
1166
+ `targetPath`s, the `Location`-header `new URL(...)` constructions, the
1167
+ `List Collections` `url` field, and the `FileSystemBackend` listing `url`
1168
+ fields now build through the helpers, making `src/lib/paths.ts` the single
1169
+ source of truth for every server path shape. New `test/paths.test.ts` pins
1170
+ each builder's member-vs-container output. No API change.
1171
+ - Removed dead code: the commented-out `@fastify/accepts` import/registration in
1172
+ `src/server.ts`, the commented debug log in `src/auth-header-hooks.ts`, and
1173
+ the three speculative `// TODO: use a uuid v5 or another hash based id`
1174
+ comments (random v4 ids are correct for these server-assigns-id create
1175
+ endpoints; deterministic ids have no natural key here and would change
1176
+ semantics).
1177
+ - Extracted the fetch-space-and-verify boilerplate repeated across ~18 handlers
1178
+ (in five files) into the new neutral module `src/requests/spaceContext.ts`. It
1179
+ loads the Space and builds the capability `invocationTarget` URL once, then
1180
+ exposes two named entry points so each call site names its authorization
1181
+ model: `fetchSpaceAndAuthorize()` (**capability-or-policy** -- capability
1182
+ invocation first, then access-control policy fallback; for read/list
1183
+ endpoints) and `fetchSpaceAndVerify()` (**capability-only**; for
1184
+ write/privileged + policy endpoints). Both return the verified context. The
1185
+ old `getSpaceController` helper was dropped (subsumed). No behavior change,
1186
+ except the handlers that fetched the Collection before verifying now verify
1187
+ first.
1188
+
1189
+ ### Removed
1190
+
1191
+ - The in-memory storage backend (`src/backends/memory.ts`). It was never wired
1192
+ into the server or the test suite -- its purpose was to drive the
1193
+ `StorageBackend` port to the right level of abstraction, which it has done.
1194
+ That abstraction now lives in the port (`src/types.ts`) and survives the
1195
+ removal; a future durable/queryable backend (SQLite/Postgres/LMDB) will be the
1196
+ real second adapter that proves the port.
1197
+
1198
+ ## 0.3.0 - 2026-06-06
1199
+
1200
+ ### Added
1201
+
1202
+ - Access-control **policy** documents, enabling world-readable ("public read")
1203
+ Collections and Resources. A `policy` auxiliary resource may be set at the
1204
+ Space, Collection, or Resource level via
1205
+ `GET|PUT|DELETE /space/{id}[/{col}[/{res}]]/policy`. Reads (`GET`/`HEAD`) now
1206
+ fall back to the effective policy when no capability is presented, or when the
1207
+ presented capability does not authorize the request -- so an anonymous `GET`
1208
+ of a resource in a Collection whose policy is `{ "type": "PublicCanRead" }`
1209
+ succeeds. Policies are **permissive-only** (they broaden access beyond
1210
+ capabilities, never restrict a valid capability holder) and resolved
1211
+ most-specific-first (Resource over Collection over Space, per the spec). The
1212
+ policy document is a `type`-discriminated, extensible shape: v1 recognizes
1213
+ only `PublicCanRead`; any unrecognized `type` grants nothing (fail-closed).
1214
+ New modules: `src/policy.ts` (resolution + evaluation + linkset building),
1215
+ `src/authorize.ts` (the capability-then-policy decision), and
1216
+ `src/requests/PolicyRequest.ts` (the policy CRUD handler).
1217
+ - **Linkset discovery** (RFC9264): Space and Collection Description objects now
1218
+ carry a `linkset` property, and `GET /space/{id}/linkset` /
1219
+ `GET /space/{id}/{col}/linkset` return an `application/linkset+json` document
1220
+ advertising the access-control `policy` resource (relation
1221
+ `https://wallet.storage/spec#policy`) when one is set.
1222
+ - Space export/import now round-trips policies. Space-, Collection-, and
1223
+ Resource-level `.policy.*` documents are carried in the export tarball and
1224
+ restored on import: the space policy fills in when the target has none (no
1225
+ clobber), and Collection/Resource policies travel with a newly-created
1226
+ Collection/Resource. `ImportStats` gains `policiesCreated` /
1227
+ `policiesSkipped`.
1228
+
1229
+ ### Changed
1230
+
1231
+ - Read requests (`GET`/`HEAD`) on Space / Collection / Resource routes no longer
1232
+ require auth headers up front: they are allowed through so the handler can
1233
+ fall back to an access-control policy. An anonymous (or unauthorized) read
1234
+ that no policy grants is now denied with **404** (consistent with the existing
1235
+ no-leak policy for failed capability invocation) rather than **401**. Writes,
1236
+ and all SpacesRepository (`/spaces`) admin routes, still require auth (401
1237
+ when absent). `policy` and `linkset` are now reserved id segments and cannot
1238
+ be used as Collection or Resource ids.
1239
+
1240
+ ## 0.2.0 - 2026-06-06
1241
+
1242
+ ### Added
1243
+
1244
+ - Emit the spec-REQUIRED `type` property on every `application/problem+json`
1245
+ error response. A new `src/problem-types.ts` defines a `ProblemTypes` catalog
1246
+ of problem-_kind_ `type` URIs (e.g. `#not-found`, `#invalid-id`,
1247
+ `#controller-mismatch`), keyed by the kind of problem and reused across
1248
+ operations per [[RFC9457]] -- not per operation. Privacy-sensitive conditions
1249
+ (Space / Collection / Resource not found, failed capability invocation) all
1250
+ collapse to a single `#not-found` so `type` cannot be used to probe resource
1251
+ existence. Body-validation responses now also carry a `pointer` (RFC 6901 JSON
1252
+ Pointer, `#/field` form) identifying the offending field; the combined
1253
+ Create/Update Space "name and controller" check is split so each missing field
1254
+ reports its own `#/name` / `#/controller` pointer. The WAS spec gains an
1255
+ "Error Type Registry" appendix documenting the catalog.
1256
+
1257
+ ### Changed
1258
+
1259
+ - Re-parent the error classes in `src/errors.ts` onto a shared `ProblemError`
1260
+ base carrying `type` / `title` / `detail` / `statusCode` and an optional
1261
+ `problems: { detail, pointer }[]` array, removing the repeated field-triad
1262
+ boilerplate. `handleError` now serializes `type` and the `problems` array
1263
+ (falling back to `[{ detail }]`).
1264
+ - Refactor `conformance/helpers.ts` to dogfood `@interop/was-client`: identities
1265
+ built by `buildZcapClients()` now carry a high-level `was` client alongside
1266
+ the raw `rootClient`, a new `wasClient({ signer })` helper mirrors
1267
+ `zcapClient({ signer })`, and `createSpace()`'s ZCap path goes through
1268
+ `WasClient.request()`. The exported helper surface is unchanged.
1269
+
1270
+ ### Security
1271
+
1272
+ - Sanitize `spaceId` / `collectionId` / `resourceId` against path traversal. A
1273
+ new `src/lib/validateId.ts` exports `assertValidId(id, { kind })` /
1274
+ `assertValidIds(ids)`, which reject any id that is not a single URL-safe path
1275
+ segment (empty, `.`, `..`, containing `/` or `\`, or outside the RFC 3986
1276
+ unreserved charset -- which also excludes every glob metacharacter). It is
1277
+ enforced at the request layer (every `SpaceRequest` / `CollectionRequest` /
1278
+ `ResourceRequest` handler, plus client-supplied ids in the Create Space /
1279
+ Create Collection bodies) before any storage access, and at the tar-import
1280
+ layer (`src/lib/importTar.ts`) for every id parsed out of an archive entry
1281
+ name. As defense in depth, `FileSystemBackend` now asserts that every built
1282
+ path resolves within its `spaces/` root before any filesystem operation. New
1283
+ `InvalidCollectionIdError` / `InvalidResourceIdError` (400) join the existing
1284
+ `InvalidSpaceIdError`.
1285
+
1286
+ ### Fixed
1287
+
1288
+ - Replace generic `throw new Error('Could not ...')` in the Collection and
1289
+ Resource create/update/delete paths with
1290
+ `StorageError({ cause, requestName })` so storage failures surface a typed 500
1291
+ with a title/detail through `handleError` instead of a bare 500 (and rename
1292
+ the `e` catch vars to `err`).
1293
+ - Validate request bodies and headers before use: Create Space (`POST /spaces/`)
1294
+ and Update Space (`PUT /space/:spaceId`) now reject a body missing `name` or
1295
+ `controller` with a typed 400 (`InvalidRequestBodyError`) rather than failing
1296
+ deeper, and `resolveResourceInput` rejects a missing `Content-Type` header
1297
+ with a typed 400 (`MissingContentTypeError`) instead of asserting it non-null.
1298
+
1299
+ ### Changed
1300
+
1301
+ - Make the storage backend an injectable dependency of `createApp()`. The app
1302
+ factory now accepts a `backend` (`createApp({ serverUrl, backend })`) and
1303
+ decorates the Fastify instance with it; request handlers read it as
1304
+ `request.server.storage` rather than importing a module-level facade.
1305
+ `src/storage.ts` no longer holds a hardcoded singleton — it exposes
1306
+ `defaultBackend()` (a filesystem backend rooted at `data/`), which
1307
+ `createApp()` uses when no backend is injected (production / `start.ts`). Each
1308
+ `test/` suite now injects its own `FileSystemBackend` over an `mkdtemp` temp
1309
+ dir and removes it in `afterAll`, so suites no longer share (or leak) the
1310
+ gitignored `data/` directory.
1311
+
1312
+ - Decouple the `StorageBackend` persistence port from the HTTP transport.
1313
+ `writeResource` now takes a transport-neutral `ResourceInput` value object
1314
+ (`{ kind: 'json'; data } | { kind: 'binary'; stream }`, both carrying a
1315
+ `contentType`) instead of a raw Fastify `request`. A new request-layer
1316
+ adapter, `resolveResourceInput()` in `src/requests/resourceInput.ts`, is now
1317
+ the only place that reads `request.body` / `request.file()` and distinguishes
1318
+ multipart from raw-blob bodies; the backends (`filesystem`, `memory`) no
1319
+ longer import Fastify at all.
1320
+
1321
+ _Architectural note (ports & adapters):_ `StorageBackend` is a driven
1322
+ (secondary) port with two interchangeable adapters. Passing the inbound
1323
+ `FastifyRequest` into it coupled that driven port to the HTTP driving
1324
+ (primary) adapter — the one dependency direction hexagonal architecture
1325
+ forbids. Routing the conversion through `resolveResourceInput()` restores the
1326
+ boundary: backends now depend only on domain types, can be unit-tested with
1327
+ plain values (no fake request objects — see `test/storage.test.ts`), and the
1328
+ multipart/blob distinction lives where it belongs, in the HTTP layer.
1329
+
1330
+ - Tighten the `StorageBackend` contract so a Resource has exactly one current
1331
+ representation, identified by `resourceId` alone within a Collection.
1332
+ Previously a resource's identity was effectively `(resourceId, contentType)`
1333
+ -- an emergent property of how each backend stored the content-type out of
1334
+ band (filename segment on the filesystem, composite
1335
+ `${resourceId}::${contentType}` map key in memory). As a result, `PUT`-ing an
1336
+ id as one content-type and then re-`PUT`-ing it under another left two stored
1337
+ representations instead of replacing the first; `getResource` returned a
1338
+ nondeterministic one and the listing emitted the id twice. Now `writeResource`
1339
+ replaces any prior representation regardless of its content-type (the
1340
+ filesystem backend prunes the old file after writing the new one; the memory
1341
+ backend overwrites in place), and `getResource`'s `contentType` parameter is
1342
+ advisory -- the single representation is resolved by `resourceId` alone, with
1343
+ the stored content-type returned in `ResourceResult.storedResourceType`. The
1344
+ content-type stays human-visible in the filesystem filename; it is now a
1345
+ descriptive attribute, not part of identity. No method signatures or spec
1346
+ behaviour changed.
1347
+
1348
+ - Route all server diagnostics through the Fastify pino logger instead of
1349
+ scattered `console.*` calls. `FileSystemBackend` now logs through an injected
1350
+ logger (`StorageBackend.logger`, typed as Fastify's `FastifyBaseLogger`);
1351
+ `createApp()` wires `fastify.log` into the active backend, and the backend
1352
+ defaults to a silent pino logger until then. `StorageError` no longer logs
1353
+ from its constructor -- `handleError` now logs 5xx faults (including the
1354
+ underlying `cause`) once, through `request.log`, leaving 4xx client errors
1355
+ unlogged.
1356
+
1357
+ ### Fixed
1358
+
1359
+ - `GET /space/:spaceId/:collectionId/:resourceId` no longer masks every
1360
+ `getResource` failure as a 404. The handler previously swallowed any error
1361
+ from the backend and reported "resource not found"; it now re-throws a genuine
1362
+ `ResourceNotFoundError` as the 404 and wraps any other failure as a typed 500
1363
+ (`StorageError`) so real storage faults are no longer hidden.
1364
+
1365
+ ## 0.1.0 - 2026-06-04
1366
+
1367
+ ### Changed
1368
+
1369
+ - Convert the codebase from JavaScript (ES2020 + JSDoc) to TypeScript (strict,
1370
+ `NodeNext`). Dev runs via `tsx`; production builds with `tsc` to `dist/`.
1371
+ Tooling adopted: ESLint flat config, Prettier, Vitest.
1372
+ - Migrate the `test/` integration suite to Vitest; keep the `conformance/` suite
1373
+ standalone (run via `tsx --test`).
1374
+ - Bump `@interop/*` packages to v7: `did-method-key` 7.x, `ed25519-signature`
1375
+ 7.x, `ed25519-verification-key` 7.x; update `security-document-loader` to
1376
+ 9.2.x and `ezcap` (dev) to 7.x.
1377
+ - Swap `@interop-alliance/http-signature-zcap-verify` for the typed
1378
+ `@interop/http-signature-zcap-verify` fork.
1379
+
1380
+ ### Added
1381
+
1382
+ - Add `@interop/data-integrity-core` as a direct dependency (source of shared
1383
+ types).
1384
+
1385
+ ### Fixed
1386
+
1387
+ - `writeResource` now awaits `request.file()` in both backends (multipart
1388
+ resource writes).
1389
+ - `ResourceRequest.delete` / `CollectionRequest.delete` now import the
1390
+ `StorageError` they throw.
1391
+ - Fix test ordering bug in collection and resource api tests.
1392
+
1393
+ ## 0.0.6 - 2026-05-31
1394
+
1395
+ ### Changed
1396
+
1397
+ - Migrate to `@interop/` forks of the key, signature and DID packages.
1398
+
1399
+ ## 0.0.5 - 2026-05-11
1400
+
1401
+ ### Added
1402
+
1403
+ - Implement 'List Collections' request.
1404
+
1405
+ ## 0.0.4 - 2026-05-11
1406
+
1407
+ ### Changed
1408
+
1409
+ - Refactor storage.js into file and memory backends.
1410
+ - Fix start server error logging.
1411
+ - Refactor listCollectionItems to use 'items' instead of 'rows'.
1412
+ - Add exportSpace functionality to FileSystemBackend.
1413
+ - Bump deps to latest.
1414
+
1415
+ ## 0.0.3 - 2026-03-25
1416
+
1417
+ ### Changed
1418
+
1419
+ - Add PUT Resource route.
1420
+ - Refactor listCollections result objects.
1421
+
1422
+ ## 0.0.2 - 2026-03-24
1423
+
1424
+ ### Changed
1425
+
1426
+ - Add SERVER_URL and PORT env vars, better errors.
1427
+
1428
+ ## 0.0.1 - 2025-08-25
1429
+
1430
+ ### Added
1431
+
1432
+ - Initial commits