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,381 @@
1
+ import * as tar from 'tar-stream'
2
+ import YAML from 'yaml'
3
+ import type { Readable } from 'node:stream'
4
+ import { assertValidId } from './validateId.js'
5
+ import { parseResourceFileName } from './resourceFileName.js'
6
+ import { InvalidImportError } from '../errors.js'
7
+ import type {
8
+ CollectionDescription,
9
+ PolicyDocument,
10
+ RevocationRecord
11
+ } from '../types.js'
12
+
13
+ /** Suffix shared by the description / policy / metadata dot-files. */
14
+ const JSON_SUFFIX = '.json'
15
+ /** Prefix of a policy dot-file (`.policy.<id>.json`). */
16
+ const POLICY_PREFIX = '.policy.'
17
+ /** Prefix of a resource metadata sidecar (`.meta.<id>.json`). */
18
+ const META_PREFIX = '.meta.'
19
+
20
+ /**
21
+ * If `fileName` is a dot-file with the given prefix and `.json` suffix, returns
22
+ * the `<id>` between them; otherwise undefined. Uses slice (not split) so ids
23
+ * containing dots (URL-safe `.` is allowed) round-trip correctly.
24
+ * @param fileName {string}
25
+ * @param prefix {string}
26
+ * @returns {string | undefined}
27
+ */
28
+ function dotFileId(fileName: string, prefix: string): string | undefined {
29
+ if (!fileName.startsWith(prefix) || !fileName.endsWith(JSON_SUFFIX)) {
30
+ return undefined
31
+ }
32
+ const id = fileName.slice(prefix.length, -JSON_SUFFIX.length)
33
+ return id.length > 0 ? id : undefined
34
+ }
35
+
36
+ /**
37
+ * If `fileName` is a policy dot-file (`.policy.<id>.json`), returns the `<id>`
38
+ * it is keyed by; otherwise undefined.
39
+ * @param fileName {string}
40
+ * @returns {string | undefined}
41
+ */
42
+ function policyFileId(fileName: string): string | undefined {
43
+ return dotFileId(fileName, POLICY_PREFIX)
44
+ }
45
+
46
+ /**
47
+ * If `fileName` is a metadata sidecar (`.meta.<resourceId>.json`), returns the
48
+ * `<resourceId>` it is keyed by; otherwise undefined.
49
+ * @param fileName {string}
50
+ * @returns {string | undefined}
51
+ */
52
+ export function metaSidecarFileId(fileName: string): string | undefined {
53
+ return dotFileId(fileName, META_PREFIX)
54
+ }
55
+
56
+ /** One extracted archive entry, keyed by its archive path. */
57
+ export interface TarEntry {
58
+ type: 'file' | 'directory'
59
+ body?: Buffer
60
+ }
61
+
62
+ /** One resource staged for import by {@link buildImportPlan}. */
63
+ export interface ImportPlanResource {
64
+ fileName: string
65
+ resourceId: string
66
+ body: Buffer
67
+ }
68
+
69
+ /** One collection (plus its resources and policies) staged for import. */
70
+ export interface ImportPlanCollection {
71
+ collectionId: string
72
+ collectionDescription: CollectionDescription
73
+ /** Collection-level access-control policy, if the archive carries one. */
74
+ collectionPolicy?: PolicyDocument
75
+ resources: ImportPlanResource[]
76
+ /** Resource-level policies, keyed by resourceId. */
77
+ resourcePolicies: Map<string, PolicyDocument>
78
+ /** Resource metadata sidecars (raw `.meta.<id>.json` bytes), keyed by resourceId. */
79
+ resourceMetadata: Map<string, Buffer>
80
+ }
81
+
82
+ /** The merge plan produced by {@link buildImportPlan}. */
83
+ export interface ImportPlan {
84
+ /** Space-level access-control policy, if the archive carries one. */
85
+ spacePolicy?: PolicyDocument
86
+ collections: ImportPlanCollection[]
87
+ /**
88
+ * Space-scoped zcap revocation records the archive carries (top-level
89
+ * `revocations/` entries), restored under the destination Space's scope.
90
+ */
91
+ revocations: RevocationRecord[]
92
+ }
93
+
94
+ /**
95
+ * @param options {object}
96
+ * @param options.tarStream {Readable}
97
+ * @returns {Promise<Map<string, TarEntry>>}
98
+ */
99
+ export async function extractTarEntries(
100
+ tarStream: Readable
101
+ ): Promise<Map<string, TarEntry>> {
102
+ const entries = new Map<string, TarEntry>()
103
+
104
+ await new Promise<void>((resolve, reject) => {
105
+ const extract = tar.extract()
106
+
107
+ extract.on('entry', (header, stream, next) => {
108
+ if (header.type === 'directory') {
109
+ entries.set(header.name, { type: 'directory' })
110
+ stream.resume()
111
+ next()
112
+ return
113
+ }
114
+
115
+ const chunks: Buffer[] = []
116
+ stream.on('data', chunk => chunks.push(Buffer.from(chunk)))
117
+ stream.on('end', () => {
118
+ entries.set(header.name, {
119
+ type: 'file',
120
+ body: Buffer.concat(chunks)
121
+ })
122
+ next()
123
+ })
124
+ stream.on('error', reject)
125
+ })
126
+
127
+ extract.on('finish', resolve)
128
+ extract.on('error', reject)
129
+ tarStream.on('error', reject)
130
+ tarStream.pipe(extract)
131
+ })
132
+
133
+ return entries
134
+ }
135
+
136
+ /**
137
+ * Validates that the archive carries a well-formed UBC v0.1 `manifest.yml`
138
+ * describing a WAS space export. Throws on any problem.
139
+ * @param entries {Map<string, TarEntry>}
140
+ * @returns {void}
141
+ */
142
+ export function validateManifest(entries: Map<string, TarEntry>): void {
143
+ const manifestEntry = entries.get('manifest.yml')
144
+ if (!manifestEntry?.body) {
145
+ throw new InvalidImportError({
146
+ message: 'Archive is missing manifest.yml.'
147
+ })
148
+ }
149
+
150
+ let manifest
151
+ try {
152
+ manifest = YAML.parse(manifestEntry.body.toString('utf8'))
153
+ } catch (err) {
154
+ throw new InvalidImportError({
155
+ message: 'Archive manifest.yml is not valid YAML.',
156
+ cause: err as Error
157
+ })
158
+ }
159
+
160
+ if (manifest['ubc-version'] !== '0.1') {
161
+ throw new InvalidImportError({
162
+ message: 'Unsupported archive manifest version.'
163
+ })
164
+ }
165
+
166
+ if (!manifest.contents?.space) {
167
+ throw new InvalidImportError({
168
+ message: 'Archive manifest does not describe a WAS space export.'
169
+ })
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Build a merge plan from a WAS space export tarball.
175
+ *
176
+ * Expected archive layout (UBC v0.1, produced by exportSpace):
177
+ * - manifest.yml
178
+ * - revocations/<digest>.json (Space-scoped zcap revocations; optional)
179
+ * - space/
180
+ * - space/<sourceSpaceId>/
181
+ * - space/<sourceSpaceId>/.space.<sourceSpaceId>.json (space metadata; ignored on import)
182
+ * - space/<sourceSpaceId>/.policy.<sourceSpaceId>.json (space-level policy)
183
+ * - space/<sourceSpaceId>/<collectionId>/
184
+ * - space/<sourceSpaceId>/<collectionId>/.collection.<collectionId>.json
185
+ * - space/<sourceSpaceId>/<collectionId>/.policy.<collectionId>.json (collection policy)
186
+ * - space/<sourceSpaceId>/<collectionId>/.policy.<resourceId>.json (resource policy)
187
+ * - space/<sourceSpaceId>/<collectionId>/r.<resourceId>.<encodedContentType>.<ext>
188
+ *
189
+ * The source space id in the path may differ from the import target; collection
190
+ * metadata, r.* resource files, and `.policy.*` policy files are merged into the
191
+ * destination.
192
+ *
193
+ * @param entries {Map<string, TarEntry>}
194
+ * @returns {ImportPlan}
195
+ */
196
+ export function buildImportPlan(entries: Map<string, TarEntry>): ImportPlan {
197
+ validateManifest(entries)
198
+
199
+ let sourceSpaceId: string | undefined
200
+ for (const name of entries.keys()) {
201
+ const match = name.match(/^space\/([^/]+)\//)
202
+ if (match) {
203
+ sourceSpaceId = match[1]
204
+ break
205
+ }
206
+ }
207
+ if (!sourceSpaceId) {
208
+ throw new InvalidImportError({
209
+ message: 'Archive does not contain space data.'
210
+ })
211
+ }
212
+
213
+ const prefix = `space/${sourceSpaceId}/`
214
+
215
+ // Space-level policy (`.policy.<sourceSpaceId>.json` at the space root).
216
+ const spacePolicyEntry = entries.get(`${prefix}.policy.${sourceSpaceId}.json`)
217
+ const spacePolicy: PolicyDocument | undefined = spacePolicyEntry?.body
218
+ ? JSON.parse(spacePolicyEntry.body.toString('utf8'))
219
+ : undefined
220
+ const collectionIds = new Set<string>()
221
+ for (const name of entries.keys()) {
222
+ if (!name.startsWith(prefix)) {
223
+ continue
224
+ }
225
+ const match = name.slice(prefix.length).match(/^([^/]+)\//)
226
+ if (match?.[1]) {
227
+ // Reject a path-traversal / non-URL-safe id parsed from the archive
228
+ // before it is used to build a destination path.
229
+ assertValidId(match[1], {
230
+ kind: 'collection',
231
+ requestName: 'Import Space'
232
+ })
233
+ collectionIds.add(match[1])
234
+ }
235
+ }
236
+
237
+ const collections = [...collectionIds].sort().map(collectionId => {
238
+ const collectionMetaKey = `${prefix}${collectionId}/.collection.${collectionId}.json`
239
+ const metaEntry = entries.get(collectionMetaKey)
240
+ const collectionDescription: CollectionDescription = metaEntry?.body
241
+ ? JSON.parse(metaEntry.body.toString('utf8'))
242
+ : { id: collectionId, type: ['Collection'], name: collectionId }
243
+
244
+ const collectionPrefix = `${prefix}${collectionId}/`
245
+ const resources: ImportPlanResource[] = []
246
+ let collectionPolicy: PolicyDocument | undefined
247
+ const resourcePolicies = new Map<string, PolicyDocument>()
248
+ const resourceMetadata = new Map<string, Buffer>()
249
+ for (const [entryName, entry] of entries) {
250
+ if (
251
+ !entryName.startsWith(collectionPrefix) ||
252
+ entry.type !== 'file' ||
253
+ !entry.body
254
+ ) {
255
+ continue
256
+ }
257
+
258
+ const fileName = entryName.slice(collectionPrefix.length)
259
+ if (fileName.includes('/')) {
260
+ continue
261
+ }
262
+
263
+ // Policy dot-files: `.policy.<collectionId>.json` is the Collection policy;
264
+ // `.policy.<resourceId>.json` is a Resource policy keyed by resource id.
265
+ const policyId = policyFileId(fileName)
266
+ if (policyId !== undefined) {
267
+ const policy: PolicyDocument = JSON.parse(entry.body.toString('utf8'))
268
+ if (policyId === collectionId) {
269
+ collectionPolicy = policy
270
+ } else {
271
+ // Reject a path-traversal / non-URL-safe id parsed from the archive.
272
+ assertValidId(policyId, {
273
+ kind: 'resource',
274
+ requestName: 'Import Space'
275
+ })
276
+ resourcePolicies.set(policyId, policy)
277
+ }
278
+ continue
279
+ }
280
+
281
+ // Metadata sidecar (`.meta.<resourceId>.json`): carried as raw bytes,
282
+ // keyed by resourceId. Written alongside a newly-created resource
283
+ // (preserving its timestamps and user-writable `custom`), or -- for a
284
+ // tombstone, whose content file is gone -- on its own as an orphan sidecar
285
+ // (see `importSpace`).
286
+ const metaId = metaSidecarFileId(fileName)
287
+ if (metaId !== undefined) {
288
+ assertValidId(metaId, { kind: 'resource', requestName: 'Import Space' })
289
+ resourceMetadata.set(metaId, entry.body)
290
+ continue
291
+ }
292
+
293
+ if (!fileName.startsWith('r.')) {
294
+ continue
295
+ }
296
+
297
+ const parts = fileName.split('.')
298
+ if (parts.length < 4 || !parts[1]) {
299
+ continue
300
+ }
301
+
302
+ // Decode the dot-escaped id segment (see `fileNameFor`) so a dotted id
303
+ // (e.g. `index.html`) round-trips. The `fileName` is preserved verbatim
304
+ // (the bytes are written under their stored name); only the parsed
305
+ // `resourceId` -- used for dedup, sidecar, and policy keying -- is decoded.
306
+ const { resourceId } = parseResourceFileName(fileName)
307
+
308
+ // Reject a path-traversal / non-URL-safe resource id parsed from the
309
+ // archive before its bytes are written to a destination path.
310
+ assertValidId(resourceId, {
311
+ kind: 'resource',
312
+ requestName: 'Import Space'
313
+ })
314
+
315
+ resources.push({
316
+ fileName,
317
+ resourceId,
318
+ body: entry.body
319
+ })
320
+ }
321
+
322
+ return {
323
+ collectionId,
324
+ collectionDescription,
325
+ collectionPolicy,
326
+ resources,
327
+ resourcePolicies,
328
+ resourceMetadata
329
+ }
330
+ })
331
+
332
+ return { spacePolicy, collections, revocations: revocationRecords(entries) }
333
+ }
334
+
335
+ /** Prefix of the archive's Space-scoped revocation entries. */
336
+ const REVOCATIONS_PREFIX = 'revocations/'
337
+
338
+ /**
339
+ * Parses the archive's Space-scoped zcap revocation records (top-level
340
+ * `revocations/<digest>.json` entries; see `revocationFileName`). Archives
341
+ * from servers that predate revocation export carry none, which yields an
342
+ * empty list. A record that is not JSON or lacks the `(capability.id,
343
+ * meta.delegator)` unique key rejects the import -- the store's file names
344
+ * and uniqueness gate are derived from those fields.
345
+ * @param entries {Map<string, TarEntry>}
346
+ * @returns {RevocationRecord[]}
347
+ */
348
+ function revocationRecords(entries: Map<string, TarEntry>): RevocationRecord[] {
349
+ const records: RevocationRecord[] = []
350
+ for (const [entryName, entry] of entries) {
351
+ if (
352
+ !entryName.startsWith(REVOCATIONS_PREFIX) ||
353
+ entry.type !== 'file' ||
354
+ !entry.body ||
355
+ entryName.slice(REVOCATIONS_PREFIX.length).includes('/')
356
+ ) {
357
+ continue
358
+ }
359
+ let record: RevocationRecord
360
+ try {
361
+ record = JSON.parse(entry.body.toString('utf8'))
362
+ } catch (err) {
363
+ throw new InvalidImportError({
364
+ message: `Archive revocation record "${entryName}" is not valid JSON.`,
365
+ cause: err as Error
366
+ })
367
+ }
368
+ if (
369
+ typeof record?.capability?.id !== 'string' ||
370
+ record.capability.id.length === 0 ||
371
+ typeof record?.meta?.delegator !== 'string' ||
372
+ record.meta.delegator.length === 0
373
+ ) {
374
+ throw new InvalidImportError({
375
+ message: `Archive revocation record "${entryName}" is malformed.`
376
+ })
377
+ }
378
+ records.push(record)
379
+ }
380
+ return records
381
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Helper: true if a content-type denotes JSON -- `application/json` or any
3
+ * `application/<prefix>+json` structured-suffix variant (e.g.
4
+ * `application/ld+json`, `application/vnd.api+json`), each optionally followed
5
+ * by parameters (`; charset=utf-8`). The `json` token is anchored to the end of
6
+ * the media type so non-JSON types that merely begin with `json` --
7
+ * `application/jsonl`, `application/json-seq`, `application/json5` -- are NOT
8
+ * treated as JSON and instead take the binary blob path.
9
+ * @param options {object}
10
+ * @param options.contentType {string}
11
+ * @returns {boolean}
12
+ */
13
+ export function isJson({ contentType }: { contentType?: string }): boolean {
14
+ return (
15
+ typeof contentType === 'string' &&
16
+ /^application\/(?:[^+\s;]+\+)?json\s*(?:;.*)?$/i.test(contentType)
17
+ )
18
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Helpers for the client-declared key-epoch stamp on encrypted-Collection
3
+ * Resources (the `key-epochs` feature). A writer declares which epoch key
4
+ * encrypted a Resource's content so a reader can pick the right key BEFORE
5
+ * attempting decryption; the value is advisory metadata the server stores
6
+ * opaquely and never computes or verifies (a write may race a rotation).
7
+ *
8
+ * On a **content** write the epoch is declared via the `WAS-Key-Epoch` request
9
+ * header -- one mechanism that works uniformly for JSON, raw-stream binary, and
10
+ * multipart writes. It is deliberately NOT signature-covered (like `If-Match`):
11
+ * it is advisory client-declared metadata, not part of the capability
12
+ * invocation. A content write stores the header's value when present and CLEARS
13
+ * the stamp when absent -- the new ciphertext's epoch is unknown, and a stale
14
+ * stamp is worse than none (a reader falls back to `currentEpoch` and can try
15
+ * other epochs). A `PUT .../meta` may also declare `epoch` as a top-level member
16
+ * of the body (a sibling of `custom`), where omitting it PRESERVES the stored
17
+ * value -- see the `putMeta` handler.
18
+ *
19
+ * The only validation is that a present value is a non-empty string (400
20
+ * otherwise); the server never checks it against the Collection marker's epochs.
21
+ */
22
+ import { InvalidRequestBodyError } from '../errors.js'
23
+
24
+ /** The request header carrying the client-declared key-epoch on a content write. */
25
+ export const KEY_EPOCH_HEADER = 'was-key-epoch'
26
+
27
+ /**
28
+ * Parses the OPTIONAL `WAS-Key-Epoch` request header into the epoch stamp for a
29
+ * content write. Resolves `{ epoch: string }` for a non-empty single-valued
30
+ * header, `{ epoch: undefined }` when absent (the content write clears any
31
+ * stored stamp), and throws `invalid-request-body` (400) for an empty or
32
+ * array-valued header.
33
+ * @param options {object}
34
+ * @param options.headers {object} the Fastify request headers
35
+ * @param [options.requestName] {string} request name for the 400 error title
36
+ * @returns {{ epoch?: string }}
37
+ */
38
+ export function parseKeyEpochHeader({
39
+ headers,
40
+ requestName
41
+ }: {
42
+ headers: Record<string, string | string[] | undefined>
43
+ requestName?: string
44
+ }): { epoch?: string } {
45
+ const value = headers[KEY_EPOCH_HEADER]
46
+ if (value === undefined) {
47
+ return {}
48
+ }
49
+ if (typeof value !== 'string' || value.length === 0) {
50
+ throw new InvalidRequestBodyError({
51
+ requestName,
52
+ detail: 'The "WAS-Key-Epoch" header must be a non-empty string.'
53
+ })
54
+ }
55
+ return { epoch: value }
56
+ }
57
+
58
+ /**
59
+ * Validates and extracts the OPTIONAL top-level `epoch` member of an Update
60
+ * Resource Metadata (`PUT .../meta`) body. Unlike a content write, a metadata
61
+ * write PRESERVES the stored epoch when the member is omitted (the stamp
62
+ * describes the content write, not the metadata write); a present value must be
63
+ * a non-empty string (400 otherwise). Returns `{ epoch: string }` when supplied,
64
+ * or `{}` when the member is absent (preserve).
65
+ * @param options {object}
66
+ * @param options.body {object} the parsed request body (already known to be an object)
67
+ * @param [options.requestName] {string} request name for the 400 error title
68
+ * @returns {{ epoch?: string }}
69
+ */
70
+ export function parseMetaEpoch({
71
+ body,
72
+ requestName
73
+ }: {
74
+ body: Record<string, unknown>
75
+ requestName?: string
76
+ }): { epoch?: string } {
77
+ if (!Object.hasOwn(body, 'epoch')) {
78
+ return {}
79
+ }
80
+ const value = body.epoch
81
+ if (typeof value !== 'string' || value.length === 0) {
82
+ throw new InvalidRequestBodyError({
83
+ requestName,
84
+ detail: 'The "epoch" property must be a non-empty string.',
85
+ pointer: '/epoch'
86
+ })
87
+ }
88
+ return { epoch: value }
89
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A keyed in-process mutex that serializes async functions per key, ported from
3
+ * the `@interop/edv-server` storage layer. The conditional-write path uses it to
4
+ * make a Resource's read-check-write atomic: two concurrent writers of the same
5
+ * Resource cannot both observe the same prior version and both succeed.
6
+ *
7
+ * This is a single-instance lock only. It does NOT coordinate writes across
8
+ * multiple server processes or a horizontally-scaled deployment -- that is out
9
+ * of scope for the reference server (see the spec's Conditional Requests note).
10
+ */
11
+
12
+ /**
13
+ * Serializes async functions per key. `run` chains the given function onto the
14
+ * tail of the key's promise queue, so all functions for the same key execute
15
+ * strictly one at a time, in call order. Distinct keys run concurrently.
16
+ */
17
+ export class KeyedMutex {
18
+ private readonly queues = new Map<string, Promise<unknown>>()
19
+
20
+ /**
21
+ * Runs `fn` once all previously-queued functions for `key` have settled,
22
+ * resolving (or rejecting) with `fn`'s result. The key's queue entry is
23
+ * cleaned up once it drains so the map does not grow without bound.
24
+ * @param key {string} the serialization key (e.g. a per-Resource path)
25
+ * @param fn {() => Promise<T>} the critical section to run under the lock
26
+ * @returns {Promise<T>}
27
+ */
28
+ async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
29
+ const previous = this.queues.get(key) ?? Promise.resolve()
30
+ const run = previous.then(fn, fn)
31
+ // Keep the chain alive even if `fn` rejects, so a failed critical section
32
+ // does not wedge the key's queue; track this swallowed-rejection tail so the
33
+ // queue entry can be reclaimed once it drains (unless a later call replaced
34
+ // it).
35
+ const tail = run.catch(() => {})
36
+ this.queues.set(key, tail)
37
+ try {
38
+ return await run
39
+ } finally {
40
+ if (this.queues.get(key) === tail) {
41
+ this.queues.delete(key)
42
+ }
43
+ }
44
+ }
45
+ }