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.
- package/CHANGELOG.md +1432 -0
- package/LICENSE +661 -0
- package/README.md +305 -0
- package/common/css/materialize.1.0.0.min.css +13 -0
- package/dist/auth-header-hooks.d.ts +84 -0
- package/dist/auth-header-hooks.d.ts.map +1 -0
- package/dist/auth-header-hooks.js +136 -0
- package/dist/auth-header-hooks.js.map +1 -0
- package/dist/authorize.d.ts +45 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +76 -0
- package/dist/authorize.js.map +1 -0
- package/dist/backends/filesystem.d.ts +1179 -0
- package/dist/backends/filesystem.d.ts.map +1 -0
- package/dist/backends/filesystem.js +2727 -0
- package/dist/backends/filesystem.js.map +1 -0
- package/dist/backends/postgres.d.ts +833 -0
- package/dist/backends/postgres.d.ts.map +1 -0
- package/dist/backends/postgres.js +2164 -0
- package/dist/backends/postgres.js.map +1 -0
- package/dist/backends/postgresSchema.d.ts +36 -0
- package/dist/backends/postgresSchema.d.ts.map +1 -0
- package/dist/backends/postgresSchema.js +203 -0
- package/dist/backends/postgresSchema.js.map +1 -0
- package/dist/config.default.d.ts +360 -0
- package/dist/config.default.d.ts.map +1 -0
- package/dist/config.default.js +493 -0
- package/dist/config.default.js.map +1 -0
- package/dist/corsProxy.d.ts +45 -0
- package/dist/corsProxy.d.ts.map +1 -0
- package/dist/corsProxy.js +278 -0
- package/dist/corsProxy.js.map +1 -0
- package/dist/digest.d.ts +45 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +174 -0
- package/dist/digest.js.map +1 -0
- package/dist/errors.d.ts +654 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +960 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/atomicFile.d.ts +61 -0
- package/dist/lib/atomicFile.d.ts.map +1 -0
- package/dist/lib/atomicFile.js +150 -0
- package/dist/lib/atomicFile.js.map +1 -0
- package/dist/lib/backendRegistry.d.ts +59 -0
- package/dist/lib/backendRegistry.d.ts.map +1 -0
- package/dist/lib/backendRegistry.js +92 -0
- package/dist/lib/backendRegistry.js.map +1 -0
- package/dist/lib/backendUsage.d.ts +28 -0
- package/dist/lib/backendUsage.d.ts.map +1 -0
- package/dist/lib/backendUsage.js +55 -0
- package/dist/lib/backendUsage.js.map +1 -0
- package/dist/lib/backends.d.ts +142 -0
- package/dist/lib/backends.d.ts.map +1 -0
- package/dist/lib/backends.js +272 -0
- package/dist/lib/backends.js.map +1 -0
- package/dist/lib/blindedIndex.d.ts +156 -0
- package/dist/lib/blindedIndex.d.ts.map +1 -0
- package/dist/lib/blindedIndex.js +338 -0
- package/dist/lib/blindedIndex.js.map +1 -0
- package/dist/lib/collectionDescription.d.ts +33 -0
- package/dist/lib/collectionDescription.d.ts.map +1 -0
- package/dist/lib/collectionDescription.js +21 -0
- package/dist/lib/collectionDescription.js.map +1 -0
- package/dist/lib/cursor.d.ts +18 -0
- package/dist/lib/cursor.d.ts.map +1 -0
- package/dist/lib/cursor.js +50 -0
- package/dist/lib/cursor.js.map +1 -0
- package/dist/lib/edvEnvelope.d.ts +50 -0
- package/dist/lib/edvEnvelope.d.ts.map +1 -0
- package/dist/lib/edvEnvelope.js +106 -0
- package/dist/lib/edvEnvelope.js.map +1 -0
- package/dist/lib/encryption.d.ts +192 -0
- package/dist/lib/encryption.d.ts.map +1 -0
- package/dist/lib/encryption.js +383 -0
- package/dist/lib/encryption.js.map +1 -0
- package/dist/lib/etag.d.ts +33 -0
- package/dist/lib/etag.d.ts.map +1 -0
- package/dist/lib/etag.js +36 -0
- package/dist/lib/etag.js.map +1 -0
- package/dist/lib/exportManifest.d.ts +29 -0
- package/dist/lib/exportManifest.d.ts.map +1 -0
- package/dist/lib/exportManifest.js +77 -0
- package/dist/lib/exportManifest.js.map +1 -0
- package/dist/lib/importTar.d.ts +81 -0
- package/dist/lib/importTar.d.ts.map +1 -0
- package/dist/lib/importTar.js +301 -0
- package/dist/lib/importTar.js.map +1 -0
- package/dist/lib/isJson.d.ts +16 -0
- package/dist/lib/isJson.d.ts.map +1 -0
- package/dist/lib/isJson.js +17 -0
- package/dist/lib/isJson.js.map +1 -0
- package/dist/lib/keyEpoch.d.ts +38 -0
- package/dist/lib/keyEpoch.d.ts.map +1 -0
- package/dist/lib/keyEpoch.js +75 -0
- package/dist/lib/keyEpoch.js.map +1 -0
- package/dist/lib/keyedMutex.d.ts +28 -0
- package/dist/lib/keyedMutex.d.ts.map +1 -0
- package/dist/lib/keyedMutex.js +45 -0
- package/dist/lib/keyedMutex.js.map +1 -0
- package/dist/lib/kmsModule.d.ts +62 -0
- package/dist/lib/kmsModule.d.ts.map +1 -0
- package/dist/lib/kmsModule.js +327 -0
- package/dist/lib/kmsModule.js.map +1 -0
- package/dist/lib/kmsRecordCipher.d.ts +82 -0
- package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
- package/dist/lib/kmsRecordCipher.js +226 -0
- package/dist/lib/kmsRecordCipher.js.map +1 -0
- package/dist/lib/pagination.d.ts +19 -0
- package/dist/lib/pagination.d.ts.map +1 -0
- package/dist/lib/pagination.js +21 -0
- package/dist/lib/pagination.js.map +1 -0
- package/dist/lib/paths.d.ts +273 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +270 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/preconditions.d.ts +65 -0
- package/dist/lib/preconditions.d.ts.map +1 -0
- package/dist/lib/preconditions.js +112 -0
- package/dist/lib/preconditions.js.map +1 -0
- package/dist/lib/resourceFileName.d.ts +41 -0
- package/dist/lib/resourceFileName.d.ts.map +1 -0
- package/dist/lib/resourceFileName.js +58 -0
- package/dist/lib/resourceFileName.js.map +1 -0
- package/dist/lib/revocations.d.ts +49 -0
- package/dist/lib/revocations.d.ts.map +1 -0
- package/dist/lib/revocations.js +86 -0
- package/dist/lib/revocations.js.map +1 -0
- package/dist/lib/validateDid.d.ts +19 -0
- package/dist/lib/validateDid.d.ts.map +1 -0
- package/dist/lib/validateDid.js +35 -0
- package/dist/lib/validateDid.js.map +1 -0
- package/dist/lib/validateId.d.ts +66 -0
- package/dist/lib/validateId.d.ts.map +1 -0
- package/dist/lib/validateId.js +126 -0
- package/dist/lib/validateId.js.map +1 -0
- package/dist/plugin.d.ts +115 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +134 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +78 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +115 -0
- package/dist/policy.js.map +1 -0
- package/dist/provisioning.d.ts +38 -0
- package/dist/provisioning.d.ts.map +1 -0
- package/dist/provisioning.js +72 -0
- package/dist/provisioning.js.map +1 -0
- package/dist/requests/BackendRequest.d.ts +61 -0
- package/dist/requests/BackendRequest.d.ts.map +1 -0
- package/dist/requests/BackendRequest.js +152 -0
- package/dist/requests/BackendRequest.js.map +1 -0
- package/dist/requests/CollectionRequest.d.ts +234 -0
- package/dist/requests/CollectionRequest.d.ts.map +1 -0
- package/dist/requests/CollectionRequest.js +705 -0
- package/dist/requests/CollectionRequest.js.map +1 -0
- package/dist/requests/KeyRequest.d.ts +115 -0
- package/dist/requests/KeyRequest.d.ts.map +1 -0
- package/dist/requests/KeyRequest.js +468 -0
- package/dist/requests/KeyRequest.js.map +1 -0
- package/dist/requests/KeystoreRequest.d.ts +99 -0
- package/dist/requests/KeystoreRequest.d.ts.map +1 -0
- package/dist/requests/KeystoreRequest.js +247 -0
- package/dist/requests/KeystoreRequest.js.map +1 -0
- package/dist/requests/PolicyRequest.d.ts +53 -0
- package/dist/requests/PolicyRequest.d.ts.map +1 -0
- package/dist/requests/PolicyRequest.js +124 -0
- package/dist/requests/PolicyRequest.js.map +1 -0
- package/dist/requests/ResourceRequest.d.ts +134 -0
- package/dist/requests/ResourceRequest.d.ts.map +1 -0
- package/dist/requests/ResourceRequest.js +548 -0
- package/dist/requests/ResourceRequest.js.map +1 -0
- package/dist/requests/RevocationRequest.d.ts +67 -0
- package/dist/requests/RevocationRequest.d.ts.map +1 -0
- package/dist/requests/RevocationRequest.js +187 -0
- package/dist/requests/RevocationRequest.js.map +1 -0
- package/dist/requests/SpaceRequest.d.ts +204 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -0
- package/dist/requests/SpaceRequest.js +520 -0
- package/dist/requests/SpaceRequest.js.map +1 -0
- package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
- package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
- package/dist/requests/SpacesRepositoryRequest.js +159 -0
- package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
- package/dist/requests/collectionContext.d.ts +21 -0
- package/dist/requests/collectionContext.d.ts.map +1 -0
- package/dist/requests/collectionContext.js +30 -0
- package/dist/requests/collectionContext.js.map +1 -0
- package/dist/requests/controllerConsent.d.ts +58 -0
- package/dist/requests/controllerConsent.d.ts.map +1 -0
- package/dist/requests/controllerConsent.js +73 -0
- package/dist/requests/controllerConsent.js.map +1 -0
- package/dist/requests/keystoreContext.d.ts +68 -0
- package/dist/requests/keystoreContext.d.ts.map +1 -0
- package/dist/requests/keystoreContext.js +87 -0
- package/dist/requests/keystoreContext.js.map +1 -0
- package/dist/requests/resourceInput.d.ts +19 -0
- package/dist/requests/resourceInput.d.ts.map +1 -0
- package/dist/requests/resourceInput.js +102 -0
- package/dist/requests/resourceInput.js.map +1 -0
- package/dist/requests/spaceContext.d.ts +127 -0
- package/dist/requests/spaceContext.d.ts.map +1 -0
- package/dist/requests/spaceContext.js +201 -0
- package/dist/requests/spaceContext.js.map +1 -0
- package/dist/routes.d.ts +55 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +306 -0
- package/dist/routes.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +60 -0
- package/dist/server.js.map +1 -0
- package/dist/start.d.ts +7 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +61 -0
- package/dist/start.js.map +1 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +47 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +915 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/views/home.hbs +9 -0
- package/dist/views/templates/main.hbs +12 -0
- package/dist/zcap.d.ts +267 -0
- package/dist/zcap.d.ts.map +1 -0
- package/dist/zcap.js +478 -0
- package/dist/zcap.js.map +1 -0
- package/package.json +109 -0
- package/src/auth-header-hooks.ts +163 -0
- package/src/authorize.ts +117 -0
- package/src/backends/filesystem.ts +3626 -0
- package/src/backends/postgres.ts +3073 -0
- package/src/backends/postgresSchema.ts +231 -0
- package/src/config.default.ts +657 -0
- package/src/corsProxy.ts +326 -0
- package/src/declarations.d.ts +28 -0
- package/src/digest.ts +223 -0
- package/src/errors.ts +1122 -0
- package/src/index.ts +21 -0
- package/src/lib/atomicFile.ts +176 -0
- package/src/lib/backendRegistry.ts +139 -0
- package/src/lib/backendUsage.ts +79 -0
- package/src/lib/backends.ts +351 -0
- package/src/lib/blindedIndex.ts +466 -0
- package/src/lib/collectionDescription.ts +43 -0
- package/src/lib/cursor.ts +52 -0
- package/src/lib/edvEnvelope.ts +120 -0
- package/src/lib/encryption.ts +472 -0
- package/src/lib/etag.ts +40 -0
- package/src/lib/exportManifest.ts +115 -0
- package/src/lib/importTar.ts +381 -0
- package/src/lib/isJson.ts +18 -0
- package/src/lib/keyEpoch.ts +89 -0
- package/src/lib/keyedMutex.ts +45 -0
- package/src/lib/kmsModule.ts +451 -0
- package/src/lib/kmsRecordCipher.ts +292 -0
- package/src/lib/pagination.ts +22 -0
- package/src/lib/paths.ts +382 -0
- package/src/lib/preconditions.ts +145 -0
- package/src/lib/resourceFileName.ts +69 -0
- package/src/lib/revocations.ts +116 -0
- package/src/lib/validateDid.ts +41 -0
- package/src/lib/validateId.ts +148 -0
- package/src/plugin.ts +288 -0
- package/src/policy.ts +176 -0
- package/src/provisioning.ts +98 -0
- package/src/requests/BackendRequest.ts +219 -0
- package/src/requests/CollectionRequest.ts +899 -0
- package/src/requests/KeyRequest.ts +617 -0
- package/src/requests/KeystoreRequest.ts +325 -0
- package/src/requests/PolicyRequest.ts +169 -0
- package/src/requests/ResourceRequest.ts +649 -0
- package/src/requests/RevocationRequest.ts +267 -0
- package/src/requests/SpaceRequest.ts +686 -0
- package/src/requests/SpacesRepositoryRequest.ts +194 -0
- package/src/requests/collectionContext.ts +41 -0
- package/src/requests/controllerConsent.ts +108 -0
- package/src/requests/keystoreContext.ts +124 -0
- package/src/requests/resourceInput.ts +118 -0
- package/src/requests/spaceContext.ts +311 -0
- package/src/routes.ts +420 -0
- package/src/server.ts +69 -0
- package/src/start.ts +71 -0
- package/src/storage.ts +60 -0
- package/src/types.ts +1016 -0
- package/src/views/home.hbs +9 -0
- package/src/views/templates/main.hbs +12 -0
- 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
|