was-teaching-server 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/CHANGELOG.md +1432 -0
  2. package/LICENSE +661 -0
  3. package/README.md +305 -0
  4. package/common/css/materialize.1.0.0.min.css +13 -0
  5. package/dist/auth-header-hooks.d.ts +84 -0
  6. package/dist/auth-header-hooks.d.ts.map +1 -0
  7. package/dist/auth-header-hooks.js +136 -0
  8. package/dist/auth-header-hooks.js.map +1 -0
  9. package/dist/authorize.d.ts +45 -0
  10. package/dist/authorize.d.ts.map +1 -0
  11. package/dist/authorize.js +76 -0
  12. package/dist/authorize.js.map +1 -0
  13. package/dist/backends/filesystem.d.ts +1179 -0
  14. package/dist/backends/filesystem.d.ts.map +1 -0
  15. package/dist/backends/filesystem.js +2727 -0
  16. package/dist/backends/filesystem.js.map +1 -0
  17. package/dist/backends/postgres.d.ts +833 -0
  18. package/dist/backends/postgres.d.ts.map +1 -0
  19. package/dist/backends/postgres.js +2164 -0
  20. package/dist/backends/postgres.js.map +1 -0
  21. package/dist/backends/postgresSchema.d.ts +36 -0
  22. package/dist/backends/postgresSchema.d.ts.map +1 -0
  23. package/dist/backends/postgresSchema.js +203 -0
  24. package/dist/backends/postgresSchema.js.map +1 -0
  25. package/dist/config.default.d.ts +360 -0
  26. package/dist/config.default.d.ts.map +1 -0
  27. package/dist/config.default.js +493 -0
  28. package/dist/config.default.js.map +1 -0
  29. package/dist/corsProxy.d.ts +45 -0
  30. package/dist/corsProxy.d.ts.map +1 -0
  31. package/dist/corsProxy.js +278 -0
  32. package/dist/corsProxy.js.map +1 -0
  33. package/dist/digest.d.ts +45 -0
  34. package/dist/digest.d.ts.map +1 -0
  35. package/dist/digest.js +174 -0
  36. package/dist/digest.js.map +1 -0
  37. package/dist/errors.d.ts +654 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +960 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/atomicFile.d.ts +61 -0
  46. package/dist/lib/atomicFile.d.ts.map +1 -0
  47. package/dist/lib/atomicFile.js +150 -0
  48. package/dist/lib/atomicFile.js.map +1 -0
  49. package/dist/lib/backendRegistry.d.ts +59 -0
  50. package/dist/lib/backendRegistry.d.ts.map +1 -0
  51. package/dist/lib/backendRegistry.js +92 -0
  52. package/dist/lib/backendRegistry.js.map +1 -0
  53. package/dist/lib/backendUsage.d.ts +28 -0
  54. package/dist/lib/backendUsage.d.ts.map +1 -0
  55. package/dist/lib/backendUsage.js +55 -0
  56. package/dist/lib/backendUsage.js.map +1 -0
  57. package/dist/lib/backends.d.ts +142 -0
  58. package/dist/lib/backends.d.ts.map +1 -0
  59. package/dist/lib/backends.js +272 -0
  60. package/dist/lib/backends.js.map +1 -0
  61. package/dist/lib/blindedIndex.d.ts +156 -0
  62. package/dist/lib/blindedIndex.d.ts.map +1 -0
  63. package/dist/lib/blindedIndex.js +338 -0
  64. package/dist/lib/blindedIndex.js.map +1 -0
  65. package/dist/lib/collectionDescription.d.ts +33 -0
  66. package/dist/lib/collectionDescription.d.ts.map +1 -0
  67. package/dist/lib/collectionDescription.js +21 -0
  68. package/dist/lib/collectionDescription.js.map +1 -0
  69. package/dist/lib/cursor.d.ts +18 -0
  70. package/dist/lib/cursor.d.ts.map +1 -0
  71. package/dist/lib/cursor.js +50 -0
  72. package/dist/lib/cursor.js.map +1 -0
  73. package/dist/lib/edvEnvelope.d.ts +50 -0
  74. package/dist/lib/edvEnvelope.d.ts.map +1 -0
  75. package/dist/lib/edvEnvelope.js +106 -0
  76. package/dist/lib/edvEnvelope.js.map +1 -0
  77. package/dist/lib/encryption.d.ts +192 -0
  78. package/dist/lib/encryption.d.ts.map +1 -0
  79. package/dist/lib/encryption.js +383 -0
  80. package/dist/lib/encryption.js.map +1 -0
  81. package/dist/lib/etag.d.ts +33 -0
  82. package/dist/lib/etag.d.ts.map +1 -0
  83. package/dist/lib/etag.js +36 -0
  84. package/dist/lib/etag.js.map +1 -0
  85. package/dist/lib/exportManifest.d.ts +29 -0
  86. package/dist/lib/exportManifest.d.ts.map +1 -0
  87. package/dist/lib/exportManifest.js +77 -0
  88. package/dist/lib/exportManifest.js.map +1 -0
  89. package/dist/lib/importTar.d.ts +81 -0
  90. package/dist/lib/importTar.d.ts.map +1 -0
  91. package/dist/lib/importTar.js +301 -0
  92. package/dist/lib/importTar.js.map +1 -0
  93. package/dist/lib/isJson.d.ts +16 -0
  94. package/dist/lib/isJson.d.ts.map +1 -0
  95. package/dist/lib/isJson.js +17 -0
  96. package/dist/lib/isJson.js.map +1 -0
  97. package/dist/lib/keyEpoch.d.ts +38 -0
  98. package/dist/lib/keyEpoch.d.ts.map +1 -0
  99. package/dist/lib/keyEpoch.js +75 -0
  100. package/dist/lib/keyEpoch.js.map +1 -0
  101. package/dist/lib/keyedMutex.d.ts +28 -0
  102. package/dist/lib/keyedMutex.d.ts.map +1 -0
  103. package/dist/lib/keyedMutex.js +45 -0
  104. package/dist/lib/keyedMutex.js.map +1 -0
  105. package/dist/lib/kmsModule.d.ts +62 -0
  106. package/dist/lib/kmsModule.d.ts.map +1 -0
  107. package/dist/lib/kmsModule.js +327 -0
  108. package/dist/lib/kmsModule.js.map +1 -0
  109. package/dist/lib/kmsRecordCipher.d.ts +82 -0
  110. package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
  111. package/dist/lib/kmsRecordCipher.js +226 -0
  112. package/dist/lib/kmsRecordCipher.js.map +1 -0
  113. package/dist/lib/pagination.d.ts +19 -0
  114. package/dist/lib/pagination.d.ts.map +1 -0
  115. package/dist/lib/pagination.js +21 -0
  116. package/dist/lib/pagination.js.map +1 -0
  117. package/dist/lib/paths.d.ts +273 -0
  118. package/dist/lib/paths.d.ts.map +1 -0
  119. package/dist/lib/paths.js +270 -0
  120. package/dist/lib/paths.js.map +1 -0
  121. package/dist/lib/preconditions.d.ts +65 -0
  122. package/dist/lib/preconditions.d.ts.map +1 -0
  123. package/dist/lib/preconditions.js +112 -0
  124. package/dist/lib/preconditions.js.map +1 -0
  125. package/dist/lib/resourceFileName.d.ts +41 -0
  126. package/dist/lib/resourceFileName.d.ts.map +1 -0
  127. package/dist/lib/resourceFileName.js +58 -0
  128. package/dist/lib/resourceFileName.js.map +1 -0
  129. package/dist/lib/revocations.d.ts +49 -0
  130. package/dist/lib/revocations.d.ts.map +1 -0
  131. package/dist/lib/revocations.js +86 -0
  132. package/dist/lib/revocations.js.map +1 -0
  133. package/dist/lib/validateDid.d.ts +19 -0
  134. package/dist/lib/validateDid.d.ts.map +1 -0
  135. package/dist/lib/validateDid.js +35 -0
  136. package/dist/lib/validateDid.js.map +1 -0
  137. package/dist/lib/validateId.d.ts +66 -0
  138. package/dist/lib/validateId.d.ts.map +1 -0
  139. package/dist/lib/validateId.js +126 -0
  140. package/dist/lib/validateId.js.map +1 -0
  141. package/dist/plugin.d.ts +115 -0
  142. package/dist/plugin.d.ts.map +1 -0
  143. package/dist/plugin.js +134 -0
  144. package/dist/plugin.js.map +1 -0
  145. package/dist/policy.d.ts +78 -0
  146. package/dist/policy.d.ts.map +1 -0
  147. package/dist/policy.js +115 -0
  148. package/dist/policy.js.map +1 -0
  149. package/dist/provisioning.d.ts +38 -0
  150. package/dist/provisioning.d.ts.map +1 -0
  151. package/dist/provisioning.js +72 -0
  152. package/dist/provisioning.js.map +1 -0
  153. package/dist/requests/BackendRequest.d.ts +61 -0
  154. package/dist/requests/BackendRequest.d.ts.map +1 -0
  155. package/dist/requests/BackendRequest.js +152 -0
  156. package/dist/requests/BackendRequest.js.map +1 -0
  157. package/dist/requests/CollectionRequest.d.ts +234 -0
  158. package/dist/requests/CollectionRequest.d.ts.map +1 -0
  159. package/dist/requests/CollectionRequest.js +705 -0
  160. package/dist/requests/CollectionRequest.js.map +1 -0
  161. package/dist/requests/KeyRequest.d.ts +115 -0
  162. package/dist/requests/KeyRequest.d.ts.map +1 -0
  163. package/dist/requests/KeyRequest.js +468 -0
  164. package/dist/requests/KeyRequest.js.map +1 -0
  165. package/dist/requests/KeystoreRequest.d.ts +99 -0
  166. package/dist/requests/KeystoreRequest.d.ts.map +1 -0
  167. package/dist/requests/KeystoreRequest.js +247 -0
  168. package/dist/requests/KeystoreRequest.js.map +1 -0
  169. package/dist/requests/PolicyRequest.d.ts +53 -0
  170. package/dist/requests/PolicyRequest.d.ts.map +1 -0
  171. package/dist/requests/PolicyRequest.js +124 -0
  172. package/dist/requests/PolicyRequest.js.map +1 -0
  173. package/dist/requests/ResourceRequest.d.ts +134 -0
  174. package/dist/requests/ResourceRequest.d.ts.map +1 -0
  175. package/dist/requests/ResourceRequest.js +548 -0
  176. package/dist/requests/ResourceRequest.js.map +1 -0
  177. package/dist/requests/RevocationRequest.d.ts +67 -0
  178. package/dist/requests/RevocationRequest.d.ts.map +1 -0
  179. package/dist/requests/RevocationRequest.js +187 -0
  180. package/dist/requests/RevocationRequest.js.map +1 -0
  181. package/dist/requests/SpaceRequest.d.ts +204 -0
  182. package/dist/requests/SpaceRequest.d.ts.map +1 -0
  183. package/dist/requests/SpaceRequest.js +520 -0
  184. package/dist/requests/SpaceRequest.js.map +1 -0
  185. package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
  186. package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
  187. package/dist/requests/SpacesRepositoryRequest.js +159 -0
  188. package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
  189. package/dist/requests/collectionContext.d.ts +21 -0
  190. package/dist/requests/collectionContext.d.ts.map +1 -0
  191. package/dist/requests/collectionContext.js +30 -0
  192. package/dist/requests/collectionContext.js.map +1 -0
  193. package/dist/requests/controllerConsent.d.ts +58 -0
  194. package/dist/requests/controllerConsent.d.ts.map +1 -0
  195. package/dist/requests/controllerConsent.js +73 -0
  196. package/dist/requests/controllerConsent.js.map +1 -0
  197. package/dist/requests/keystoreContext.d.ts +68 -0
  198. package/dist/requests/keystoreContext.d.ts.map +1 -0
  199. package/dist/requests/keystoreContext.js +87 -0
  200. package/dist/requests/keystoreContext.js.map +1 -0
  201. package/dist/requests/resourceInput.d.ts +19 -0
  202. package/dist/requests/resourceInput.d.ts.map +1 -0
  203. package/dist/requests/resourceInput.js +102 -0
  204. package/dist/requests/resourceInput.js.map +1 -0
  205. package/dist/requests/spaceContext.d.ts +127 -0
  206. package/dist/requests/spaceContext.d.ts.map +1 -0
  207. package/dist/requests/spaceContext.js +201 -0
  208. package/dist/requests/spaceContext.js.map +1 -0
  209. package/dist/routes.d.ts +55 -0
  210. package/dist/routes.d.ts.map +1 -0
  211. package/dist/routes.js +306 -0
  212. package/dist/routes.js.map +1 -0
  213. package/dist/server.d.ts +20 -0
  214. package/dist/server.d.ts.map +1 -0
  215. package/dist/server.js +60 -0
  216. package/dist/server.js.map +1 -0
  217. package/dist/start.d.ts +7 -0
  218. package/dist/start.d.ts.map +1 -0
  219. package/dist/start.js +61 -0
  220. package/dist/start.js.map +1 -0
  221. package/dist/storage.d.ts +29 -0
  222. package/dist/storage.d.ts.map +1 -0
  223. package/dist/storage.js +47 -0
  224. package/dist/storage.js.map +1 -0
  225. package/dist/types.d.ts +915 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/views/home.hbs +9 -0
  230. package/dist/views/templates/main.hbs +12 -0
  231. package/dist/zcap.d.ts +267 -0
  232. package/dist/zcap.d.ts.map +1 -0
  233. package/dist/zcap.js +478 -0
  234. package/dist/zcap.js.map +1 -0
  235. package/package.json +109 -0
  236. package/src/auth-header-hooks.ts +163 -0
  237. package/src/authorize.ts +117 -0
  238. package/src/backends/filesystem.ts +3626 -0
  239. package/src/backends/postgres.ts +3073 -0
  240. package/src/backends/postgresSchema.ts +231 -0
  241. package/src/config.default.ts +657 -0
  242. package/src/corsProxy.ts +326 -0
  243. package/src/declarations.d.ts +28 -0
  244. package/src/digest.ts +223 -0
  245. package/src/errors.ts +1122 -0
  246. package/src/index.ts +21 -0
  247. package/src/lib/atomicFile.ts +176 -0
  248. package/src/lib/backendRegistry.ts +139 -0
  249. package/src/lib/backendUsage.ts +79 -0
  250. package/src/lib/backends.ts +351 -0
  251. package/src/lib/blindedIndex.ts +466 -0
  252. package/src/lib/collectionDescription.ts +43 -0
  253. package/src/lib/cursor.ts +52 -0
  254. package/src/lib/edvEnvelope.ts +120 -0
  255. package/src/lib/encryption.ts +472 -0
  256. package/src/lib/etag.ts +40 -0
  257. package/src/lib/exportManifest.ts +115 -0
  258. package/src/lib/importTar.ts +381 -0
  259. package/src/lib/isJson.ts +18 -0
  260. package/src/lib/keyEpoch.ts +89 -0
  261. package/src/lib/keyedMutex.ts +45 -0
  262. package/src/lib/kmsModule.ts +451 -0
  263. package/src/lib/kmsRecordCipher.ts +292 -0
  264. package/src/lib/pagination.ts +22 -0
  265. package/src/lib/paths.ts +382 -0
  266. package/src/lib/preconditions.ts +145 -0
  267. package/src/lib/resourceFileName.ts +69 -0
  268. package/src/lib/revocations.ts +116 -0
  269. package/src/lib/validateDid.ts +41 -0
  270. package/src/lib/validateId.ts +148 -0
  271. package/src/plugin.ts +288 -0
  272. package/src/policy.ts +176 -0
  273. package/src/provisioning.ts +98 -0
  274. package/src/requests/BackendRequest.ts +219 -0
  275. package/src/requests/CollectionRequest.ts +899 -0
  276. package/src/requests/KeyRequest.ts +617 -0
  277. package/src/requests/KeystoreRequest.ts +325 -0
  278. package/src/requests/PolicyRequest.ts +169 -0
  279. package/src/requests/ResourceRequest.ts +649 -0
  280. package/src/requests/RevocationRequest.ts +267 -0
  281. package/src/requests/SpaceRequest.ts +686 -0
  282. package/src/requests/SpacesRepositoryRequest.ts +194 -0
  283. package/src/requests/collectionContext.ts +41 -0
  284. package/src/requests/controllerConsent.ts +108 -0
  285. package/src/requests/keystoreContext.ts +124 -0
  286. package/src/requests/resourceInput.ts +118 -0
  287. package/src/requests/spaceContext.ts +311 -0
  288. package/src/routes.ts +420 -0
  289. package/src/server.ts +69 -0
  290. package/src/start.ts +71 -0
  291. package/src/storage.ts +60 -0
  292. package/src/types.ts +1016 -0
  293. package/src/views/home.hbs +9 -0
  294. package/src/views/templates/main.hbs +12 -0
  295. package/src/zcap.ts +657 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Collection client-side encryption marker helpers (spec "Encrypted
3
+ * Collections"). A Collection MAY carry a non-secret `encryption` marker
4
+ * declaring that its Resources are client-encrypted and naming the scheme; any
5
+ * authorized reader discovers it by reading the Collection Description and then
6
+ * decrypts with its own keys. The server never decrypts: it validates only the
7
+ * marker's *shape* and enforces *set-once* immutability, storing the value
8
+ * opaquely. This mirrors the backend-selection helpers in lib/backends.ts
9
+ * (validate on write / preserve on read), kept separate because encryption is a
10
+ * per-Collection client concern, not a backend capability.
11
+ */
12
+ import type {
13
+ CollectionEncryption,
14
+ CollectionEncryptionEpoch
15
+ } from '../types.js'
16
+ import {
17
+ InvalidRequestBodyError,
18
+ EncryptionImmutableError,
19
+ UnsupportedEncryptionSchemeError,
20
+ EncryptionSchemeMismatchError
21
+ } from '../errors.js'
22
+ import { isValidEdvDocument } from './edvEnvelope.js'
23
+
24
+ /**
25
+ * The encryption schemes this server recognizes and can enforce on write (spec
26
+ * "Encryption Scheme Registry"). Each entry pins the scheme token to its
27
+ * required stored-representation media type and the structural validator for its
28
+ * envelope profile. v1 has exactly one entry: `edv` (EDV-over-WAS), an EDV
29
+ * **Encrypted Document** (a JSON object whose `jwe` member is a JWE in JSON
30
+ * serialization) carried as `application/json` -- matching what the EDV codec
31
+ * actually stores and how a native EDV server serves an Encrypted Document.
32
+ * "Marked with a recognized scheme" structurally implies "non-conforming writes
33
+ * rejected here" -- the fail-closed guarantee: a plaintext object under
34
+ * `application/json` passes the media-type gate but fails the structural
35
+ * `jwe` gate, so it is still rejected. Extending the registry (a new scheme, or
36
+ * a new media type for an existing one) is the only place a scheme becomes
37
+ * acceptable.
38
+ */
39
+ export const SUPPORTED_ENCRYPTION_SCHEMES: Record<
40
+ string,
41
+ { mediaType: string; validateEnvelope: (body: unknown) => boolean }
42
+ > = {
43
+ edv: {
44
+ mediaType: 'application/json',
45
+ validateEnvelope: isValidEdvDocument
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Validates a client-supplied Collection `encryption` marker and returns the
51
+ * normalized value to persist, or `undefined` when absent (plaintext). Two
52
+ * gates: (1) shape -- a present value must be an object with a non-empty string
53
+ * `scheme`, else `invalid-request-body` (400, pointer `#/encryption`); (2)
54
+ * fail-closed scheme gate -- the `scheme` MUST name one this server recognizes
55
+ * and can enforce on write (`SUPPORTED_ENCRYPTION_SCHEMES`), else
56
+ * `unsupported-encryption-scheme` (400, pointer `#/encryption/scheme`). Taking
57
+ * the spec's SHOULD path, the reference server refuses to store a marker it
58
+ * cannot back with write-time validation, rather than storing an unknown scheme
59
+ * opaquely. Unknown **extra fields** on an otherwise-recognized marker are still
60
+ * preserved (the whole object is returned, not reduced to `{ scheme }`) so
61
+ * future public-reference fields a newer client adds (e.g. recipient key
62
+ * references) survive an older server unchanged.
63
+ *
64
+ * @param options {object}
65
+ * @param [options.encryption] {unknown} the request body's `encryption` value
66
+ * @param [options.requestName] {string} request name for the 400 error title
67
+ * @returns {CollectionEncryption | undefined} the marker to store, or undefined
68
+ */
69
+ export function assertSupportedEncryption({
70
+ encryption,
71
+ requestName
72
+ }: {
73
+ encryption?: unknown
74
+ requestName?: string
75
+ }): CollectionEncryption | undefined {
76
+ if (encryption === undefined) {
77
+ return undefined
78
+ }
79
+ const scheme = (encryption as { scheme?: unknown })?.scheme
80
+ if (
81
+ typeof encryption !== 'object' ||
82
+ encryption === null ||
83
+ typeof scheme !== 'string' ||
84
+ scheme.length === 0
85
+ ) {
86
+ throw new InvalidRequestBodyError({
87
+ requestName,
88
+ detail:
89
+ 'Collection "encryption" must be an object with a non-empty string "scheme".',
90
+ pointer: '#/encryption'
91
+ })
92
+ }
93
+ // Fail closed: only a recognized scheme (one the server validates on write) is
94
+ // accepted; an unknown scheme is rejected rather than stored opaquely.
95
+ if (!Object.hasOwn(SUPPORTED_ENCRYPTION_SCHEMES, scheme)) {
96
+ throw new UnsupportedEncryptionSchemeError({ scheme })
97
+ }
98
+ // Validate the OPTIONAL key-epoch fields (`epochs` / `currentEpoch`) when
99
+ // present: shape-only safety rails against client bugs (a dropped epoch, a
100
+ // dangling `currentEpoch`), never crypto verification -- the server holds no
101
+ // key. Absent fields are a plain single-key-set marker and pass unchanged.
102
+ assertValidEncryptionEpochs({
103
+ marker: encryption as CollectionEncryption,
104
+ requestName
105
+ })
106
+
107
+ // Preserve the whole marker (only `scheme` is typed today; keep any extra
108
+ // forward-compat fields on a recognized scheme).
109
+ return encryption as CollectionEncryption
110
+ }
111
+
112
+ /**
113
+ * Validates the OPTIONAL key-epoch public-reference fields of a Collection
114
+ * `encryption` marker (spec "Encrypted Collections"; the `key-epochs` feature).
115
+ * Shape-only integrity checks that catch client bugs -- the server never
116
+ * interprets key material, so these are safety rails, not cryptographic
117
+ * verification. Rejects with `invalid-request-body` (400) and a precise
118
+ * `pointer`. Rules:
119
+ * - `epochs` and `currentEpoch` are all-or-nothing: either both absent (a plain
120
+ * single-key-set marker) or both present.
121
+ * - `epochs`: a non-empty array; each entry an object with a non-empty string
122
+ * `id` (ids unique across the array) and a non-empty `recipients` array.
123
+ * - each `recipients` entry carries the JWE recipients-entry members the
124
+ * marker requires: a `header` object with non-empty string `kid` and `alg`,
125
+ * plus a string `encrypted_key` (the wrapped epoch key).
126
+ * - `currentEpoch`: a non-empty string naming an `id` that exists in `epochs`.
127
+ *
128
+ * @param options {object}
129
+ * @param options.marker {CollectionEncryption} the shape-validated marker
130
+ * @param [options.requestName] {string} request name for the 400 error title
131
+ * @returns {void}
132
+ */
133
+ function assertValidEncryptionEpochs({
134
+ marker,
135
+ requestName
136
+ }: {
137
+ marker: CollectionEncryption
138
+ requestName?: string
139
+ }): void {
140
+ const { epochs, currentEpoch } = marker
141
+ // All-or-nothing: `epochs` and `currentEpoch` appear together or not at all.
142
+ if ((epochs === undefined) !== (currentEpoch === undefined)) {
143
+ throw new InvalidRequestBodyError({
144
+ requestName,
145
+ detail:
146
+ 'Collection "encryption.epochs" and "encryption.currentEpoch" must both be present or both absent.',
147
+ pointer: '#/encryption/epochs'
148
+ })
149
+ }
150
+ if (epochs === undefined) {
151
+ return
152
+ }
153
+ if (!Array.isArray(epochs) || epochs.length === 0) {
154
+ throw new InvalidRequestBodyError({
155
+ requestName,
156
+ detail: 'Collection "encryption.epochs" must be a non-empty array.',
157
+ pointer: '#/encryption/epochs'
158
+ })
159
+ }
160
+ const ids = new Set<string>()
161
+ epochs.forEach((epoch, epochIndex) => {
162
+ const pointer = `#/encryption/epochs/${epochIndex}`
163
+ if (typeof epoch !== 'object' || epoch === null || Array.isArray(epoch)) {
164
+ throw new InvalidRequestBodyError({
165
+ requestName,
166
+ detail: 'Each "encryption.epochs" entry must be an object.',
167
+ pointer
168
+ })
169
+ }
170
+ const { id, recipients } = epoch as CollectionEncryptionEpoch
171
+ if (typeof id !== 'string' || id.length === 0) {
172
+ throw new InvalidRequestBodyError({
173
+ requestName,
174
+ detail:
175
+ 'Each "encryption.epochs" entry must have a non-empty string "id".',
176
+ pointer: `${pointer}/id`
177
+ })
178
+ }
179
+ if (ids.has(id)) {
180
+ throw new InvalidRequestBodyError({
181
+ requestName,
182
+ detail: `Duplicate "encryption.epochs" id "${id}".`,
183
+ pointer: `${pointer}/id`
184
+ })
185
+ }
186
+ ids.add(id)
187
+ if (!Array.isArray(recipients) || recipients.length === 0) {
188
+ throw new InvalidRequestBodyError({
189
+ requestName,
190
+ detail:
191
+ 'Each "encryption.epochs" entry must have a non-empty "recipients" array.',
192
+ pointer: `${pointer}/recipients`
193
+ })
194
+ }
195
+ recipients.forEach((recipient, recipientIndex) => {
196
+ const rPointer = `${pointer}/recipients/${recipientIndex}`
197
+ // The JWE recipients-entry members the wrapped-epoch-key marker needs:
198
+ // `header.kid` / `header.alg` and the wrapped key `encrypted_key`. The
199
+ // member checks (optional chaining included) already reject every
200
+ // non-object or malformed entry, so no generic JWE-entry shape test runs
201
+ // first.
202
+ const header = (
203
+ recipient as { header?: { kid?: unknown; alg?: unknown } }
204
+ )?.header
205
+ if (
206
+ typeof header?.kid !== 'string' ||
207
+ header.kid.length === 0 ||
208
+ typeof header.alg !== 'string' ||
209
+ header.alg.length === 0 ||
210
+ typeof (recipient as { encrypted_key?: unknown }).encrypted_key !==
211
+ 'string'
212
+ ) {
213
+ throw new InvalidRequestBodyError({
214
+ requestName,
215
+ detail:
216
+ 'Each "recipients" entry must have a "header" object with non-empty string "kid" and "alg", plus a string "encrypted_key".',
217
+ pointer: rPointer
218
+ })
219
+ }
220
+ })
221
+ })
222
+ if (typeof currentEpoch !== 'string' || currentEpoch.length === 0) {
223
+ throw new InvalidRequestBodyError({
224
+ requestName,
225
+ detail:
226
+ 'Collection "encryption.currentEpoch" must be a non-empty string.',
227
+ pointer: '#/encryption/currentEpoch'
228
+ })
229
+ }
230
+ if (!ids.has(currentEpoch)) {
231
+ throw new InvalidRequestBodyError({
232
+ requestName,
233
+ detail: `Collection "encryption.currentEpoch" ("${currentEpoch}") does not name an epoch in "epochs".`,
234
+ pointer: '#/encryption/currentEpoch'
235
+ })
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Enforces the epoch-safety rails on an UPDATE, when the existing marker already
241
+ * carries `epochs` (spec "Encrypted Collections"; the `key-epochs` feature).
242
+ * Call only when an `incoming` marker was supplied and shape-validated. Rules
243
+ * (both `invalid-request-body`, 400):
244
+ * - **append-only**: every existing epoch id must still be present in
245
+ * `incoming.epochs` -- dropping an epoch would strand every Resource stamped
246
+ * with it (the removed reader could still hold that epoch's key, but no
247
+ * remaining reader could find it).
248
+ * - **`currentEpoch` never moves backwards**: the incoming `currentEpoch` must
249
+ * equal the existing one OR name an epoch id that was NOT in the existing
250
+ * `epochs` list (a freshly appended epoch). This is the array-order-independent
251
+ * formulation of "monotonic".
252
+ *
253
+ * Recipients WITHIN an existing epoch MAY change (adding a recipient wraps the
254
+ * epoch key to it; escrow adds entries to old epochs), so that is not
255
+ * restricted. A first declaration of `epochs` on a marker that had none is
256
+ * likewise unrestricted (there is nothing to append to yet).
257
+ *
258
+ * @param options {object}
259
+ * @param [options.existing] {CollectionEncryption} the persisted marker
260
+ * @param options.incoming {CollectionEncryption} the validated request marker
261
+ * @returns {void}
262
+ */
263
+ export function assertEncryptionEpochsTransition({
264
+ existing,
265
+ incoming
266
+ }: {
267
+ existing?: CollectionEncryption
268
+ incoming: CollectionEncryption
269
+ }): void {
270
+ const existingEpochs = existing?.epochs
271
+ if (existingEpochs === undefined || existingEpochs.length === 0) {
272
+ // No prior epochs: a first epoch declaration has nothing to append to.
273
+ return
274
+ }
275
+ const incomingIds = new Set((incoming.epochs ?? []).map(epoch => epoch.id))
276
+ // Append-only: no existing epoch id may vanish.
277
+ for (const epoch of existingEpochs) {
278
+ if (!incomingIds.has(epoch.id)) {
279
+ throw new InvalidRequestBodyError({
280
+ detail: `Collection "encryption.epochs" is append-only: epoch "${epoch.id}" may not be removed.`,
281
+ pointer: '#/encryption/epochs'
282
+ })
283
+ }
284
+ }
285
+ // `currentEpoch` never moves backwards: keep it, or repoint it to a
286
+ // newly-appended epoch id (one that did not exist before).
287
+ const existingIds = new Set(existingEpochs.map(epoch => epoch.id))
288
+ const { currentEpoch } = incoming
289
+ if (
290
+ currentEpoch !== existing?.currentEpoch &&
291
+ existingIds.has(currentEpoch as string)
292
+ ) {
293
+ throw new InvalidRequestBodyError({
294
+ detail: `Collection "encryption.currentEpoch" may not move back to the existing epoch "${currentEpoch}"; repoint it only to a newly appended epoch.`,
295
+ pointer: '#/encryption/currentEpoch'
296
+ })
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Enforces the full `encryption`-marker transition rails against a persisted
302
+ * marker in one call: set-once immutability
303
+ * ({@link assertEncryptionTransition}) plus the key-epoch rails
304
+ * ({@link assertEncryptionEpochsTransition}). Unlike those two -- which require
305
+ * a supplied `incoming` -- this also accepts an absent one: a write whose
306
+ * description would CLEAR an existing marker is rejected with
307
+ * `encryption-immutable` (409), on the same terms as changing it. The request
308
+ * layer uses this twice per Update Collection: once against its own
309
+ * (pre-lock) read for a clean early rejection, and again as the
310
+ * `writeCollection` `assertTransition` callback, re-evaluated inside the
311
+ * backend's lock/transaction against the freshly re-read description -- so a
312
+ * concurrent marker write cannot be silently clobbered and the epoch
313
+ * append-only rail holds unconditionally, not just under `If-Match`.
314
+ *
315
+ * @param options {object}
316
+ * @param [options.existing] {CollectionEncryption} the persisted marker
317
+ * @param [options.incoming] {CollectionEncryption} the marker about to be
318
+ * persisted (absent when the write would drop the marker entirely)
319
+ * @returns {void}
320
+ */
321
+ export function assertEncryptionMarkerTransition({
322
+ existing,
323
+ incoming
324
+ }: {
325
+ existing?: CollectionEncryption
326
+ incoming?: CollectionEncryption
327
+ }): void {
328
+ if (existing === undefined) {
329
+ // Nothing persisted yet: a first declaration (or a plaintext Collection
330
+ // staying plaintext) has no rails to check.
331
+ return
332
+ }
333
+ if (incoming === undefined) {
334
+ throw new EncryptionImmutableError()
335
+ }
336
+ assertEncryptionTransition({ existing, incoming })
337
+ assertEncryptionEpochsTransition({ existing, incoming })
338
+ }
339
+
340
+ /**
341
+ * Enforces set-once immutability of a Collection's `encryption` marker on
342
+ * update. Call only when an `incoming` marker was supplied (and shape-validated)
343
+ * by the request. Declaring a marker on a Collection that lacks one is allowed
344
+ * (`absent -> present`: late declaration / migration of a pre-marker
345
+ * Collection); re-sending the same `scheme` is a no-op. Changing the `scheme` of
346
+ * an existing marker is rejected with `encryption-immutable` (409) -- it would
347
+ * corrupt the stored, client-encrypted Resources. (Clearing is not expressible:
348
+ * an absent body `encryption` leaves the existing marker untouched, and an
349
+ * explicit non-object is already a 400 in `assertSupportedEncryption`.) The
350
+ * `scheme` is the immutable identity; comparison deliberately ignores any
351
+ * future public-reference fields, whose evolution (e.g. adding a recipient) is a
352
+ * separate, allowed operation.
353
+ *
354
+ * @param options {object}
355
+ * @param [options.existing] {CollectionEncryption} the persisted marker
356
+ * @param options.incoming {CollectionEncryption} the validated request marker
357
+ * @returns {void}
358
+ */
359
+ export function assertEncryptionTransition({
360
+ existing,
361
+ incoming
362
+ }: {
363
+ existing?: CollectionEncryption
364
+ incoming: CollectionEncryption
365
+ }): void {
366
+ if (existing !== undefined && existing.scheme !== incoming.scheme) {
367
+ throw new EncryptionImmutableError()
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Fail-closed structural validation of a Resource **content** write into an
373
+ * encrypted Collection (spec "Encryption Scheme Registry"). When the target
374
+ * Collection declares a recognized `encryption` scheme, a write MUST be a
375
+ * conforming envelope of that scheme -- two gates: (1) the request `Content-Type`
376
+ * MUST be the scheme's registered media type (so a binary/`octet-stream`,
377
+ * `multipart`, or plain `application/json` upload is rejected outright), and (2)
378
+ * the parsed body MUST satisfy the scheme's structural envelope profile. A
379
+ * failure of either is `encryption-scheme-mismatch` (422). No-op when the
380
+ * Collection has no marker (plaintext) or -- defensively -- an unrecognized
381
+ * scheme (which `assertSupportedEncryption` prevents from ever being stored), so
382
+ * plaintext Collections and API documents are unaffected. The server validates
383
+ * structure only; it never decrypts. Call this **after** capability verification
384
+ * and the 404-if-missing check, and before resolving the body stream, so a wrong
385
+ * content type is rejected without consuming the upload and a 422 is observable
386
+ * only to a caller already authorized to write the target.
387
+ *
388
+ * @param options {object}
389
+ * @param options.collectionDescription {{ encryption?: CollectionEncryption }}
390
+ * the target Collection's stored description
391
+ * @param [options.contentType] {string} the request `Content-Type` header
392
+ * @param options.body {unknown} the parsed request body (an object for the
393
+ * `application/<suffix>+json` media types the scheme registry uses)
394
+ * @returns {void}
395
+ */
396
+ export function assertEncryptedWriteConforms({
397
+ collectionDescription,
398
+ contentType,
399
+ body
400
+ }: {
401
+ collectionDescription: { encryption?: CollectionEncryption }
402
+ contentType?: string
403
+ body: unknown
404
+ }): void {
405
+ const scheme = collectionDescription.encryption?.scheme
406
+ if (scheme === undefined) {
407
+ return
408
+ }
409
+ const profile = SUPPORTED_ENCRYPTION_SCHEMES[scheme]
410
+ if (!profile) {
411
+ return
412
+ }
413
+ // Gate 1: the stored representation's media type. Compare the bare media type
414
+ // (parameters like `; charset=utf-8` stripped), case-insensitively.
415
+ const mediaType = (contentType ?? '').split(';')[0]!.trim().toLowerCase()
416
+ if (mediaType !== profile.mediaType) {
417
+ throw new EncryptionSchemeMismatchError({
418
+ detail: `A write into a Collection encrypted with the '${scheme}' scheme must use Content-Type '${profile.mediaType}'.`
419
+ })
420
+ }
421
+ // Gate 2: the envelope's structural profile (server validates shape, not
422
+ // contents -- it never decrypts).
423
+ if (!profile.validateEnvelope(body)) {
424
+ throw new EncryptionSchemeMismatchError({
425
+ detail: `Resource body is not a structurally valid '${scheme}' encryption envelope.`
426
+ })
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Fail-closed structural validation of a Resource **metadata** write (`PUT
432
+ * /meta`) into an encrypted Collection (spec "Encrypted Collections"). When the
433
+ * target Collection declares a recognized `encryption` scheme, the user-writable
434
+ * `custom` object MUST be a conforming envelope of that scheme -- the same
435
+ * structural profile used for content -- so a plaintext `{ name, tags }` cannot
436
+ * be stored server-visibly. Unlike {@link assertEncryptedWriteConforms} there is
437
+ * **no media-type gate**: the metadata document itself stays `application/json`
438
+ * (its server-managed top-level fields are plaintext); only the `custom`
439
+ * sub-value is the envelope. A non-conforming `custom` is
440
+ * `encryption-scheme-mismatch` (422). No-op when the Collection has no marker
441
+ * (plaintext) or -- defensively -- an unrecognized scheme. The server validates
442
+ * structure only; it never decrypts. Call this **after** capability verification
443
+ * and the 404-if-missing check, before the write, so a 422 is observable only to
444
+ * a caller already authorized to write the target.
445
+ *
446
+ * @param options {object}
447
+ * @param options.collectionDescription {{ encryption?: CollectionEncryption }}
448
+ * the target Collection's stored description
449
+ * @param options.custom {unknown} the request body's `custom` value
450
+ * @returns {void}
451
+ */
452
+ export function assertEncryptedMetaConforms({
453
+ collectionDescription,
454
+ custom
455
+ }: {
456
+ collectionDescription: { encryption?: CollectionEncryption }
457
+ custom: unknown
458
+ }): void {
459
+ const scheme = collectionDescription.encryption?.scheme
460
+ if (scheme === undefined) {
461
+ return
462
+ }
463
+ const profile = SUPPORTED_ENCRYPTION_SCHEMES[scheme]
464
+ if (!profile) {
465
+ return
466
+ }
467
+ if (!profile.validateEnvelope(custom)) {
468
+ throw new EncryptionSchemeMismatchError({
469
+ detail: `Resource metadata "custom" is not a structurally valid '${scheme}' encryption envelope.`
470
+ })
471
+ }
472
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Helpers for the HTTP `ETag` strong validator that backs conditional writes
3
+ * (the `conditional-writes` feature). A Resource's monotonic `version` is
4
+ * formatted as a quoted strong validator on the wire, and incoming `If-Match` /
5
+ * `If-None-Match` request headers are normalized into write preconditions.
6
+ */
7
+
8
+ /**
9
+ * Formats a Resource's monotonic `version` as a quoted strong `ETag` validator
10
+ * (e.g. version 3 to `"3"`). The quotes are part of the on-the-wire value, and
11
+ * `If-Match` comparison is exact-string (strong) comparison.
12
+ * @param version {number}
13
+ * @returns {string}
14
+ */
15
+ export function formatEtag(version: number): string {
16
+ return `"${version}"`
17
+ }
18
+
19
+ /**
20
+ * Normalizes the `If-Match` / `If-None-Match` request headers into the write
21
+ * preconditions the storage layer evaluates. Only `If-None-Match: *`
22
+ * (create-if-absent) is supported; an `If-Match` value is passed through as the
23
+ * quoted ETag to match. A header that is absent (or, for an array-valued header,
24
+ * not a single string) contributes no precondition.
25
+ * @param headers {object}
26
+ * @param [headers.if-match] {string | string[]}
27
+ * @param [headers.if-none-match] {string | string[]}
28
+ * @returns {{ ifMatch?: string, ifNoneMatch?: boolean }}
29
+ */
30
+ export function parseWritePreconditions(headers: {
31
+ 'if-match'?: string | string[]
32
+ 'if-none-match'?: string | string[]
33
+ }): { ifMatch?: string; ifNoneMatch?: boolean } {
34
+ const ifMatch = headers['if-match']
35
+ const ifNoneMatch = headers['if-none-match']
36
+ return {
37
+ ...(typeof ifMatch === 'string' && { ifMatch }),
38
+ ...(ifNoneMatch === '*' && { ifNoneMatch: true })
39
+ }
40
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Backend-agnostic builder for the Space-export `manifest.yml` document (UBC
3
+ * v0.1, FEP-6fcd). Both storage backends synthesize the same archive layout --
4
+ * `space/<spaceId>/...` entries in the filesystem backend's on-disk file-name
5
+ * dialect -- so an archive exported from one backend imports into the other;
6
+ * this module is the single home for the manifest that describes it.
7
+ */
8
+ import {
9
+ UBC_MANIFEST_URL,
10
+ SPACE_URL,
11
+ COLLECTION_URL,
12
+ RESOURCE_URL,
13
+ POLICY_URL,
14
+ META_URL
15
+ } from '../config.default.js'
16
+
17
+ /**
18
+ * Fixed `mtime` for every entry in an export archive. `tar-stream` defaults a
19
+ * header's `mtime` to the wall-clock time it was packed, which makes two
20
+ * exports of an unchanged Space differ whenever they straddle a one-second
21
+ * boundary. Pinning it to the Unix epoch makes an export a pure function of
22
+ * the Space's contents (byte-reproducible and diff-stable); the timestamp
23
+ * carries no meaning a consumer relies on.
24
+ */
25
+ export const EXPORT_ENTRY_MTIME = new Date(0)
26
+
27
+ /**
28
+ * One top-level entry of the Space being exported, in archive order: a
29
+ * Space-level file (`files` absent) or a Collection directory with its ordered
30
+ * file names.
31
+ */
32
+ export interface ExportSpaceEntry {
33
+ name: string
34
+ /** present for a Collection directory: its file names, in archive order */
35
+ files?: string[]
36
+ }
37
+
38
+ /**
39
+ * Classifies one Collection-dir file name into its manifest entry: the known
40
+ * dot-file kinds and resource representations get a documenting `url`, anything
41
+ * else is listed by bare name.
42
+ * @param fileName {string}
43
+ * @returns {unknown}
44
+ */
45
+ function collectionManifestEntry(fileName: string): unknown {
46
+ if (fileName.startsWith('.collection.')) {
47
+ return { [fileName]: { url: COLLECTION_URL } }
48
+ }
49
+ if (fileName.startsWith('.policy.')) {
50
+ return { [fileName]: { url: POLICY_URL } }
51
+ }
52
+ if (fileName.startsWith('.meta.')) {
53
+ return { [fileName]: { url: META_URL } }
54
+ }
55
+ if (fileName.startsWith('r.')) {
56
+ return { [fileName]: { url: RESOURCE_URL } }
57
+ }
58
+ return fileName
59
+ }
60
+
61
+ /**
62
+ * Builds the UBC v0.1 manifest object for a Space export. The caller supplies
63
+ * the archive's top-level entries in the order they will be packed (Space-level
64
+ * files interleaved with Collection directories); the manifest mirrors that
65
+ * order.
66
+ * @param options {object}
67
+ * @param options.spaceId {string}
68
+ * @param options.entries {ExportSpaceEntry[]} ordered top-level entries
69
+ * @param [options.revocationFiles] {string[]} ordered file names of the
70
+ * archive's Space-scoped zcap revocation records (`revocations/` entries);
71
+ * omitted from the manifest when the Space has none
72
+ * @returns {object} the manifest document (serialize with `YAML.stringify`)
73
+ */
74
+ export function buildExportManifest({
75
+ spaceId,
76
+ entries,
77
+ revocationFiles = []
78
+ }: {
79
+ spaceId: string
80
+ entries: ExportSpaceEntry[]
81
+ revocationFiles?: string[]
82
+ }): object {
83
+ const spaceContents: unknown[] = []
84
+ for (const entry of entries) {
85
+ if (entry.files === undefined) {
86
+ // top-level files in space (e.g. .space.<spaceId>.json)
87
+ spaceContents.push(entry.name)
88
+ continue
89
+ }
90
+ spaceContents.push({
91
+ [entry.name]: {
92
+ contents: entry.files.map(collectionManifestEntry)
93
+ }
94
+ })
95
+ }
96
+
97
+ return {
98
+ 'ubc-version': '0.1',
99
+ contents: {
100
+ 'manifest.yml': { url: UBC_MANIFEST_URL },
101
+ ...(revocationFiles.length > 0 && {
102
+ revocations: { contents: [...revocationFiles] }
103
+ }),
104
+ space: {
105
+ url: SPACE_URL,
106
+ contents: {
107
+ [spaceId]: {
108
+ url: SPACE_URL,
109
+ contents: spaceContents
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }