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,3626 @@
1
+ /**
2
+ * Filesystem persistence backend: stores Spaces, Collections, and Resources as
3
+ * directories and files under `data/spaces/`, and WebKMS keystores under the
4
+ * sibling `data/keystores/` tree. The default (and currently only) adapter
5
+ * implementing the StorageBackend contract documented in types.ts.
6
+ */
7
+ import path from 'node:path'
8
+ import { mkdir, rm, stat as fsStat } from 'node:fs/promises'
9
+ import { pipeline } from 'node:stream/promises'
10
+ import { execFile } from 'node:child_process'
11
+ import { promisify } from 'node:util'
12
+ import { Transform, type Readable } from 'node:stream'
13
+ import fs from 'node:fs'
14
+ import jsonfs from 'fs-json-store'
15
+ import pino from 'pino'
16
+ import type { FastifyBaseLogger } from 'fastify'
17
+ import {
18
+ StorageError,
19
+ ResourceNotFoundError,
20
+ SpaceNotFoundError,
21
+ QuotaExceededError,
22
+ CountQuotaExceededError,
23
+ PayloadTooLargeError,
24
+ KeystoreStateConflictError,
25
+ KeyIdConflictError,
26
+ DuplicateRevocationError
27
+ } from '../errors.js'
28
+ import * as tar from 'tar-stream'
29
+ import YAML from 'yaml'
30
+ import {
31
+ DEFAULT_MAX_UPLOAD_BYTES,
32
+ DEFAULT_MAX_SPACES_PER_CONTROLLER,
33
+ DEFAULT_MAX_COLLECTIONS_PER_SPACE,
34
+ DEFAULT_MAX_RESOURCES_PER_SPACE,
35
+ QUOTA_USAGE_CACHE_TTL,
36
+ normalizeCountLimit
37
+ } from '../config.default.js'
38
+ import {
39
+ extractTarEntries,
40
+ buildImportPlan,
41
+ metaSidecarFileId
42
+ } from '../lib/importTar.js'
43
+ import { collectionPath, resourcePath } from '../lib/paths.js'
44
+ import {
45
+ encodeFilenameSegment,
46
+ fileNameFor,
47
+ parseResourceFileName
48
+ } from '../lib/resourceFileName.js'
49
+ import { sanitizeBackendRecord } from '../lib/backends.js'
50
+ import { backendUsageFields } from '../lib/backendUsage.js'
51
+ import { assertEncryptedWriteConforms } from '../lib/encryption.js'
52
+ import { encodeCursor, decodeCursor } from '../lib/cursor.js'
53
+ import {
54
+ buildExportManifest,
55
+ EXPORT_ENTRY_MTIME
56
+ } from '../lib/exportManifest.js'
57
+ import { revocationFileName } from '../lib/revocations.js'
58
+ import { KeyedMutex } from '../lib/keyedMutex.js'
59
+ import { isJson } from '../lib/isJson.js'
60
+ import { normalizeDescriptionWrite } from '../lib/collectionDescription.js'
61
+ import {
62
+ atomicWriteFile,
63
+ atomicCreateFile,
64
+ tempPathFor,
65
+ commitTempFile
66
+ } from '../lib/atomicFile.js'
67
+ import { DEFAULT_PAGE_SIZE, clampPageSize } from '../lib/pagination.js'
68
+ import {
69
+ runBlindedIndexQuery,
70
+ collectUniqueBlindedTerms,
71
+ assertNoUniqueBlindedConflict
72
+ } from '../lib/blindedIndex.js'
73
+ import type {
74
+ BlindedIndexQuery,
75
+ BlindedIndexQueryPage
76
+ } from '../lib/blindedIndex.js'
77
+ import {
78
+ assertWritePrecondition,
79
+ assertMetaWritePrecondition,
80
+ assertCollectionWritePrecondition
81
+ } from '../lib/preconditions.js'
82
+ import type {
83
+ SpaceDescription,
84
+ CollectionDescription,
85
+ CollectionSummary,
86
+ CollectionResourcesList,
87
+ ResourceResult,
88
+ ResourceMetadata,
89
+ ResourceMetadataCustom,
90
+ ResourceInput,
91
+ ImportStats,
92
+ PolicyDocument,
93
+ BackendDescriptor,
94
+ BackendUsage,
95
+ CollectionUsage,
96
+ StorageBackend,
97
+ StoredBackendRecord,
98
+ KeystoreConfig,
99
+ KmsKeyRecord,
100
+ RevocationRecord,
101
+ RevocationScope,
102
+ CapabilitySummary,
103
+ IDID
104
+ } from '../types.js'
105
+
106
+ const { Store: MetadataJsonStore } = jsonfs
107
+
108
+ const execFileAsync = promisify(execFile)
109
+
110
+ /**
111
+ * Compares two strings in code-unit order (the order the `<` / `>` operators
112
+ * use), returning -1 / 0 / 1. Keyset pagination sorts and seeks with the same
113
+ * operator, so the comparator must agree with `>` -- `localeCompare` can not.
114
+ */
115
+ function compareCodeUnits(left: string, right: string): number {
116
+ return left < right ? -1 : left > right ? 1 : 0
117
+ }
118
+
119
+ /**
120
+ * Silent logger used when no logger is injected into the backend, so the backend
121
+ * stays quiet by default (e.g. in `defaultBackend()` before `createApp` wires
122
+ * `fastify.log` in, or in tests).
123
+ */
124
+ const silentLogger: FastifyBaseLogger = pino({ level: 'silent' })
125
+
126
+ /**
127
+ * The on-disk shape of a Resource's metadata sidecar (`.meta.<resourceId>.json`,
128
+ * see `metaSidecarFileName`). Only the server-managed timestamps, the monotonic
129
+ * `version`, and the user-writable `custom` object are persisted; `contentType`
130
+ * / `size` are always derived from the stored representation, never duplicated
131
+ * here.
132
+ *
133
+ * `createdBy` is the DID of whoever created the Resource (spec "Resource
134
+ * Metadata Data Model"): an OPTIONAL server-managed property, absent when no
135
+ * creator was recorded.
136
+ *
137
+ * `version` is the per-Resource monotonic counter that backs the HTTP `ETag`
138
+ * strong validator (see `formatEtag`): it starts at 1 on first content write and
139
+ * increments on each subsequent content write. It is `undefined` only for a
140
+ * Resource written before versioning existed (a legacy sidecar), in which case
141
+ * the backend treats the current version as 0.
142
+ *
143
+ * `metaVersion` is the independent monotonic counter for the `/meta`
144
+ * sub-resource (spec V2 metadata versioning): it starts at 1 on first metadata
145
+ * write and increments on each subsequent one, backing the `/meta` ETag. It is
146
+ * kept separate from `version` so a metadata-only edit does not bump the content
147
+ * ETag (preserving the content-ETag contract), and is `undefined` until the
148
+ * first metadata write. A content write preserves it unchanged.
149
+ *
150
+ * `deleted` marks a **tombstone**: a soft delete that drops the content
151
+ * representation but keeps the sidecar so the change feed (replication) still
152
+ * surfaces it. A
153
+ * tombstone has no `r.<id>...` content file, so it is invisible to every normal
154
+ * read path (which gates on the content file via `_findFile`); only the
155
+ * (future) change feed reads it. `contentType` records the representation's
156
+ * last-known content-type, which the content filename no longer carries once it
157
+ * is gone -- present only on a tombstone (a live Resource derives its
158
+ * content-type from the filename).
159
+ */
160
+ interface MetaSidecar {
161
+ createdAt: string
162
+ updatedAt: string
163
+ // DID of the Resource's creator, set from the invoker of the first content
164
+ // write and thereafter preserved verbatim (as `createdAt` is), including
165
+ // across a tombstone. Server-managed: never sourced from the request body,
166
+ // and not reachable from the user-writable `custom`. Absent on a sidecar
167
+ // written before `createdBy` was recorded, or by a caller with no invoker.
168
+ createdBy?: IDID
169
+ version?: number
170
+ metaVersion?: number
171
+ // On a plaintext Collection `custom` is `{ name, tags }`; on an encrypted
172
+ // Collection it is the opaque encryption envelope (an arbitrary JSON object),
173
+ // stored verbatim -- the server never decrypts it.
174
+ custom?: ResourceMetadataCustom | Record<string, unknown>
175
+ // The client-declared key epoch the current content was encrypted under (the
176
+ // `key-epochs` feature). Stored opaquely: a content write sets it from the
177
+ // `WAS-Key-Epoch` header (clearing it when absent -- the new ciphertext's
178
+ // epoch is unknown), while a metadata write PRESERVES it unless the `/meta`
179
+ // body supplies a new value. The server never computes or verifies it.
180
+ epoch?: string
181
+ deleted?: boolean
182
+ contentType?: string
183
+ }
184
+
185
+ /**
186
+ * Builds the on-disk filename for a Resource's metadata sidecar:
187
+ * `.meta.<resourceId>.json`. A dot-file kept alongside the resource
188
+ * representation in the Collection dir (the same convention as `.policy.` /
189
+ * `.collection.`), holding the timestamps and user-writable `custom` object.
190
+ * @param resourceId {string}
191
+ * @returns {string}
192
+ */
193
+ export function metaSidecarFileName(resourceId: string): string {
194
+ return `.meta.${resourceId}.json`
195
+ }
196
+
197
+ /**
198
+ * Opens a read stream for a file, resolving once the stream has opened (and
199
+ * rejecting if it errors first).
200
+ * @param filePath {string}
201
+ * @param logger {FastifyBaseLogger}
202
+ * @returns {Promise<import('node:fs').ReadStream>}
203
+ */
204
+ async function openFileStream(
205
+ filePath: string,
206
+ logger: FastifyBaseLogger
207
+ ): Promise<fs.ReadStream> {
208
+ const resourceStream = fs.createReadStream(filePath)
209
+ return new Promise((resolve, reject) => {
210
+ resourceStream
211
+ .on('error', error => {
212
+ reject(new Error(`Error creating a read stream: ${error}`))
213
+ })
214
+ .on('open', () => {
215
+ logger.info(`GET -- Reading ${filePath}`)
216
+ resolve(resourceStream)
217
+ })
218
+ })
219
+ }
220
+
221
+ export class FileSystemBackend implements StorageBackend {
222
+ spacesDir: string
223
+ /**
224
+ * Root of the WebKMS keystore tree (`data/keystores/<localId>/`), a sibling
225
+ * of `spacesDir` -- the `/kms` facet is deliberately separable from Spaces
226
+ * (own route family, own storage tree).
227
+ */
228
+ keystoresDir: string
229
+ /**
230
+ * Root of the Space zcap revocation tree (`data/space-revocations/<spaceId>/`),
231
+ * a sibling of `spacesDir` rather than a subdirectory of each Space. Space
232
+ * revocations deliberately live OUTSIDE the Space's own directory because
233
+ * `listCollections` and `_countLiveResources` treat every subdirectory of a
234
+ * Space dir as a Collection -- a `revocations/` dir nested inside a Space would
235
+ * surface as a phantom Collection (and could collide with a real one), so it
236
+ * gets its own root.
237
+ */
238
+ spaceRevocationsDir: string
239
+ logger: FastifyBaseLogger
240
+ /**
241
+ * Per-Space storage capacity, in bytes (spec "Quotas"). `undefined` means no
242
+ * configured limit -- the backend reports an unlimited quota (state always
243
+ * `ok`) and skips write-path enforcement. A finite value drives the
244
+ * `near-limit` / `over-quota` state thresholds (see `reportUsage`) and is
245
+ * enforced on the write path: `writeResource` and `importSpace` reject writes
246
+ * that would push a Space over capacity with `QuotaExceededError` (507). The
247
+ * constructor normalizes a non-finite ctor option (`Infinity`) to `undefined`.
248
+ */
249
+ capacityBytes?: number
250
+ /**
251
+ * Largest single upload the backend accepts, in bytes (spec "Quotas", the
252
+ * `maxUploadBytes` constraint). `undefined` means no per-upload cap. Distinct
253
+ * from `capacityBytes` (the cumulative per-Space quota): a write larger than
254
+ * this cap is rejected with `PayloadTooLargeError` (413) even when the Space
255
+ * has ample headroom, while smaller writes still succeed. Advertised in quota
256
+ * reports under `constraints.maxUploadBytes` and enforced on `writeResource`.
257
+ * The constructor normalizes an unset ctor option to
258
+ * {@link DEFAULT_MAX_UPLOAD_BYTES} (a default-on cap) and a non-finite option
259
+ * (`Infinity`) to `undefined` (explicitly no cap).
260
+ */
261
+ maxUploadBytes?: number
262
+ /**
263
+ * Max Spaces a single controller may create (spec "Quotas", a default-on
264
+ * count quota). `undefined` means no cap. Enforced on the Space create path
265
+ * (`writeSpace`): a new Space whose `controller` already owns this many
266
+ * Spaces is rejected with `CountQuotaExceededError` (507); overwriting an
267
+ * existing Space never trips it. The constructor normalizes an unset ctor
268
+ * option to {@link DEFAULT_MAX_SPACES_PER_CONTROLLER} and a non-finite option
269
+ * (`Infinity`) to `undefined` (explicitly no cap). Soft under concurrency,
270
+ * like the byte quota.
271
+ */
272
+ maxSpacesPerController?: number
273
+ /**
274
+ * Max Collections a single Space may hold (spec "Quotas", a default-on count
275
+ * quota). `undefined` means no cap. Enforced on the Collection create path
276
+ * (`writeCollection`); overwriting an existing Collection description never
277
+ * trips it. Normalized like {@link maxSpacesPerController}.
278
+ */
279
+ maxCollectionsPerSpace?: number
280
+ /**
281
+ * Max live Resources a single Space may hold across all its Collections (spec
282
+ * "Quotas", a default-on count quota). `undefined` means no cap. Enforced on
283
+ * the Resource create path (`writeResource`); a tombstone does not count, and
284
+ * a write over an existing live Resource never trips it. Normalized like
285
+ * {@link maxSpacesPerController}.
286
+ */
287
+ maxResourcesPerSpace?: number
288
+
289
+ /**
290
+ * Per-Resource write serialization (the `conditional-writes` feature). A
291
+ * content write or delete that carries a precondition reads the current
292
+ * `version`, evaluates `If-Match` / `If-None-Match`, and writes -- all under
293
+ * this lock, keyed per Resource -- so two concurrent writers cannot both
294
+ * observe the same prior version and both succeed. Single-instance only.
295
+ */
296
+ private _writeMutex = new KeyedMutex()
297
+
298
+ /**
299
+ * Per-Space usage totals for the write-path quota pre-flight, so
300
+ * `_assertSpaceHeadroom` does not spawn `du` (a whole-Space tree walk) on
301
+ * every resource write. Entries live `QUOTA_USAGE_CACHE_TTL` ms; each
302
+ * accepted write adds its incoming bytes to the cached total, and deletes
303
+ * invalidate the Space's entry. Quota reports (`reportUsage`) always
304
+ * re-measure. Single-instance only, like `_writeMutex`.
305
+ */
306
+ private _usageCache = new Map<
307
+ string,
308
+ { usageBytes: number; expiresAt: number }
309
+ >()
310
+
311
+ constructor({
312
+ dataDir,
313
+ logger,
314
+ capacityBytes,
315
+ maxUploadBytes,
316
+ maxSpacesPerController,
317
+ maxCollectionsPerSpace,
318
+ maxResourcesPerSpace
319
+ }: {
320
+ dataDir: string
321
+ logger?: FastifyBaseLogger
322
+ capacityBytes?: number
323
+ maxUploadBytes?: number
324
+ maxSpacesPerController?: number
325
+ maxCollectionsPerSpace?: number
326
+ maxResourcesPerSpace?: number
327
+ }) {
328
+ this.spacesDir = path.join(dataDir, 'spaces')
329
+ this.keystoresDir = path.join(dataDir, 'keystores')
330
+ // A sibling of spacesDir, NOT nested under each Space: a `revocations/` dir
331
+ // inside a Space dir would be mistaken for a Collection (see the
332
+ // `spaceRevocationsDir` property doc).
333
+ this.spaceRevocationsDir = path.join(dataDir, 'space-revocations')
334
+ this.logger = logger ?? silentLogger
335
+ // A non-finite `capacityBytes` (`Infinity` from an explicit `unlimited`)
336
+ // behaves exactly like unset inside the backend: no configured limit.
337
+ this.capacityBytes =
338
+ capacityBytes !== undefined && Number.isFinite(capacityBytes)
339
+ ? capacityBytes
340
+ : undefined
341
+ // Normalize the per-upload cap so every downstream guard keeps its plain
342
+ // `!== undefined` test: an unset option applies the default-on cap; a
343
+ // non-finite option (`Infinity`) means explicitly no cap (the streaming
344
+ // write path this backend uses makes an unbounded upload safe).
345
+ this.maxUploadBytes =
346
+ maxUploadBytes === undefined
347
+ ? DEFAULT_MAX_UPLOAD_BYTES
348
+ : Number.isFinite(maxUploadBytes)
349
+ ? maxUploadBytes
350
+ : undefined
351
+ // Count quotas normalize like `maxUploadBytes`: an unset option applies the
352
+ // default-on limit, a non-finite option (`Infinity`) means explicitly no
353
+ // cap, so every guard keeps its plain `!== undefined` test.
354
+ this.maxSpacesPerController = normalizeCountLimit(
355
+ maxSpacesPerController,
356
+ DEFAULT_MAX_SPACES_PER_CONTROLLER
357
+ )
358
+ this.maxCollectionsPerSpace = normalizeCountLimit(
359
+ maxCollectionsPerSpace,
360
+ DEFAULT_MAX_COLLECTIONS_PER_SPACE
361
+ )
362
+ this.maxResourcesPerSpace = normalizeCountLimit(
363
+ maxResourcesPerSpace,
364
+ DEFAULT_MAX_RESOURCES_PER_SPACE
365
+ )
366
+ }
367
+
368
+ /**
369
+ * Self-description advertised at `GET /space/:spaceId/backends`. The
370
+ * filesystem backend is the single server-configured default: it stores both
371
+ * JSON documents and binary blobs on disk, so its data survives restarts.
372
+ *
373
+ * It advertises the `conditional-writes` affordance: it exposes a per-Resource
374
+ * `version` as an HTTP `ETag` validator and honors `If-Match` / `If-None-Match`
375
+ * write preconditions atomically (returning `412 precondition-failed` on a
376
+ * mismatch). The remaining `features` vocabulary token (`chunked-streams`)
377
+ * is not implemented yet and is added when it lands.
378
+ * (Client-side encryption is deliberately not a backend feature: encrypted
379
+ * documents are opaque client-encrypted JSON this backend already stores
380
+ * faithfully, with no server cooperation.)
381
+ * @returns {Required<Omit<BackendDescriptor, 'provider' | 'connection'>>}
382
+ */
383
+ describe(): Required<Omit<BackendDescriptor, 'provider' | 'connection'>> {
384
+ // The wire type only REQUIRES `id`; this backend always populates every
385
+ // field except the `external`-only `provider` / `connection` (the default
386
+ // backend is server-managed), so its return is the stricter
387
+ // `Required<Omit<..., 'provider' | 'connection'>>` (which also lets
388
+ // `reportUsage` read a non-optional `managedBy` off `describe()`).
389
+ return {
390
+ id: 'default',
391
+ name: 'Server Filesystem',
392
+ managedBy: 'server',
393
+ storageMode: ['document', 'blob'],
394
+ persistence: 'durable',
395
+ // `changes-query`: serves the `changes` profile of the reserved `query`
396
+ // endpoint -- the replication change feed (`changesSince`).
397
+ // `blinded-index-query`: serves the `blinded-index` profile -- EDV
398
+ // blinded-attribute queries (`queryByBlindedIndex`).
399
+ // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
400
+ // keys on the `encryption` marker, a client-declared `epoch` stamp on
401
+ // Resources, and conditional (`If-Match`) Collection Description writes.
402
+ features: [
403
+ 'conditional-writes',
404
+ 'changes-query',
405
+ 'blinded-index-query',
406
+ 'key-epochs'
407
+ ]
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Measures disk usage under the Space dir with `du`, returning the grand total
413
+ * and a per-Collection breakdown in one pass. `du -d 1 -B 1` (GNU coreutils)
414
+ * reports each immediate subdirectory (one per Collection) plus the Space dir
415
+ * itself (the total, which also covers top-level Space files such as the
416
+ * `.space.` / `.policy.` documents), all in bytes. An absent Space dir (ENOENT
417
+ * before the dir is provisioned) reports zero usage rather than throwing.
418
+ * @param spaceDir {string}
419
+ * @returns {Promise<{ total: number, byCollection: CollectionUsage[] }>}
420
+ */
421
+ async _diskUsage(
422
+ spaceDir: string
423
+ ): Promise<{ total: number; byCollection: CollectionUsage[] }> {
424
+ let stdout: string
425
+ try {
426
+ ;({ stdout } = await execFileAsync('du', [
427
+ '-d',
428
+ '1',
429
+ '-B',
430
+ '1',
431
+ spaceDir
432
+ ]))
433
+ } catch (err) {
434
+ // `du` exits non-zero (with an ENOENT-style stderr) when the dir is
435
+ // absent; treat that as zero usage. Anything else is a real failure.
436
+ if (
437
+ (err as NodeJS.ErrnoException).code === 'ENOENT' ||
438
+ /No such file or directory/.test((err as Error).message)
439
+ ) {
440
+ return { total: 0, byCollection: [] }
441
+ }
442
+ throw new StorageError({ cause: err as Error })
443
+ }
444
+
445
+ const rootResolved = path.resolve(spaceDir)
446
+ let total = 0
447
+ const byCollection: CollectionUsage[] = []
448
+ for (const line of stdout.split('\n')) {
449
+ if (!line) {
450
+ continue
451
+ }
452
+ const tab = line.indexOf('\t')
453
+ const usageBytes = Number(line.slice(0, tab))
454
+ const entryPath = line.slice(tab + 1)
455
+ if (path.resolve(entryPath) === rootResolved) {
456
+ // The summary line for the Space dir itself is the grand total.
457
+ total = usageBytes
458
+ } else {
459
+ // Every immediate subdirectory is a Collection (see `listCollections`).
460
+ byCollection.push({ id: path.basename(entryPath), usageBytes })
461
+ }
462
+ }
463
+ byCollection.sort((a, b) => a.id.localeCompare(b.id))
464
+ return { total, byCollection }
465
+ }
466
+
467
+ /**
468
+ * Measures the bytes this Space consumes on disk for the Space Quota report
469
+ * (spec "Quotas"). `usageBytes` is the `du` total under the Space dir
470
+ * (Collection dirs plus top-level Space files); `usageByCollection` breaks the
471
+ * per-Collection totals out (they sum to slightly less than `usageBytes`,
472
+ * since the Space-level files belong to no Collection).
473
+ *
474
+ * The per-Collection `usageByCollection` breakdown is included only when
475
+ * `includeCollections` is set -- the spec's `?include=collections` opt-in (see
476
+ * the `quotas` handler, which now tolerates the query string via the
477
+ * `allowTargetQuery` ZCap path). On the filesystem the breakdown is free (the
478
+ * one `du -d 1` pass yields it alongside the total), but it is still omitted by
479
+ * default to keep the hot-path payload lean and match the wire contract.
480
+ *
481
+ * `state` / `restrictedActions` derive from usage vs `capacityBytes`: an
482
+ * unlimited backend is always `ok`; a finite capacity yields `near-limit` at
483
+ * `QUOTA_NEAR_LIMIT_FRACTION` of capacity and `over-quota` (with reads/deletes
484
+ * still allowed, but `POST`/`PUT` restricted) at or above full.
485
+ * @param options {object}
486
+ * @param options.spaceId {string}
487
+ * @param [options.includeCollections] {boolean} include the per-Collection
488
+ * breakdown (spec `?include=collections`)
489
+ * @returns {Promise<BackendUsage>}
490
+ */
491
+ async reportUsage({
492
+ spaceId,
493
+ includeCollections = false
494
+ }: {
495
+ spaceId: string
496
+ includeCollections?: boolean
497
+ }): Promise<BackendUsage> {
498
+ const spaceDir = this._spaceDir(spaceId)
499
+ const measuredAt = new Date().toISOString()
500
+
501
+ const { total: usageBytes, byCollection: usageByCollection } =
502
+ await this._diskUsage(spaceDir)
503
+
504
+ return {
505
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes: usageBytes }),
506
+ measuredAt,
507
+ ...(includeCollections && { usageByCollection })
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Measures the bytes a single Collection consumes on disk for the
513
+ * per-Collection Quota report (spec "Quotas", `GET /space/{id}/{cid}/quota`).
514
+ * `usageBytes` is scoped to the Collection (its slice of the one-pass
515
+ * `_diskUsage` breakdown; zero if the Collection dir is empty or absent),
516
+ * while `state` / `limit` / `restrictedActions` describe the backend's overall
517
+ * condition (derived from the Space total -- the quota is a per-backend limit,
518
+ * not per-Collection). The per-Collection `usageByCollection` breakdown is
519
+ * omitted (a single Collection is the whole report).
520
+ * @param options {object}
521
+ * @param options.spaceId {string}
522
+ * @param options.collectionId {string}
523
+ * @returns {Promise<BackendUsage>}
524
+ */
525
+ async reportCollectionUsage({
526
+ spaceId,
527
+ collectionId
528
+ }: {
529
+ spaceId: string
530
+ collectionId: string
531
+ }): Promise<BackendUsage> {
532
+ const spaceDir = this._spaceDir(spaceId)
533
+ const measuredAt = new Date().toISOString()
534
+
535
+ const { total: spaceTotalBytes, byCollection } =
536
+ await this._diskUsage(spaceDir)
537
+ const usageBytes =
538
+ byCollection.find(entry => entry.id === collectionId)?.usageBytes ?? 0
539
+
540
+ return {
541
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes }),
542
+ measuredAt
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Builds the backend-identity and condition fields shared by the Space and
548
+ * per-Collection quota reports. `usageBytes` is what the report shows (the
549
+ * Space total or a single Collection's slice); `spaceTotalBytes` drives the
550
+ * `state` / `restrictedActions`, which are backend-wide (the quota is a
551
+ * per-Space limit) and so always measured against the Space total. The
552
+ * `constraints.maxUploadBytes` cap is advertised when configured.
553
+ * @param options {object}
554
+ * @param options.usageBytes {number} the usage figure to report
555
+ * @param options.spaceTotalBytes {number} the Space total, for state
556
+ * @returns {Omit<BackendUsage, 'measuredAt' | 'usageByCollection'>}
557
+ */
558
+ _backendUsageFields({
559
+ usageBytes,
560
+ spaceTotalBytes
561
+ }: {
562
+ usageBytes: number
563
+ spaceTotalBytes: number
564
+ }): Omit<BackendUsage, 'measuredAt' | 'usageByCollection'> {
565
+ const { id, name, managedBy } = this.describe()
566
+ return backendUsageFields({
567
+ usageBytes,
568
+ spaceTotalBytes,
569
+ capacityBytes: this.capacityBytes,
570
+ maxUploadBytes: this.maxUploadBytes,
571
+ id,
572
+ name,
573
+ managedBy
574
+ })
575
+ }
576
+
577
+ /**
578
+ * Quota pre-flight for the write path (spec "Quotas"): measures the Space's
579
+ * current on-disk usage and returns the remaining headroom in bytes against
580
+ * `capacityBytes`. Throws `QuotaExceededError` (507) when the Space is already
581
+ * at or over capacity, or when a known `incomingBytes` would not fit. Callers
582
+ * pass the configured `capacityBytes` explicitly (an unlimited backend skips
583
+ * enforcement entirely and never calls this).
584
+ *
585
+ * This is a soft limit under concurrency: two simultaneous writes can each pass
586
+ * against the same usage snapshot and jointly overshoot. The per-write
587
+ * streaming guard (`_quotaGuard`) still bounds each individual write.
588
+ *
589
+ * The `du` measurement (a whole-Space tree walk) is cached per Space for
590
+ * `QUOTA_USAGE_CACHE_TTL` ms (see `_usageCache`): between re-measurements
591
+ * each accepted write's `incomingBytes` is added to the cached total, so a
592
+ * burst of writes costs one tree walk, not one per write.
593
+ * @param options {object}
594
+ * @param options.spaceId {string}
595
+ * @param options.capacityBytes {number} the configured per-Space limit
596
+ * @param [options.incomingBytes] {number} known size of the pending write
597
+ * @returns {Promise<number>} remaining headroom in bytes
598
+ */
599
+ async _assertSpaceHeadroom({
600
+ spaceId,
601
+ capacityBytes,
602
+ incomingBytes = 0
603
+ }: {
604
+ spaceId: string
605
+ capacityBytes: number
606
+ incomingBytes?: number
607
+ }): Promise<number> {
608
+ let cached = this._usageCache.get(spaceId)
609
+ if (!cached || cached.expiresAt <= Date.now()) {
610
+ const { total } = await this._diskUsage(this._spaceDir(spaceId))
611
+ cached = {
612
+ usageBytes: total,
613
+ expiresAt: Date.now() + QUOTA_USAGE_CACHE_TTL
614
+ }
615
+ this._usageCache.set(spaceId, cached)
616
+ }
617
+ const headroom = capacityBytes - cached.usageBytes
618
+ if (headroom <= 0 || incomingBytes > headroom) {
619
+ throw new QuotaExceededError({ spaceId, capacityBytes })
620
+ }
621
+ // Count the accepted write against the cached total so writes within the
622
+ // TTL accumulate rather than each re-admitting against the same snapshot.
623
+ cached.usageBytes += incomingBytes
624
+ return headroom
625
+ }
626
+
627
+ /**
628
+ * A pass-through `Transform` that counts the bytes flowing through it and
629
+ * aborts the pipeline with `QuotaExceededError` (507) once the cumulative total
630
+ * would exceed `headroomBytes`. Hard-caps a streamed blob write whose size is
631
+ * not known up front (so the pre-flight check alone cannot catch it), e.g. a
632
+ * multipart upload or a body without `Content-Length`.
633
+ * @param options {object}
634
+ * @param options.spaceId {string}
635
+ * @param options.capacityBytes {number} the configured per-Space limit
636
+ * @param options.headroomBytes {number} max bytes this write may add
637
+ * @returns {Transform}
638
+ */
639
+ _quotaGuard({
640
+ spaceId,
641
+ capacityBytes,
642
+ headroomBytes
643
+ }: {
644
+ spaceId: string
645
+ capacityBytes: number
646
+ headroomBytes: number
647
+ }): Transform {
648
+ let written = 0
649
+ return new Transform({
650
+ transform(chunk, _encoding, callback) {
651
+ written += chunk.length
652
+ if (written > headroomBytes) {
653
+ callback(new QuotaExceededError({ spaceId, capacityBytes }))
654
+ return
655
+ }
656
+ callback(null, chunk)
657
+ }
658
+ })
659
+ }
660
+
661
+ /**
662
+ * A pass-through `Transform` that counts the bytes flowing through it and
663
+ * aborts the pipeline with `PayloadTooLargeError` (413) once the cumulative
664
+ * total exceeds `maxUploadBytes`. Caps a single streamed upload whose size is
665
+ * not known up front (e.g. a multipart part or a body without
666
+ * `Content-Length`), independently of the cumulative Space quota.
667
+ * @param options {object}
668
+ * @param options.maxUploadBytes {number} the per-upload cap in bytes
669
+ * @returns {Transform}
670
+ */
671
+ _uploadCapGuard({ maxUploadBytes }: { maxUploadBytes: number }): Transform {
672
+ const backendId = this.describe().id
673
+ let written = 0
674
+ return new Transform({
675
+ transform(chunk, _encoding, callback) {
676
+ written += chunk.length
677
+ if (written > maxUploadBytes) {
678
+ callback(new PayloadTooLargeError({ maxUploadBytes, backendId }))
679
+ return
680
+ }
681
+ callback(null, chunk)
682
+ }
683
+ })
684
+ }
685
+
686
+ /**
687
+ * Defense in depth: asserts that a built path stays within the given storage
688
+ * root (`spacesDir` by default; `keystoresDir` for the keystore tree), so a
689
+ * malformed id that somehow slips past request-layer validation can never
690
+ * escape it (path traversal). The request and tar-import layers reject such
691
+ * ids first; this is the last line of defense.
692
+ * @param targetPath {string}
693
+ * @param [rootDir] {string} the containing root; defaults to `spacesDir`
694
+ * @returns {void}
695
+ */
696
+ _assertContained(targetPath: string, rootDir: string = this.spacesDir): void {
697
+ const root = path.resolve(rootDir)
698
+ const resolved = path.resolve(targetPath)
699
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
700
+ throw new StorageError({
701
+ cause: new Error(
702
+ `Resolved path "${resolved}" escapes the storage root.`
703
+ )
704
+ })
705
+ }
706
+ }
707
+
708
+ _spaceDir(spaceId: string): string {
709
+ const spaceDir = path.join(this.spacesDir, spaceId)
710
+ this._assertContained(spaceDir)
711
+ return spaceDir
712
+ }
713
+
714
+ /**
715
+ * The directory holding one Space's zcap revocation records, under the
716
+ * sibling `spaceRevocationsDir` root (see that property's doc for why it is
717
+ * not inside the Space dir), guarded against escaping it.
718
+ * @param spaceId {string}
719
+ * @returns {string}
720
+ */
721
+ _spaceRevocationDir(spaceId: string): string {
722
+ const dir = path.join(this.spaceRevocationsDir, spaceId)
723
+ this._assertContained(dir, this.spaceRevocationsDir)
724
+ return dir
725
+ }
726
+
727
+ _collectionDir({
728
+ spaceId,
729
+ collectionId
730
+ }: {
731
+ spaceId: string
732
+ collectionId: string
733
+ }): string {
734
+ const collectionDir = path.join(this._spaceDir(spaceId), collectionId)
735
+ this._assertContained(collectionDir)
736
+ return collectionDir
737
+ }
738
+
739
+ /**
740
+ * @param options {object}
741
+ * @param options.spaceId {string}
742
+ * @returns {Promise<string>} Created space storage directory path.
743
+ */
744
+ async _ensureSpaceDir({ spaceId }: { spaceId: string }): Promise<string> {
745
+ const spaceDir = this._spaceDir(spaceId)
746
+ // Ensure the parent spaces/ directory exists (the dataDir may be brand new,
747
+ // e.g. a per-suite temp dir); the space dir itself is created non-recursively
748
+ // below so its EEXIST case can be detected.
749
+ await mkdir(this.spacesDir, { recursive: true })
750
+ try {
751
+ await mkdir(spaceDir)
752
+ } catch (err) {
753
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
754
+ this.logger.info(`Space "${spaceId}" already exists, overwriting.`)
755
+ } else {
756
+ throw new StorageError({ cause: err as Error })
757
+ }
758
+ }
759
+ return spaceDir
760
+ }
761
+
762
+ /**
763
+ * @param options {object}
764
+ * @param options.spaceId {string}
765
+ * @param options.collectionId {string}
766
+ * @returns {Promise<string>} Created collection storage directory path.
767
+ */
768
+ async _ensureCollectionDir({
769
+ spaceId,
770
+ collectionId
771
+ }: {
772
+ spaceId: string
773
+ collectionId: string
774
+ }): Promise<string> {
775
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
776
+ try {
777
+ await mkdir(collectionDir)
778
+ } catch (err) {
779
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
780
+ this.logger.info(
781
+ `Collection "${collectionId}" already exists, overwriting.`
782
+ )
783
+ } else {
784
+ this.logger.error({ err }, 'Error creating directory')
785
+ throw err // http 500
786
+ }
787
+ }
788
+ return collectionDir
789
+ }
790
+
791
+ /**
792
+ * Lists every on-disk file belonging to a single Resource: the
793
+ * representation(s) whose name starts with `r.<encodedResourceId>.` in the
794
+ * Collection dir. The trailing `.` anchors to the filename's segment boundary
795
+ * (`r.<encodedResourceId>.<encodedType>.<ext>`) so a resourceId that is a
796
+ * prefix of another (e.g. `note` vs `notebook`) does not match the longer one;
797
+ * the id is dot-escaped to match the stored name (see `fileNameFor`). A Resource
798
+ * normally has a single current representation, so this usually returns one
799
+ * path; it returns more only transiently while a prior representation under a
800
+ * different content-type is being pruned. An absent Collection dir resolves an
801
+ * empty list (it holds no such files).
802
+ * @param options {object}
803
+ * @param options.collectionDir {string}
804
+ * @param options.resourceId {string}
805
+ * @returns {Promise<string[]>} full paths, in directory order
806
+ */
807
+ async _resourceFilesFor({
808
+ collectionDir,
809
+ resourceId
810
+ }: {
811
+ collectionDir: string
812
+ resourceId: string
813
+ }): Promise<string[]> {
814
+ const prefix = `r.${encodeFilenameSegment(resourceId)}.`
815
+ let entries: fs.Dirent[]
816
+ try {
817
+ entries = await fs.promises.readdir(collectionDir, {
818
+ withFileTypes: true
819
+ })
820
+ } catch (err) {
821
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
822
+ return []
823
+ }
824
+ throw err
825
+ }
826
+ return entries
827
+ .filter(entry => entry.isFile() && entry.name.startsWith(prefix))
828
+ .map(entry => path.join(collectionDir, entry.name))
829
+ }
830
+
831
+ /**
832
+ * @param options {object}
833
+ * @param options.collectionDir {string}
834
+ * @param options.resourceId {string}
835
+ * @returns {Promise<string|undefined>} First matching resource file path.
836
+ */
837
+ async _findFile({
838
+ collectionDir,
839
+ resourceId
840
+ }: {
841
+ collectionDir: string
842
+ resourceId: string
843
+ }): Promise<string | undefined> {
844
+ const [filePath] = await this._resourceFilesFor({
845
+ collectionDir,
846
+ resourceId
847
+ })
848
+ return filePath
849
+ }
850
+
851
+ // Spaces
852
+
853
+ /**
854
+ * @param options {object}
855
+ * @param options.spaceId {string}
856
+ * @param options.spaceDescription {SpaceDescription}
857
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
858
+ * Space's `createdBy` on first write only
859
+ * @returns {Promise<void>} Resolved value is implementation-defined and ignored.
860
+ */
861
+ async writeSpace({
862
+ spaceId,
863
+ spaceDescription,
864
+ createdBy
865
+ }: {
866
+ spaceId: string
867
+ spaceDescription: SpaceDescription
868
+ createdBy?: IDID
869
+ }): Promise<void> {
870
+ // Prior description, read once and reused below: for the create-path quota
871
+ // check (a brand-new Space has none yet) and to resolve `createdBy`.
872
+ const prior = await this.getSpaceDescription({ spaceId })
873
+
874
+ // Count quota (create path only): a brand-new Space (no description yet)
875
+ // must not push its controller past `maxSpacesPerController`. Overwriting an
876
+ // existing Space's description never trips it. Space creation is rare, so
877
+ // the O(all Spaces) enumeration is acceptable; soft under concurrency, like
878
+ // the byte quota.
879
+ if (this.maxSpacesPerController !== undefined && !prior) {
880
+ const { controller } = spaceDescription
881
+ const spaces = await this.listSpaces()
882
+ const owned = spaces.filter(
883
+ space => space.controller === controller
884
+ ).length
885
+ if (owned >= this.maxSpacesPerController) {
886
+ throw new CountQuotaExceededError({
887
+ scope: 'Spaces per controller',
888
+ limit: this.maxSpacesPerController
889
+ })
890
+ }
891
+ }
892
+
893
+ // `createdBy` names the Space's creator, not its last writer: taken from
894
+ // this write's invoker only when this write CREATES the description, and
895
+ // preserved verbatim afterward -- including preserved-as-absent, so a Space
896
+ // created with no invoker (a token-provisioned create) never has a later
897
+ // writer backfilled into it as its creator. The client-supplied
898
+ // `spaceDescription` is wire input and may carry its own `createdBy` --
899
+ // discard it, since the server alone is authoritative for this field.
900
+ const { createdBy: _suppliedCreatedBy, ...rest } = spaceDescription
901
+ const creator = prior ? prior.createdBy : createdBy
902
+
903
+ const spaceDir = await this._ensureSpaceDir({ spaceId })
904
+ const filename = `.space.${spaceId}.json`
905
+ // Durable full replacement: `MetadataJsonStore.read` parses plain JSON, so
906
+ // an atomically-written JSON string round-trips through the same read path.
907
+ await atomicWriteFile({
908
+ filePath: path.join(spaceDir, filename),
909
+ data: JSON.stringify({
910
+ ...rest,
911
+ ...(creator !== undefined && { createdBy: creator })
912
+ })
913
+ })
914
+ }
915
+
916
+ /**
917
+ * @param options {object}
918
+ * @param options.spaceId {string}
919
+ * @returns {Promise<SpaceDescription|undefined>}
920
+ * Resolves falsy when the Space does not exist (must not throw).
921
+ */
922
+ async getSpaceDescription({
923
+ spaceId
924
+ }: {
925
+ spaceId: string
926
+ }): Promise<SpaceDescription | undefined> {
927
+ const spaceDir = this._spaceDir(spaceId)
928
+ const filename = `.space.${spaceId}.json`
929
+ const metaStore = new MetadataJsonStore<SpaceDescription>({
930
+ file: path.join(spaceDir, filename)
931
+ })
932
+ return await metaStore.read()
933
+ }
934
+
935
+ /**
936
+ * Removes a Space and everything scoped to it. The Space's zcap revocations
937
+ * live in a sibling root (`spaceRevocationsDir`), not under the Space dir, so
938
+ * they are removed explicitly here rather than falling out of the Space dir
939
+ * rm.
940
+ * @param options {object}
941
+ * @param options.spaceId {string}
942
+ * @returns {Promise<void>}
943
+ */
944
+ async deleteSpace({ spaceId }: { spaceId: string }): Promise<void> {
945
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
946
+ this._usageCache.delete(spaceId)
947
+ // Remove this Space's revocations, which sit outside the Space dir.
948
+ await rm(this._spaceRevocationDir(spaceId), {
949
+ recursive: true,
950
+ force: true
951
+ })
952
+ // `force: true` keeps delete idempotent (the `StorageBackend` contract):
953
+ // removing an absent Space resolves rather than rejecting with `ENOENT`.
954
+ return await rm(this._spaceDir(spaceId), { recursive: true, force: true })
955
+ }
956
+
957
+ /**
958
+ * Enumerates every Space stored on this backend (each immediate subdirectory
959
+ * of the spaces root), sorted by Space id. An absent spaces root (nothing
960
+ * stored yet) resolves an empty list, not an error; a directory without a
961
+ * readable description file (e.g. a partially deleted Space) is skipped.
962
+ * @returns {Promise<SpaceDescription[]>}
963
+ */
964
+ async listSpaces(): Promise<SpaceDescription[]> {
965
+ let rootEntries: fs.Dirent[]
966
+ try {
967
+ rootEntries = await fs.promises.readdir(this.spacesDir, {
968
+ withFileTypes: true
969
+ })
970
+ } catch (err) {
971
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
972
+ return []
973
+ }
974
+ throw new StorageError({ cause: err as Error })
975
+ }
976
+ const spaceEntries = rootEntries
977
+ .filter(entry => entry.isDirectory())
978
+ .sort((a, b) => a.name.localeCompare(b.name))
979
+ const spaces: SpaceDescription[] = []
980
+ for (const entry of spaceEntries) {
981
+ const spaceDescription = await this.getSpaceDescription({
982
+ spaceId: entry.name
983
+ })
984
+ if (spaceDescription) {
985
+ spaces.push(spaceDescription)
986
+ }
987
+ }
988
+ return spaces
989
+ }
990
+
991
+ /**
992
+ * Counts the live Resources across every Collection of a Space, for the
993
+ * Resource count quota (`maxResourcesPerSpace`). Enumerates each Collection
994
+ * dir and counts distinct Resource ids that have a live representation file
995
+ * (`r.<id>...`); a tombstone (a `.meta.` sidecar with no `r.` file) does not
996
+ * count. An absent Space dir counts zero (not yet provisioned). Soft under
997
+ * concurrency, like the byte quota -- measured at check time by enumeration.
998
+ * @param options {object}
999
+ * @param options.spaceId {string}
1000
+ * @returns {Promise<number>} the number of live Resources
1001
+ */
1002
+ async _countLiveResources({ spaceId }: { spaceId: string }): Promise<number> {
1003
+ const spaceDir = this._spaceDir(spaceId)
1004
+ let spaceEntries: fs.Dirent[]
1005
+ try {
1006
+ spaceEntries = await fs.promises.readdir(spaceDir, {
1007
+ withFileTypes: true
1008
+ })
1009
+ } catch (err) {
1010
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
1011
+ return 0
1012
+ }
1013
+ throw new StorageError({ cause: err as Error })
1014
+ }
1015
+ let count = 0
1016
+ for (const entry of spaceEntries) {
1017
+ if (!entry.isDirectory()) {
1018
+ continue
1019
+ }
1020
+ const collectionDir = path.join(spaceDir, entry.name)
1021
+ const files = await fs.promises.readdir(collectionDir)
1022
+ // A live Resource has one representation file; count distinct ids so a
1023
+ // transient second representation (mid content-type swap) is not
1024
+ // double-counted.
1025
+ const liveIds = new Set<string>()
1026
+ for (const fileName of files) {
1027
+ if (!fileName.startsWith('r.')) {
1028
+ continue
1029
+ }
1030
+ liveIds.add(parseResourceFileName(fileName).resourceId)
1031
+ }
1032
+ count += liveIds.size
1033
+ }
1034
+ return count
1035
+ }
1036
+
1037
+ /**
1038
+ * @param options {object}
1039
+ * @param options.spaceId {string}
1040
+ * @returns {Promise<CollectionSummary[]>}
1041
+ */
1042
+ async listCollections({
1043
+ spaceId
1044
+ }: {
1045
+ spaceId: string
1046
+ }): Promise<CollectionSummary[]> {
1047
+ const spaceDir = this._spaceDir(spaceId)
1048
+ const spaceEntries = await fs.promises.readdir(spaceDir, {
1049
+ withFileTypes: true
1050
+ })
1051
+ const collectionEntries = spaceEntries
1052
+ .filter(entry => entry.isDirectory())
1053
+ .sort((a, b) => a.name.localeCompare(b.name))
1054
+ const collections: CollectionSummary[] = []
1055
+ for (const entry of collectionEntries) {
1056
+ const collectionDescription = await this.getCollectionDescription({
1057
+ spaceId,
1058
+ collectionId: entry.name
1059
+ })
1060
+ collections.push({
1061
+ id: entry.name,
1062
+ url: collectionPath({ spaceId, collectionId: entry.name }),
1063
+ // `name` is optional on the wire type; a stored Collection normally has
1064
+ // one (create defaults it to the id). Fall back to the dir name for a
1065
+ // description-less directory too (e.g. one left by a policy write to a
1066
+ // never-created Collection) -- reading `.name` off `undefined` here would
1067
+ // 500 the entire Space listing.
1068
+ name: collectionDescription?.name ?? entry.name
1069
+ })
1070
+ }
1071
+
1072
+ return collections
1073
+ }
1074
+
1075
+ /**
1076
+ * @param options {object}
1077
+ * @param options.spaceId {string}
1078
+ * @returns {Promise<Readable>} tar-stream pack
1079
+ */
1080
+ async exportSpace({ spaceId }: { spaceId: string }): Promise<Readable> {
1081
+ const spaceDescription = await this.getSpaceDescription({ spaceId })
1082
+ if (!spaceDescription) {
1083
+ throw new SpaceNotFoundError({ requestName: 'Export Space' })
1084
+ }
1085
+
1086
+ const sourceSpaceDir = this._spaceDir(spaceId)
1087
+ const spaceEntries = (
1088
+ await fs.promises.readdir(sourceSpaceDir, { withFileTypes: true })
1089
+ ).filter(
1090
+ // Backend registration records (.backend.<id>.json) hold plaintext
1091
+ // connection material and do NOT travel in a Space export; after import
1092
+ // the user re-registers (re-runs consent + POST /backends). importSpace
1093
+ // ignores unrecognized space-level files, so this is symmetric.
1094
+ entry => !(entry.isFile() && entry.name.startsWith('.backend.'))
1095
+ )
1096
+ spaceEntries.sort((a, b) => a.name.localeCompare(b.name))
1097
+
1098
+ const collectionEntriesByDir: Record<string, typeof spaceEntries> = {}
1099
+ for (const entry of spaceEntries) {
1100
+ if (!entry.isDirectory()) {
1101
+ continue
1102
+ }
1103
+ const entries = await fs.promises.readdir(
1104
+ path.join(sourceSpaceDir, entry.name),
1105
+ { withFileTypes: true }
1106
+ )
1107
+ collectionEntriesByDir[entry.name] = entries
1108
+ .filter(e => e.isFile())
1109
+ .sort((a, b) => a.name.localeCompare(b.name))
1110
+ }
1111
+
1112
+ // Space-scoped zcap revocations travel with the export. They live in the
1113
+ // sibling `spaceRevocationsDir` root (see that property's doc), so the
1114
+ // Space-dir walk above never sees them; they pack under a top-level
1115
+ // `revocations/` dir -- outside `space/<spaceId>/`, where a subdirectory
1116
+ // would read as a Collection on import.
1117
+ const revocationsDir = this._spaceRevocationDir(spaceId)
1118
+ let revocationFiles: string[] = []
1119
+ try {
1120
+ revocationFiles = (
1121
+ await fs.promises.readdir(revocationsDir, { withFileTypes: true })
1122
+ )
1123
+ .filter(entry => entry.isFile() && entry.name.endsWith('.json'))
1124
+ .map(entry => entry.name)
1125
+ .sort((a, b) => a.localeCompare(b))
1126
+ } catch (err) {
1127
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
1128
+ throw new StorageError({ cause: err as Error })
1129
+ }
1130
+ }
1131
+
1132
+ const manifest = buildExportManifest({
1133
+ spaceId,
1134
+ entries: spaceEntries.map(entry =>
1135
+ entry.isDirectory()
1136
+ ? {
1137
+ name: entry.name,
1138
+ files: (collectionEntriesByDir[entry.name] ?? []).map(
1139
+ file => file.name
1140
+ )
1141
+ }
1142
+ : // top-level files in space (e.g. .space.<spaceId>.json)
1143
+ { name: entry.name }
1144
+ ),
1145
+ revocationFiles
1146
+ })
1147
+
1148
+ // Fixed mtime on every entry so the archive is byte-reproducible (see
1149
+ // EXPORT_ENTRY_MTIME).
1150
+ const mtime = EXPORT_ENTRY_MTIME
1151
+ const pack = tar.pack()
1152
+ pack.entry({ name: 'manifest.yml', mtime }, YAML.stringify(manifest))
1153
+ pack.entry({ name: 'space/', type: 'directory', mtime })
1154
+ pack.entry({ name: `space/${spaceId}/`, type: 'directory', mtime })
1155
+
1156
+ for (const entry of spaceEntries) {
1157
+ const entryTarget = `space/${spaceId}/${entry.name}`
1158
+
1159
+ if (entry.isDirectory()) {
1160
+ pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime })
1161
+ for (const file of collectionEntriesByDir[entry.name] ?? []) {
1162
+ const bytes = await fs.promises.readFile(
1163
+ path.join(sourceSpaceDir, entry.name, file.name)
1164
+ )
1165
+ pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes)
1166
+ }
1167
+ } else if (entry.isFile()) {
1168
+ const bytes = await fs.promises.readFile(
1169
+ path.join(sourceSpaceDir, entry.name)
1170
+ )
1171
+ pack.entry({ name: entryTarget, mtime }, bytes)
1172
+ }
1173
+ }
1174
+
1175
+ if (revocationFiles.length > 0) {
1176
+ pack.entry({ name: 'revocations/', type: 'directory', mtime })
1177
+ for (const name of revocationFiles) {
1178
+ const bytes = await fs.promises.readFile(
1179
+ path.join(revocationsDir, name)
1180
+ )
1181
+ pack.entry({ name: `revocations/${name}`, mtime }, bytes)
1182
+ }
1183
+ }
1184
+
1185
+ pack.finalize()
1186
+ return pack
1187
+ }
1188
+
1189
+ /**
1190
+ * Merges a WAS space-export tarball into an existing Space (collections and
1191
+ * resources that already exist are skipped, not overwritten).
1192
+ * @param options {object}
1193
+ * @param options.spaceId {string}
1194
+ * @param options.tarStream {Readable}
1195
+ * @returns {Promise<ImportStats>}
1196
+ */
1197
+ async importSpace({
1198
+ spaceId,
1199
+ tarStream
1200
+ }: {
1201
+ spaceId: string
1202
+ tarStream: Readable
1203
+ }): Promise<ImportStats> {
1204
+ const entries = await extractTarEntries(tarStream)
1205
+ const { spacePolicy, collections, revocations } = buildImportPlan(entries)
1206
+
1207
+ // Pre-flight pass over every staged resource, before writing anything, so a
1208
+ // rejected import leaves the Space untouched. Three checks, each of which the
1209
+ // PUT/POST write routes already enforce and which import MUST inherit too:
1210
+ // - per-upload size cap (413): reject any single body over `maxUploadBytes`;
1211
+ // - fail-closed encryption (422): when the target Collection declares a
1212
+ // recognized `encryption` scheme, every incoming resource body MUST be a
1213
+ // conforming envelope of it (a plaintext body under an encrypted
1214
+ // Collection would otherwise store server-visible plaintext);
1215
+ // - cumulative quota (507): sum bodies and check remaining Space headroom.
1216
+ // The effective encryption marker is the merged-into Collection's existing
1217
+ // one, else the import's own Collection description (a new Collection). Skips
1218
+ // (existing ids) are counted conservatively, as for the quota estimate.
1219
+ const {
1220
+ capacityBytes,
1221
+ maxUploadBytes,
1222
+ maxCollectionsPerSpace,
1223
+ maxResourcesPerSpace
1224
+ } = this
1225
+ let incomingBytes = 0
1226
+ for (const {
1227
+ collectionId,
1228
+ collectionDescription,
1229
+ resources
1230
+ } of collections) {
1231
+ const existing = await this.getCollectionDescription({
1232
+ spaceId,
1233
+ collectionId
1234
+ })
1235
+ const effectiveEncryption = existing
1236
+ ? existing.encryption
1237
+ : collectionDescription.encryption
1238
+ for (const { fileName, body } of resources) {
1239
+ if (maxUploadBytes !== undefined && body.length > maxUploadBytes) {
1240
+ throw new PayloadTooLargeError({
1241
+ maxUploadBytes,
1242
+ backendId: this.describe().id,
1243
+ uploadBytes: body.length
1244
+ })
1245
+ }
1246
+ if (effectiveEncryption?.scheme !== undefined) {
1247
+ const { contentType } = parseResourceFileName(fileName)
1248
+ let parsedBody: unknown
1249
+ try {
1250
+ parsedBody = JSON.parse(body.toString('utf8'))
1251
+ } catch {
1252
+ parsedBody = undefined
1253
+ }
1254
+ assertEncryptedWriteConforms({
1255
+ collectionDescription: { encryption: effectiveEncryption },
1256
+ contentType,
1257
+ body: parsedBody
1258
+ })
1259
+ }
1260
+ incomingBytes += body.length
1261
+ }
1262
+ }
1263
+ if (capacityBytes !== undefined) {
1264
+ await this._assertSpaceHeadroom({
1265
+ spaceId,
1266
+ capacityBytes,
1267
+ incomingBytes
1268
+ })
1269
+ }
1270
+
1271
+ const stats: ImportStats = {
1272
+ collectionsCreated: 0,
1273
+ collectionsSkipped: 0,
1274
+ resourcesCreated: 0,
1275
+ resourcesSkipped: 0,
1276
+ policiesCreated: 0,
1277
+ policiesSkipped: 0
1278
+ }
1279
+
1280
+ // Space-level policy: restore it when the destination has none (the import
1281
+ // target Space pre-exists, so this fills in a missing policy without
1282
+ // clobbering one the destination already carries).
1283
+ if (spacePolicy) {
1284
+ if (await this.getPolicy({ spaceId })) {
1285
+ stats.policiesSkipped++
1286
+ } else {
1287
+ await this.writePolicy({ spaceId, policy: spacePolicy })
1288
+ stats.policiesCreated++
1289
+ }
1290
+ }
1291
+
1292
+ // Count quotas: measure the Space's existing live Collections/Resources
1293
+ // ONCE here, then track running totals as the apply loop creates items, so
1294
+ // an import cannot push the Space past `maxCollectionsPerSpace` /
1295
+ // `maxResourcesPerSpace`. Only brand-new items count -- a re-imported
1296
+ // existing id is skipped and does not -- mirroring the per-create
1297
+ // write-path guards without re-enumerating the Space per item.
1298
+ const collectionIds = new Set(
1299
+ (await this.listCollections({ spaceId })).map(entry => entry.id)
1300
+ )
1301
+ let liveResourceCount =
1302
+ maxResourcesPerSpace !== undefined
1303
+ ? await this._countLiveResources({ spaceId })
1304
+ : 0
1305
+
1306
+ for (const {
1307
+ collectionId,
1308
+ collectionDescription,
1309
+ collectionPolicy,
1310
+ resources,
1311
+ resourcePolicies,
1312
+ resourceMetadata
1313
+ } of collections) {
1314
+ // check if collection already exists
1315
+ const collectionExisted = Boolean(
1316
+ await this.getCollectionDescription({ spaceId, collectionId })
1317
+ )
1318
+ if (collectionExisted) {
1319
+ stats.collectionsSkipped++
1320
+ } else {
1321
+ // A brand-new Collection (one whose id the Space did not already hold,
1322
+ // even as a description-less directory) counts against the cap; filling
1323
+ // in the description of an existing directory does not.
1324
+ if (
1325
+ maxCollectionsPerSpace !== undefined &&
1326
+ !collectionIds.has(collectionId) &&
1327
+ collectionIds.size >= maxCollectionsPerSpace
1328
+ ) {
1329
+ throw new CountQuotaExceededError({
1330
+ scope: 'Collections per Space',
1331
+ limit: maxCollectionsPerSpace
1332
+ })
1333
+ }
1334
+ collectionIds.add(collectionId)
1335
+ await this._persistCollection({
1336
+ spaceId,
1337
+ collectionId,
1338
+ collectionDescription
1339
+ })
1340
+ stats.collectionsCreated++
1341
+ }
1342
+
1343
+ // A collection-level policy travels with a newly-created collection; for
1344
+ // an existing (skipped) collection, leave its access policy untouched.
1345
+ if (collectionPolicy) {
1346
+ if (collectionExisted) {
1347
+ stats.policiesSkipped++
1348
+ } else {
1349
+ await this.writePolicy({
1350
+ spaceId,
1351
+ collectionId,
1352
+ policy: collectionPolicy
1353
+ })
1354
+ stats.policiesCreated++
1355
+ }
1356
+ }
1357
+
1358
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
1359
+
1360
+ for (const { fileName, resourceId, body } of resources) {
1361
+ // Skip anything the destination already has for this id: a live
1362
+ // representation (`_findFile`) OR a sidecar (`_readMetaSidecar`, which
1363
+ // includes a `deleted:true` tombstone). Checking only `_findFile` would
1364
+ // let an import write content back over a soft-deleted (tombstoned)
1365
+ // resource -- resurrecting it while its `deleted:true` sidecar remains,
1366
+ // yielding a served-but-tombstoned resource and an inconsistent feed.
1367
+ const resourceExists =
1368
+ Boolean(await this._findFile({ collectionDir, resourceId })) ||
1369
+ Boolean(await this._readMetaSidecar({ collectionDir, resourceId }))
1370
+ if (resourceExists) {
1371
+ stats.resourcesSkipped++
1372
+ // A resource-level policy travels with a newly-created resource only.
1373
+ if (resourcePolicies.has(resourceId)) {
1374
+ stats.policiesSkipped++
1375
+ }
1376
+ continue
1377
+ }
1378
+
1379
+ // A new live Resource counts against the per-Space cap.
1380
+ if (maxResourcesPerSpace !== undefined) {
1381
+ if (liveResourceCount >= maxResourcesPerSpace) {
1382
+ throw new CountQuotaExceededError({
1383
+ scope: 'Resources per Space',
1384
+ limit: maxResourcesPerSpace
1385
+ })
1386
+ }
1387
+ liveResourceCount++
1388
+ }
1389
+
1390
+ await atomicWriteFile({
1391
+ filePath: path.join(collectionDir, fileName),
1392
+ data: body
1393
+ })
1394
+ stats.resourcesCreated++
1395
+
1396
+ // A metadata sidecar travels with a newly-created resource (preserving
1397
+ // its timestamps and user-writable `custom`); an absent one leaves
1398
+ // `getResourceMetadata` to fall back to the file's stat times.
1399
+ const metadataBytes = resourceMetadata.get(resourceId)
1400
+ if (metadataBytes) {
1401
+ await atomicWriteFile({
1402
+ filePath: this._metaSidecarPath({ collectionDir, resourceId }),
1403
+ data: metadataBytes
1404
+ })
1405
+ }
1406
+
1407
+ const resourcePolicy = resourcePolicies.get(resourceId)
1408
+ if (resourcePolicy) {
1409
+ await this.writePolicy({
1410
+ spaceId,
1411
+ collectionId,
1412
+ resourceId,
1413
+ policy: resourcePolicy
1414
+ })
1415
+ stats.policiesCreated++
1416
+ }
1417
+ }
1418
+
1419
+ // Carry tombstones: a soft-deleted Resource (see `deleteResource`) exports
1420
+ // as a `.meta.` sidecar with no paired `r.` content file, so it never
1421
+ // appears in `resources` above. Restore each such ORPHAN sidecar that is a
1422
+ // tombstone (`deleted: true`) -- writing only the sidecar re-creates the
1423
+ // tombstone. A non-tombstone orphan sidecar is anomalous (a Resource with
1424
+ // no representation) and is skipped. Merge semantics match resources:
1425
+ // anything the destination already has for that id (a live Resource or an
1426
+ // existing tombstone) is left untouched.
1427
+ const importedResourceIds = new Set(resources.map(r => r.resourceId))
1428
+ for (const [resourceId, metadataBytes] of resourceMetadata) {
1429
+ if (importedResourceIds.has(resourceId)) {
1430
+ continue
1431
+ }
1432
+ let sidecar: MetaSidecar | undefined
1433
+ try {
1434
+ sidecar = JSON.parse(metadataBytes.toString('utf8'))
1435
+ } catch {
1436
+ continue
1437
+ }
1438
+ if (sidecar?.deleted !== true) {
1439
+ continue
1440
+ }
1441
+ const exists =
1442
+ Boolean(await this._findFile({ collectionDir, resourceId })) ||
1443
+ Boolean(await this._readMetaSidecar({ collectionDir, resourceId }))
1444
+ if (exists) {
1445
+ stats.resourcesSkipped++
1446
+ continue
1447
+ }
1448
+ await atomicWriteFile({
1449
+ filePath: this._metaSidecarPath({ collectionDir, resourceId }),
1450
+ data: metadataBytes
1451
+ })
1452
+ stats.resourcesCreated++
1453
+ }
1454
+ }
1455
+
1456
+ // Restore the archive's Space-scoped zcap revocations under this Space's
1457
+ // scope: a capability revoked before the export must stay revoked after
1458
+ // an import (a backup/restore round-trip must not resurrect revoked
1459
+ // access). Merge semantics match the rest of the import -- an
1460
+ // already-stored record is skipped -- and a record past its GC horizon is
1461
+ // dropped (the capability itself has expired; `isRevoked` would prune it).
1462
+ const now = Date.now()
1463
+ for (const record of revocations) {
1464
+ if (record.meta.expires && Date.parse(record.meta.expires) <= now) {
1465
+ continue
1466
+ }
1467
+ try {
1468
+ await this.insertRevocation({ scope: { spaceId }, record })
1469
+ } catch (err) {
1470
+ if (!(err instanceof DuplicateRevocationError)) {
1471
+ throw err
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ return stats
1477
+ }
1478
+
1479
+ // Collections
1480
+
1481
+ /**
1482
+ * @param options {object}
1483
+ * @param options.spaceId {string}
1484
+ * @param options.collectionId {string}
1485
+ * @param options.collectionDescription {CollectionDescription}
1486
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
1487
+ * Collection's `createdBy` on first write only
1488
+ * @param [options.ifMatch] {string} an `If-Match` compare-and-swap on the
1489
+ * current description version (the `key-epochs` feature); a stale validator
1490
+ * throws `PreconditionFailedError` (412)
1491
+ * @returns {Promise<{ version: number }>} the Collection's new description
1492
+ * version (the `ETag` validator)
1493
+ */
1494
+ async writeCollection({
1495
+ spaceId,
1496
+ collectionId,
1497
+ collectionDescription,
1498
+ createdBy,
1499
+ ifMatch,
1500
+ assertTransition
1501
+ }: {
1502
+ spaceId: string
1503
+ collectionId: string
1504
+ collectionDescription: CollectionDescription
1505
+ createdBy?: IDID
1506
+ ifMatch?: string
1507
+ assertTransition?: (
1508
+ prior?: CollectionDescription & { descriptionVersion?: number }
1509
+ ) => void
1510
+ }): Promise<{ version: number }> {
1511
+ // Serialize the read-check-write under a per-Collection-description lock so
1512
+ // the `If-Match` compare-and-swap and the monotonic version bump are atomic
1513
+ // with the write (two concurrent recipient edits cannot clobber one
1514
+ // another). A distinct lock namespace from the per-Resource / unique-scan
1515
+ // locks: a description write and a Resource write touch different files.
1516
+ return this._writeMutex.run(
1517
+ this._collectionDescLockKey({ spaceId, collectionId }),
1518
+ async () => {
1519
+ // Prior description, read once and reused below: for the create-path
1520
+ // quota check, `createdBy` resolution, and the CAS version.
1521
+ const prior = await this.getCollectionDescription({
1522
+ spaceId,
1523
+ collectionId
1524
+ })
1525
+
1526
+ // Compare-and-swap on the current description version (opt-in): a stale
1527
+ // `If-Match` throws 412. An unconditional write skips this.
1528
+ assertCollectionWritePrecondition({
1529
+ collectionId,
1530
+ currentVersion: prior?.descriptionVersion ?? 0,
1531
+ ifMatch
1532
+ })
1533
+
1534
+ // The request layer's state-transition rails (e.g. epoch append-only),
1535
+ // re-evaluated here against the description just read under the lock.
1536
+ assertTransition?.(prior)
1537
+
1538
+ // Count quota (create path only): a new Collection must not push its
1539
+ // Space past `maxCollectionsPerSpace`; overwriting an existing
1540
+ // Collection's description never trips it.
1541
+ if (this.maxCollectionsPerSpace !== undefined && !prior) {
1542
+ const collections = await this.listCollections({ spaceId })
1543
+ if (collections.length >= this.maxCollectionsPerSpace) {
1544
+ throw new CountQuotaExceededError({
1545
+ scope: 'Collections per Space',
1546
+ limit: this.maxCollectionsPerSpace
1547
+ })
1548
+ }
1549
+ }
1550
+
1551
+ // `createdBy` names the Collection's creator, not its last writer: taken
1552
+ // from this write's invoker only when this write CREATES the
1553
+ // description, and preserved verbatim afterward -- including
1554
+ // preserved-as-absent. The client-supplied `collectionDescription` is
1555
+ // wire input and may carry its own `createdBy` (and the out-of-band
1556
+ // `descriptionVersion` when a caller spread a read result back in) --
1557
+ // discard both, since the server alone is authoritative for them.
1558
+ const {
1559
+ createdBy: _suppliedCreatedBy,
1560
+ descriptionVersion: _suppliedVersion,
1561
+ ...rest
1562
+ } = collectionDescription as CollectionDescription & {
1563
+ descriptionVersion?: number
1564
+ }
1565
+ const creator = prior ? prior.createdBy : createdBy
1566
+ const version = (prior?.descriptionVersion ?? 0) + 1
1567
+
1568
+ await this._persistCollection({
1569
+ spaceId,
1570
+ collectionId,
1571
+ collectionDescription: {
1572
+ ...rest,
1573
+ ...(creator !== undefined && { createdBy: creator })
1574
+ },
1575
+ descriptionVersion: version
1576
+ })
1577
+ return { version }
1578
+ }
1579
+ )
1580
+ }
1581
+
1582
+ /**
1583
+ * Builds the per-Collection-description serialization key for `_writeMutex`
1584
+ * (`desc:<spaceId>/<collectionId>`), so a Collection Description compare-and-
1585
+ * swap serializes with itself while staying disjoint from the per-Resource and
1586
+ * unique-scan lock namespaces.
1587
+ * @param options {object}
1588
+ * @param options.spaceId {string}
1589
+ * @param options.collectionId {string}
1590
+ * @returns {string}
1591
+ */
1592
+ _collectionDescLockKey({
1593
+ spaceId,
1594
+ collectionId
1595
+ }: {
1596
+ spaceId: string
1597
+ collectionId: string
1598
+ }): string {
1599
+ return `desc:${spaceId}/${collectionId}`
1600
+ }
1601
+
1602
+ /**
1603
+ * Writes a Collection's description file (creating the Collection dir if
1604
+ * needed), with NO count-quota check. The count guard lives in the public
1605
+ * `writeCollection`; `importSpace` tracks the Space's Collection count itself
1606
+ * (measured once up front) and calls this directly, so it does not
1607
+ * re-enumerate the Space per created Collection.
1608
+ * @param options {object}
1609
+ * @param options.spaceId {string}
1610
+ * @param options.collectionId {string}
1611
+ * @param options.collectionDescription {CollectionDescription}
1612
+ * @param [options.descriptionVersion] {number} the monotonic description
1613
+ * version to persist (the `ETag` validator; the `key-epochs` feature). Kept
1614
+ * OUT of the wire body -- stored under a reserved `_version` member that
1615
+ * `getCollectionDescription` strips and re-surfaces as `descriptionVersion`.
1616
+ * When omitted (the import path), a `_version` already on the incoming
1617
+ * description is preserved, else it defaults to `1`.
1618
+ * @returns {Promise<void>}
1619
+ */
1620
+ async _persistCollection({
1621
+ spaceId,
1622
+ collectionId,
1623
+ collectionDescription,
1624
+ descriptionVersion
1625
+ }: {
1626
+ spaceId: string
1627
+ collectionId: string
1628
+ collectionDescription: CollectionDescription
1629
+ descriptionVersion?: number
1630
+ }): Promise<void> {
1631
+ const collectionDir = await this._ensureCollectionDir({
1632
+ spaceId,
1633
+ collectionId
1634
+ })
1635
+ const filename = `.collection.${collectionId}.json`
1636
+ // Shared normalization (lib/collectionDescription.ts): strip the
1637
+ // version-bearing members from the incoming body and re-stamp -- an
1638
+ // explicit `descriptionVersion` wins, else a `_version` already on the
1639
+ // incoming (imported) description is kept, else the first write starts
1640
+ // at 1.
1641
+ const { body, version } = normalizeDescriptionWrite({
1642
+ collectionDescription,
1643
+ descriptionVersion
1644
+ })
1645
+ await atomicWriteFile({
1646
+ filePath: path.join(collectionDir, filename),
1647
+ data: JSON.stringify({ ...body, _version: version })
1648
+ })
1649
+ }
1650
+
1651
+ /**
1652
+ * @param options {object}
1653
+ * @param options.spaceId {string}
1654
+ * @param options.collectionId {string}
1655
+ * @returns {Promise<CollectionDescription|undefined>}
1656
+ * Resolves falsy when the Collection does not exist (must not throw).
1657
+ */
1658
+ async getCollectionDescription({
1659
+ spaceId,
1660
+ collectionId
1661
+ }: {
1662
+ spaceId: string
1663
+ collectionId: string
1664
+ }): Promise<
1665
+ (CollectionDescription & { descriptionVersion?: number }) | undefined
1666
+ > {
1667
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
1668
+ const filename = `.collection.${collectionId}.json`
1669
+ const metaStore = new MetadataJsonStore<
1670
+ CollectionDescription & { _version?: number }
1671
+ >({
1672
+ file: path.join(collectionDir, filename)
1673
+ })
1674
+ const raw = await metaStore.read()
1675
+ if (!raw) {
1676
+ return undefined
1677
+ }
1678
+ // `_version` is the internal ETag validator, kept out of the wire body:
1679
+ // strip it and re-surface it out-of-band as `descriptionVersion` (the
1680
+ // handler sets the `ETag` header from it). Absent for a legacy Collection.
1681
+ const { _version, ...description } = raw
1682
+ return {
1683
+ ...description,
1684
+ ...(_version !== undefined && { descriptionVersion: _version })
1685
+ }
1686
+ }
1687
+
1688
+ /**
1689
+ * @param options {object}
1690
+ * @param options.spaceId {string}
1691
+ * @param options.collectionId {string}
1692
+ * @returns {Promise<void>}
1693
+ */
1694
+ async deleteCollection({
1695
+ spaceId,
1696
+ collectionId
1697
+ }: {
1698
+ spaceId: string
1699
+ collectionId: string
1700
+ }): Promise<void> {
1701
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
1702
+ this._usageCache.delete(spaceId)
1703
+ // `force: true` keeps delete idempotent (spec / `StorageBackend` contract):
1704
+ // removing an absent (or already-deleted) Collection resolves rather than
1705
+ // rejecting with `ENOENT` (which the request layer would wrap as a 500).
1706
+ return await rm(this._collectionDir({ spaceId, collectionId }), {
1707
+ recursive: true,
1708
+ force: true
1709
+ })
1710
+ }
1711
+
1712
+ /**
1713
+ * Lists a Collection's Resources, OPTIONALLY cursor-paginated (spec
1714
+ * "Pagination"). Items are returned in a stable total order -- ascending by
1715
+ * `resourceId`, read straight from the `r.<resourceId>.<type>.<ext>` filename
1716
+ * (the keyset). A `cursor` resumes the scan at the first id strictly greater
1717
+ * than the cursor's anchor, so paging stays correct even if the anchor id was
1718
+ * deleted between pages; `limit` bounds the page (clamped to
1719
+ * `[1, MAX_PAGE_SIZE]`, default `DEFAULT_PAGE_SIZE`). `next` is built (and
1720
+ * present) only when a further page may follow.
1721
+ * @param options {object}
1722
+ * @param options.spaceId {string}
1723
+ * @param options.collectionId {string}
1724
+ * @param [options.limit] {number} requested page size
1725
+ * @param [options.cursor] {string} opaque cursor from a prior page's `next`
1726
+ * @returns {Promise<CollectionResourcesList>}
1727
+ */
1728
+ async listCollectionItems({
1729
+ spaceId,
1730
+ collectionId,
1731
+ limit,
1732
+ cursor,
1733
+ collectionDescription: providedDescription
1734
+ }: {
1735
+ spaceId: string
1736
+ collectionId: string
1737
+ limit?: number
1738
+ cursor?: string
1739
+ collectionDescription?: CollectionDescription
1740
+ }): Promise<CollectionResourcesList> {
1741
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
1742
+ // Prefer the caller's control-plane description. When this backend serves a
1743
+ // Collection's data plane (an external backend), it does NOT hold the
1744
+ // description locally, so its own `getCollectionDescription` would resolve
1745
+ // `undefined` and reading `.name`/`.type` off it would 500.
1746
+ const collectionDescription =
1747
+ providedDescription ??
1748
+ (await this.getCollectionDescription({ spaceId, collectionId }))
1749
+
1750
+ // Enumerate the Collection dir directly rather than globbing: glob v13 does
1751
+ // not sort, so its order is nondeterministic -- pagination needs a stable
1752
+ // keyset. Keep only resource representations (`r.<id>.<type>.<ext>`), which
1753
+ // drops the `.meta.` / `.collection.` / `.policy.` dot-files.
1754
+ let entries: fs.Dirent[] = []
1755
+ try {
1756
+ entries = await fs.promises.readdir(collectionDir, {
1757
+ withFileTypes: true
1758
+ })
1759
+ } catch (err) {
1760
+ this.logger.error({ err }, 'Error reading collection directory')
1761
+ }
1762
+ const resources = entries
1763
+ .filter(entry => entry.isFile() && entry.name.startsWith('r.'))
1764
+ .map(entry => parseResourceFileName(entry.name))
1765
+ // Sort by `resourceId` ascending in code-unit order -- the SAME ordering
1766
+ // the cursor seek (`resourceId > after`) uses, so the keyset is consistent
1767
+ // (localeCompare could disagree with the `>` operator and break paging).
1768
+ .sort((left, right) =>
1769
+ compareCodeUnits(left.resourceId, right.resourceId)
1770
+ )
1771
+
1772
+ // The full count is free here (we enumerated the whole dir), so keep
1773
+ // returning `totalItems` -- the count of the entire Collection, not the page.
1774
+ const totalItems = resources.length
1775
+
1776
+ // Seek to the first entry strictly after the cursor's anchor id. Keyset
1777
+ // stability: a missing anchor (deleted between pages) does not break the
1778
+ // scan, since we resume at the first id greater than it.
1779
+ let startIndex = 0
1780
+ if (cursor !== undefined) {
1781
+ const { after } = decodeCursor(cursor)
1782
+ const found = resources.findIndex(({ resourceId }) => resourceId > after)
1783
+ startIndex = found === -1 ? resources.length : found
1784
+ }
1785
+
1786
+ // Clamp `limit` to `[1, MAX_PAGE_SIZE]`, defaulting when absent.
1787
+ const pageSize =
1788
+ limit === undefined ? DEFAULT_PAGE_SIZE : clampPageSize(limit)
1789
+
1790
+ // Take `pageSize + 1` from the seek point to detect a further page without a
1791
+ // second pass; the page is the first `pageSize`, and `hasMore` is whether we
1792
+ // got the extra one (so a page that exactly fills the Collection has no
1793
+ // spurious empty trailing page).
1794
+ const window = resources.slice(startIndex, startIndex + pageSize + 1)
1795
+ const hasMore = window.length > pageSize
1796
+ const pageEntries = hasMore ? window.slice(0, pageSize) : window
1797
+
1798
+ // Read `.meta` sidecars ONLY for the items on this page (the previous
1799
+ // implementation read a sidecar for every resource on every list). Surface
1800
+ // the user-writable `custom.name` (spec: updating it updates the name shown
1801
+ // in Collection listings) -- but only for a plaintext Collection. On an
1802
+ // encrypted Collection `custom` is the opaque encryption envelope, so the
1803
+ // server cannot project a `name`; the listing omits it (spec "List
1804
+ // Collection", encrypted-Collection note). (A bare `custom?.name` on a JWE
1805
+ // envelope already yields `undefined`; the guard makes that explicit.)
1806
+ const encrypted = collectionDescription?.encryption !== undefined
1807
+ const items = await Promise.all(
1808
+ pageEntries.map(async ({ resourceId, contentType }) => {
1809
+ const sidecar = await this._readMetaSidecar({
1810
+ collectionDir,
1811
+ resourceId
1812
+ })
1813
+ const name = encrypted
1814
+ ? undefined
1815
+ : (sidecar?.custom as ResourceMetadataCustom | undefined)?.name
1816
+ return {
1817
+ id: resourceId,
1818
+ url: resourcePath({ spaceId, collectionId, resourceId }),
1819
+ contentType,
1820
+ ...(name !== undefined && { name }),
1821
+ // The client-declared key epoch (the `key-epochs` feature) rides each
1822
+ // listing item so a reader can pick the right epoch key without a
1823
+ // `/meta` fetch per Resource.
1824
+ ...(sidecar?.epoch !== undefined && { epoch: sidecar.epoch })
1825
+ }
1826
+ })
1827
+ )
1828
+
1829
+ // `next` is present iff a further page may follow; its absence marks the last
1830
+ // page (the authoritative end-of-list signal). The cursor (the last id on
1831
+ // this page) and the page size are baked into the URL so the client follows
1832
+ // it verbatim without constructing query parameters.
1833
+ let next: string | undefined
1834
+ if (hasMore) {
1835
+ const lastId = pageEntries[pageEntries.length - 1]!.resourceId
1836
+ const base = collectionPath({
1837
+ spaceId,
1838
+ collectionId,
1839
+ trailingSlash: true
1840
+ })
1841
+ next = `${base}?limit=${pageSize}&cursor=${encodeCursor(lastId)}`
1842
+ }
1843
+
1844
+ return {
1845
+ id: collectionId,
1846
+ url: collectionPath({ spaceId, collectionId }),
1847
+ name: collectionDescription?.name ?? collectionId,
1848
+ type: collectionDescription?.type || ['Collection'],
1849
+ totalItems,
1850
+ items,
1851
+ ...(next !== undefined && { next })
1852
+ }
1853
+ }
1854
+
1855
+ // Resources
1856
+
1857
+ /**
1858
+ * Writes a resource representation (JSON value or byte stream) to disk, under
1859
+ * the per-Resource write lock (the `conditional-writes` feature), and bumps
1860
+ * the Resource's monotonic `version`. The new version is returned so the
1861
+ * request layer can surface it as the response `ETag`.
1862
+ *
1863
+ * When a conditional-write precondition is supplied it is evaluated against
1864
+ * the Resource's current state atomically with the write (under the lock),
1865
+ * throwing `PreconditionFailedError` (412) on a mismatch:
1866
+ * - `ifNoneMatch` (a create-if-absent `If-None-Match: *`) fails if the
1867
+ * Resource already exists.
1868
+ * - `ifMatch` (an update-if-unchanged `If-Match: "<etag>"`) fails if the
1869
+ * Resource is absent or its current `ETag` does not equal `ifMatch`.
1870
+ * `ifNoneMatch` takes precedence when both are supplied (RFC9110).
1871
+ *
1872
+ * @param options {object}
1873
+ * @param options.spaceId {string}
1874
+ * @param options.collectionId {string}
1875
+ * @param options.resourceId {string}
1876
+ * @param options.input {ResourceInput}
1877
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
1878
+ * Resource's `createdBy` on first write only
1879
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
1880
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
1881
+ * @returns {Promise<{ version: number }>} the Resource's new version
1882
+ */
1883
+ async writeResource({
1884
+ spaceId,
1885
+ collectionId,
1886
+ resourceId,
1887
+ input,
1888
+ createdBy,
1889
+ epoch,
1890
+ ifMatch,
1891
+ ifNoneMatch
1892
+ }: {
1893
+ spaceId: string
1894
+ collectionId: string
1895
+ resourceId: string
1896
+ input: ResourceInput
1897
+ createdBy?: IDID
1898
+ epoch?: string
1899
+ ifMatch?: string
1900
+ ifNoneMatch?: boolean
1901
+ }): Promise<{ version: number }> {
1902
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
1903
+ const lockKey = this._resourceLockKey({
1904
+ spaceId,
1905
+ collectionId,
1906
+ resourceId
1907
+ })
1908
+ const write = () =>
1909
+ this._writeMutex.run(lockKey, () =>
1910
+ this._writeResourceLocked({
1911
+ spaceId,
1912
+ collectionDir,
1913
+ resourceId,
1914
+ input,
1915
+ createdBy,
1916
+ epoch,
1917
+ ifMatch,
1918
+ ifNoneMatch
1919
+ })
1920
+ )
1921
+
1922
+ // The EDV unique-attribute invariant (`unique: true` blinded attributes;
1923
+ // the `blinded-index-query` feature): a write claiming a unique blinded
1924
+ // triple must not collide with another live document's claim. Only a
1925
+ // unique-carrying JSON write can create a new claim, so only such writes
1926
+ // pay for it: they serialize per Collection (the outer lock, so two racing
1927
+ // claimants cannot both pass the scan), evaluate the conflict against the
1928
+ // Collection's other live JSON documents, then take the ordinary
1929
+ // per-Resource lock nested inside. Distinct-key nesting cannot deadlock:
1930
+ // plain writes never hold a Resource key while waiting on a Collection key.
1931
+ if (
1932
+ input.kind === 'json' &&
1933
+ collectUniqueBlindedTerms({ document: input.data }).length > 0
1934
+ ) {
1935
+ return this._writeMutex.run(
1936
+ this._collectionLockKey({ spaceId, collectionId }),
1937
+ async () => {
1938
+ assertNoUniqueBlindedConflict({
1939
+ document: input.data,
1940
+ candidates: await this._readJsonCandidates({
1941
+ spaceId,
1942
+ collectionId,
1943
+ excludeResourceId: resourceId
1944
+ })
1945
+ })
1946
+ return write()
1947
+ }
1948
+ )
1949
+ }
1950
+ return write()
1951
+ }
1952
+
1953
+ /**
1954
+ * The critical section of `writeResource`, run under the per-Resource lock:
1955
+ * evaluates any precondition, writes the representation, prunes a stale
1956
+ * representation under a different content-type, and persists the bumped
1957
+ * `version` in the sidecar. See `writeResource` for the parameters.
1958
+ * @returns {Promise<{ version: number }>}
1959
+ */
1960
+ private async _writeResourceLocked({
1961
+ spaceId,
1962
+ collectionDir,
1963
+ resourceId,
1964
+ input,
1965
+ createdBy,
1966
+ epoch,
1967
+ ifMatch,
1968
+ ifNoneMatch
1969
+ }: {
1970
+ spaceId: string
1971
+ collectionDir: string
1972
+ resourceId: string
1973
+ input: ResourceInput
1974
+ createdBy?: IDID
1975
+ epoch?: string
1976
+ ifMatch?: string
1977
+ ifNoneMatch?: boolean
1978
+ }): Promise<{ version: number }> {
1979
+ const filename = fileNameFor({ resourceId, contentType: input.contentType })
1980
+ const filePath = path.join(collectionDir, filename)
1981
+ this._assertContained(filePath)
1982
+
1983
+ // Evaluate any conditional-write precondition against the current state
1984
+ // before writing (still inside the lock, so the check and write are atomic).
1985
+ if (ifMatch !== undefined || ifNoneMatch) {
1986
+ await this._assertWritePrecondition({
1987
+ collectionDir,
1988
+ resourceId,
1989
+ ifMatch,
1990
+ ifNoneMatch
1991
+ })
1992
+ }
1993
+
1994
+ // Count quota (create path only): a new live Resource must not push its
1995
+ // Space past `maxResourcesPerSpace`. A write over an existing live
1996
+ // representation is an update (never trips it); a write over a tombstone
1997
+ // (no `r.` file) is a create and does count. Soft under concurrency.
1998
+ if (this.maxResourcesPerSpace !== undefined) {
1999
+ const isLive =
2000
+ (await this._findFile({ collectionDir, resourceId })) !== undefined
2001
+ if (!isLive) {
2002
+ const liveCount = await this._countLiveResources({ spaceId })
2003
+ if (liveCount >= this.maxResourcesPerSpace) {
2004
+ throw new CountQuotaExceededError({
2005
+ scope: 'Resources per Space',
2006
+ limit: this.maxResourcesPerSpace
2007
+ })
2008
+ }
2009
+ }
2010
+ }
2011
+
2012
+ const { capacityBytes, maxUploadBytes } = this
2013
+
2014
+ if (input.kind === 'json') {
2015
+ // JSON bodies are fully in memory, so their serialized size is known up
2016
+ // front and the pre-flight checks alone suffice (no streaming guard). The
2017
+ // per-upload cap (413) is checked before the cumulative quota (507).
2018
+ // Serialize once and reuse for both the size pre-flight and the write.
2019
+ const serialized = JSON.stringify(input.data)
2020
+ const incomingBytes = Buffer.byteLength(serialized)
2021
+ if (maxUploadBytes !== undefined && incomingBytes > maxUploadBytes) {
2022
+ throw new PayloadTooLargeError({
2023
+ maxUploadBytes,
2024
+ backendId: this.describe().id,
2025
+ uploadBytes: incomingBytes
2026
+ })
2027
+ }
2028
+ if (capacityBytes !== undefined) {
2029
+ await this._assertSpaceHeadroom({
2030
+ spaceId,
2031
+ capacityBytes,
2032
+ incomingBytes
2033
+ })
2034
+ }
2035
+ // Write the serialized JSON directly rather than through fs-json-store,
2036
+ // whose `write` verifies the result via `readExisting` and treats a falsy
2037
+ // round-tripped value (`null`, `false`, `0`, `""`) as "file does not
2038
+ // exist" -- which would 500 a legitimate top-level primitive Resource. The
2039
+ // read path (`getResource`) streams the bytes back verbatim, so any
2040
+ // top-level JSON value -- object, array, or bare primitive -- round-trips.
2041
+ // Ensure the Collection dir exists first (fs-json-store used to create it
2042
+ // on the fly; a data-plane backend may not have seen this Collection yet).
2043
+ this.logger.info('Creating JSON resource')
2044
+ await mkdir(path.dirname(filePath), { recursive: true })
2045
+ // Durable, atomic replacement (write-temp + fsync + rename + dir fsync):
2046
+ // the final path never observes a torn write, even across a crash.
2047
+ await atomicWriteFile({ filePath, data: serialized })
2048
+ } else {
2049
+ this.logger.info('Writing blob')
2050
+ // Pre-flight the declared size (when present) against the per-upload cap,
2051
+ // then stream through guards that hard-cap a body whose size is omitted or
2052
+ // understated: the upload cap (413) and, when a quota is configured, the
2053
+ // Space headroom (507). On overflow either guard removes the partial file
2054
+ // before surfacing the error.
2055
+ if (
2056
+ maxUploadBytes !== undefined &&
2057
+ input.declaredBytes !== undefined &&
2058
+ input.declaredBytes > maxUploadBytes
2059
+ ) {
2060
+ throw new PayloadTooLargeError({
2061
+ maxUploadBytes,
2062
+ backendId: this.describe().id,
2063
+ uploadBytes: input.declaredBytes
2064
+ })
2065
+ }
2066
+ const guards: Transform[] = []
2067
+ if (maxUploadBytes !== undefined) {
2068
+ guards.push(this._uploadCapGuard({ maxUploadBytes }))
2069
+ }
2070
+ if (capacityBytes !== undefined) {
2071
+ const headroomBytes = await this._assertSpaceHeadroom({
2072
+ spaceId,
2073
+ capacityBytes,
2074
+ incomingBytes: input.declaredBytes ?? 0
2075
+ })
2076
+ guards.push(this._quotaGuard({ spaceId, capacityBytes, headroomBytes }))
2077
+ }
2078
+ // Stream into a temp file in the same directory, then durably commit it
2079
+ // (fsync + rename + dir fsync) once the whole body has been written and
2080
+ // verified. Streaming to the final path directly would expose a truncated
2081
+ // representation under the resource's name if the process crashed
2082
+ // mid-stream; staging + rename makes the resource appear only whole.
2083
+ const tempPath = tempPathFor(filePath)
2084
+ try {
2085
+ await pipeline([
2086
+ input.stream,
2087
+ ...guards,
2088
+ fs.createWriteStream(tempPath)
2089
+ ])
2090
+ await commitTempFile({ tempPath, filePath })
2091
+ } catch (err) {
2092
+ // Remove the partial file on ANY failure: a guard rejection (413/507),
2093
+ // an aborted upload, or a streamed `Digest` mismatch (the request layer
2094
+ // verifies the body's digest as it flows). A failed write must not leave
2095
+ // a truncated or unverified representation behind. Only the temp path is
2096
+ // ever staged into, so a failed write never touches the final path.
2097
+ await rm(tempPath, { force: true })
2098
+ throw err
2099
+ }
2100
+ }
2101
+
2102
+ // A Resource has a single current representation: remove any prior
2103
+ // representation stored under a different content-type (its filename
2104
+ // differs). Write-new-then-prune (not delete-then-write) so the resource is
2105
+ // never momentarily absent.
2106
+ const existing = await this._resourceFilesFor({ collectionDir, resourceId })
2107
+ await Promise.all(
2108
+ existing
2109
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
2110
+ .map(name => rm(name))
2111
+ )
2112
+
2113
+ // Maintain the server-managed timestamps and the monotonic `version`: a
2114
+ // content write sets `createdAt` on first write, bumps `updatedAt`, and
2115
+ // increments `version` (the ETag validator) from its prior value, preserving
2116
+ // any user-writable `custom` and the independent `metaVersion` already stored
2117
+ // in the sidecar (a content write does not touch the metadata sub-resource).
2118
+ //
2119
+ // `createdBy` pairs with `createdAt`: taken from this write's invoker only
2120
+ // when this write creates the sidecar, so it names the creator rather than
2121
+ // the last writer, and is preserved verbatim afterward -- including
2122
+ // preserved-as-absent, so a Resource created with no invoker never has a
2123
+ // later writer backfilled into it. A tombstone keeps both, so re-creating a
2124
+ // deleted id under a different invoker preserves the original creator, as
2125
+ // it does the original `createdAt`.
2126
+ const now = new Date().toISOString()
2127
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2128
+ const version = (prior?.version ?? 0) + 1
2129
+ const creator = prior ? prior.createdBy : createdBy
2130
+ await this._writeMetaSidecar({
2131
+ collectionDir,
2132
+ resourceId,
2133
+ sidecar: {
2134
+ createdAt: prior?.createdAt ?? now,
2135
+ updatedAt: now,
2136
+ ...(creator !== undefined && { createdBy: creator }),
2137
+ version,
2138
+ ...(prior?.metaVersion !== undefined && {
2139
+ metaVersion: prior.metaVersion
2140
+ }),
2141
+ ...(prior?.custom && { custom: prior.custom }),
2142
+ // The key-epoch stamp is set from this write's declaration and CLEARED
2143
+ // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
2144
+ // worse than none), so it is NOT preserved from `prior` like `custom`.
2145
+ ...(epoch !== undefined && { epoch })
2146
+ }
2147
+ })
2148
+ return { version }
2149
+ }
2150
+
2151
+ /**
2152
+ * Builds the per-Resource serialization key for `_writeMutex`
2153
+ * (`<spaceId>/<collectionId>/<resourceId>`), so conditional writes to distinct
2154
+ * Resources run concurrently while writes to the same Resource are ordered.
2155
+ * @param options {object}
2156
+ * @param options.spaceId {string}
2157
+ * @param options.collectionId {string}
2158
+ * @param options.resourceId {string}
2159
+ * @returns {string}
2160
+ */
2161
+ _resourceLockKey({
2162
+ spaceId,
2163
+ collectionId,
2164
+ resourceId
2165
+ }: {
2166
+ spaceId: string
2167
+ collectionId: string
2168
+ resourceId: string
2169
+ }): string {
2170
+ return `${spaceId}/${collectionId}/${resourceId}`
2171
+ }
2172
+
2173
+ /**
2174
+ * The Collection-level mutex key, held by unique-blinded-attribute writes to
2175
+ * serialize their conflict scans. Namespaced (`unique:` prefix) so it can
2176
+ * never collide with a per-Resource key.
2177
+ * @param options {object}
2178
+ * @param options.spaceId {string}
2179
+ * @param options.collectionId {string}
2180
+ * @returns {string}
2181
+ */
2182
+ _collectionLockKey({
2183
+ spaceId,
2184
+ collectionId
2185
+ }: {
2186
+ spaceId: string
2187
+ collectionId: string
2188
+ }): string {
2189
+ return `unique:${spaceId}/${collectionId}`
2190
+ }
2191
+
2192
+ /**
2193
+ * Evaluates a conditional-write precondition against a Resource's current
2194
+ * on-disk state. MUST be called inside the per-Resource write lock so the
2195
+ * check is atomic with the write that follows. Throws
2196
+ * `PreconditionFailedError` (412) when the precondition is not met.
2197
+ * @param options {object}
2198
+ * @param options.collectionDir {string}
2199
+ * @param options.resourceId {string}
2200
+ * @param [options.ifMatch] {string} a quoted ETag (`If-Match`)
2201
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
2202
+ * @returns {Promise<void>}
2203
+ */
2204
+ async _assertWritePrecondition({
2205
+ collectionDir,
2206
+ resourceId,
2207
+ ifMatch,
2208
+ ifNoneMatch
2209
+ }: {
2210
+ collectionDir: string
2211
+ resourceId: string
2212
+ ifMatch?: string
2213
+ ifNoneMatch?: boolean
2214
+ }): Promise<void> {
2215
+ const exists =
2216
+ (await this._findFile({ collectionDir, resourceId })) !== undefined
2217
+ const prior = exists
2218
+ ? await this._readMetaSidecar({ collectionDir, resourceId })
2219
+ : undefined
2220
+ assertWritePrecondition({
2221
+ resourceId,
2222
+ exists,
2223
+ currentVersion: prior?.version ?? 0,
2224
+ ifMatch,
2225
+ ifNoneMatch
2226
+ })
2227
+ }
2228
+
2229
+ /**
2230
+ * @param options {object}
2231
+ * @param options.spaceId {string}
2232
+ * @param options.collectionId {string}
2233
+ * @param options.resourceId {string}
2234
+ * @param [options.contentType] {string}
2235
+ * @returns {Promise<ResourceResult>} includes the Resource's current
2236
+ * `version` (the ETag validator) when one is recorded in its sidecar.
2237
+ */
2238
+ async getResource({
2239
+ spaceId,
2240
+ collectionId,
2241
+ resourceId
2242
+ }: {
2243
+ spaceId: string
2244
+ collectionId: string
2245
+ resourceId: string
2246
+ /**
2247
+ * `contentType` is advisory and ignored for lookup: a Resource has a
2248
+ * single current representation, resolved by `resourceId` alone.
2249
+ */
2250
+ contentType?: string
2251
+ }): Promise<ResourceResult> {
2252
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2253
+ const filePath = await this._findFile({ collectionDir, resourceId })
2254
+
2255
+ if (!filePath) {
2256
+ throw new ResourceNotFoundError({ requestName: 'Get Resource' })
2257
+ }
2258
+
2259
+ try {
2260
+ await fsStat(filePath)
2261
+ } catch (err) {
2262
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
2263
+ throw new ResourceNotFoundError({ requestName: 'Get Resource' })
2264
+ }
2265
+ throw err
2266
+ }
2267
+
2268
+ // Derive the stored content-type from the filename segment (the exact type
2269
+ // it was written under), not from `mime.lookup` on the extension.
2270
+ const { contentType: storedResourceType } = parseResourceFileName(
2271
+ path.basename(filePath)
2272
+ )
2273
+
2274
+ // Surface the ETag validator: the per-Resource `version` from the sidecar
2275
+ // (absent only for a legacy Resource written before versioning).
2276
+ const sidecar = await this._readMetaSidecar({ collectionDir, resourceId })
2277
+
2278
+ return {
2279
+ resourceStream: await openFileStream(filePath, this.logger),
2280
+ storedResourceType,
2281
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
2282
+ }
2283
+ }
2284
+
2285
+ /**
2286
+ * Builds the on-disk path for a Resource's metadata sidecar
2287
+ * (`.meta.<resourceId>.json`) in its Collection dir.
2288
+ * @param options {object}
2289
+ * @param options.collectionDir {string}
2290
+ * @param options.resourceId {string}
2291
+ * @returns {string}
2292
+ */
2293
+ _metaSidecarPath({
2294
+ collectionDir,
2295
+ resourceId
2296
+ }: {
2297
+ collectionDir: string
2298
+ resourceId: string
2299
+ }): string {
2300
+ const filePath = path.join(collectionDir, metaSidecarFileName(resourceId))
2301
+ this._assertContained(filePath)
2302
+ return filePath
2303
+ }
2304
+
2305
+ /**
2306
+ * Reads a Resource's metadata sidecar. Resolves `undefined` when none has been
2307
+ * written yet (e.g. a Resource created before sidecars existed).
2308
+ * @param options {object}
2309
+ * @param options.collectionDir {string}
2310
+ * @param options.resourceId {string}
2311
+ * @returns {Promise<MetaSidecar|undefined>}
2312
+ */
2313
+ async _readMetaSidecar({
2314
+ collectionDir,
2315
+ resourceId
2316
+ }: {
2317
+ collectionDir: string
2318
+ resourceId: string
2319
+ }): Promise<MetaSidecar | undefined> {
2320
+ const metaStore = new MetadataJsonStore<MetaSidecar>({
2321
+ file: this._metaSidecarPath({ collectionDir, resourceId })
2322
+ })
2323
+ return await metaStore.read()
2324
+ }
2325
+
2326
+ /**
2327
+ * Writes a Resource's metadata sidecar (full replacement).
2328
+ * @param options {object}
2329
+ * @param options.collectionDir {string}
2330
+ * @param options.resourceId {string}
2331
+ * @param options.sidecar {MetaSidecar}
2332
+ * @returns {Promise<void>}
2333
+ */
2334
+ async _writeMetaSidecar({
2335
+ collectionDir,
2336
+ resourceId,
2337
+ sidecar
2338
+ }: {
2339
+ collectionDir: string
2340
+ resourceId: string
2341
+ sidecar: MetaSidecar
2342
+ }): Promise<void> {
2343
+ await atomicWriteFile({
2344
+ filePath: this._metaSidecarPath({ collectionDir, resourceId }),
2345
+ data: JSON.stringify(sidecar)
2346
+ })
2347
+ }
2348
+
2349
+ /**
2350
+ * Reads the metadata of a Resource's current representation: the REQUIRED
2351
+ * server-managed fields (`contentType`, `size`, both derived from the stored
2352
+ * file), plus the OPTIONAL `createdAt` / `updatedAt` timestamps and the
2353
+ * user-writable `custom` object read from the sidecar. For a Resource written
2354
+ * before sidecars existed, the timestamps fall back to the file's birth/modify
2355
+ * times and `custom` is omitted. Resolves `undefined` when the Resource is
2356
+ * absent (including a delete race on `stat`).
2357
+ *
2358
+ * Also surfaces the Resource's content `version` and its `metaVersion` (the
2359
+ * two ETag validators) when recorded in the sidecar, so the request layer can
2360
+ * set the `ETag` header: HEAD / the resource itself use the content `version`,
2361
+ * while `GET /meta` uses `metaVersion`. Both are out-of-band fields the request
2362
+ * layer reads for the header; neither is part of the Resource Metadata wire
2363
+ * body.
2364
+ * @param options {object}
2365
+ * @param options.spaceId {string}
2366
+ * @param options.collectionId {string}
2367
+ * @param options.resourceId {string}
2368
+ * @returns {Promise<(ResourceMetadata & { version?: number; metaVersion?:
2369
+ * number }) | undefined>}
2370
+ */
2371
+ async getResourceMetadata({
2372
+ spaceId,
2373
+ collectionId,
2374
+ resourceId
2375
+ }: {
2376
+ spaceId: string
2377
+ collectionId: string
2378
+ resourceId: string
2379
+ }): Promise<
2380
+ (ResourceMetadata & { version?: number; metaVersion?: number }) | undefined
2381
+ > {
2382
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2383
+ const filePath = await this._findFile({ collectionDir, resourceId })
2384
+ if (!filePath) {
2385
+ return undefined
2386
+ }
2387
+
2388
+ let stats
2389
+ try {
2390
+ stats = await fsStat(filePath)
2391
+ } catch (err) {
2392
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
2393
+ return undefined
2394
+ }
2395
+ throw err
2396
+ }
2397
+
2398
+ // Derive the stored content-type from the filename segment (the exact type
2399
+ // it was written under), as `getResource` does.
2400
+ const { contentType } = parseResourceFileName(path.basename(filePath))
2401
+
2402
+ const sidecar = await this._readMetaSidecar({ collectionDir, resourceId })
2403
+ const createdAt = sidecar?.createdAt ?? stats.birthtime.toISOString()
2404
+ const updatedAt = sidecar?.updatedAt ?? stats.mtime.toISOString()
2405
+ const hasCustom = sidecar?.custom && Object.keys(sidecar.custom).length > 0
2406
+
2407
+ return {
2408
+ contentType,
2409
+ size: stats.size,
2410
+ createdAt,
2411
+ updatedAt,
2412
+ // Absent for a Resource created before `createdBy` was recorded; there is
2413
+ // no stat-based fallback for it, as there is for the timestamps.
2414
+ ...(sidecar?.createdBy !== undefined && { createdBy: sidecar.createdBy }),
2415
+ // `custom` is returned verbatim -- `{ name, tags }` on a plaintext
2416
+ // Collection, the opaque encryption envelope on an encrypted one.
2417
+ ...(hasCustom && { custom: sidecar!.custom as ResourceMetadataCustom }),
2418
+ // The client-declared key epoch (the `key-epochs` feature), when stamped.
2419
+ ...(sidecar?.epoch !== undefined && { epoch: sidecar.epoch }),
2420
+ ...(sidecar?.version !== undefined && { version: sidecar.version }),
2421
+ ...(sidecar?.metaVersion !== undefined && {
2422
+ metaVersion: sidecar.metaVersion
2423
+ })
2424
+ }
2425
+ }
2426
+
2427
+ /**
2428
+ * Replaces the user-writable `custom` object of a Resource's metadata sidecar
2429
+ * (full replacement; `{}` clears it), bumping `updatedAt` and the independent
2430
+ * `metaVersion` (the `/meta` ETag). Does not create a Resource: resolves
2431
+ * `undefined` when the Resource is absent so the handler can 404. The content
2432
+ * `version` and the two REQUIRED server-managed fields are untouched (a
2433
+ * metadata write does not change the stored representation, preserving the
2434
+ * content ETag contract). On an encrypted Collection `custom` is the opaque
2435
+ * encryption envelope, stored verbatim.
2436
+ *
2437
+ * Runs under the per-Resource write lock -- the same lock content writes take
2438
+ * -- so an `If-Match` / `If-None-Match` precondition (evaluated on
2439
+ * `metaVersion`) is atomic with the write and serializes with concurrent
2440
+ * content/metadata writes to the same Resource. A precondition mismatch throws
2441
+ * `PreconditionFailedError` (412).
2442
+ * @param options {object}
2443
+ * @param options.spaceId {string}
2444
+ * @param options.collectionId {string}
2445
+ * @param options.resourceId {string}
2446
+ * @param options.custom {ResourceMetadataCustom | Record<string, unknown>}
2447
+ * @param [options.ifMatch] {string} `If-Match` on the current `metaVersion`
2448
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` -- write only if
2449
+ * no metadata has been written yet (`metaVersion` unset)
2450
+ * @returns {Promise<{ metaVersion: number } | undefined>} the new
2451
+ * `metaVersion`, or `undefined` when the Resource does not exist
2452
+ */
2453
+ async writeResourceMetadata({
2454
+ spaceId,
2455
+ collectionId,
2456
+ resourceId,
2457
+ custom,
2458
+ epoch,
2459
+ ifMatch,
2460
+ ifNoneMatch
2461
+ }: {
2462
+ spaceId: string
2463
+ collectionId: string
2464
+ resourceId: string
2465
+ custom: ResourceMetadataCustom | Record<string, unknown>
2466
+ epoch?: string
2467
+ ifMatch?: string
2468
+ ifNoneMatch?: boolean
2469
+ }): Promise<{ metaVersion: number } | undefined> {
2470
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2471
+ const writeMeta = async (): Promise<
2472
+ { metaVersion: number } | undefined
2473
+ > => {
2474
+ const filePath = await this._findFile({ collectionDir, resourceId })
2475
+ if (!filePath) {
2476
+ return undefined
2477
+ }
2478
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2479
+ // Evaluate the `/meta` precondition against the current `metaVersion`
2480
+ // atomically under the lock, before writing. `If-None-Match: *` means
2481
+ // "only if no metadata has been written yet"; `If-Match` pins the current
2482
+ // `metaVersion` ETag.
2483
+ assertMetaWritePrecondition({
2484
+ resourceId,
2485
+ metaVersion: prior?.metaVersion,
2486
+ ifMatch,
2487
+ ifNoneMatch
2488
+ })
2489
+
2490
+ const now = new Date().toISOString()
2491
+ // Fall back to the file's birth time for `createdAt` if the Resource
2492
+ // predates sidecars (so a meta write does not lose its creation time).
2493
+ let createdAt = prior?.createdAt
2494
+ if (!createdAt) {
2495
+ try {
2496
+ createdAt = (await fsStat(filePath)).birthtime.toISOString()
2497
+ } catch {
2498
+ createdAt = now
2499
+ }
2500
+ }
2501
+ const metaVersion = (prior?.metaVersion ?? 0) + 1
2502
+ const hasCustom = Object.keys(custom).length > 0
2503
+ // The key-epoch stamp describes the CONTENT write, not this metadata
2504
+ // write, so a supplied `epoch` replaces it but an omitted one PRESERVES
2505
+ // the stored value (unlike `custom`, which is full-replace).
2506
+ const resolvedEpoch = epoch ?? prior?.epoch
2507
+ await this._writeMetaSidecar({
2508
+ collectionDir,
2509
+ resourceId,
2510
+ sidecar: {
2511
+ createdAt,
2512
+ updatedAt: now,
2513
+ // Preserve the server-managed creator: a metadata write replaces only
2514
+ // `custom`, and `createdBy` is not user-writable.
2515
+ ...(prior?.createdBy !== undefined && {
2516
+ createdBy: prior.createdBy
2517
+ }),
2518
+ // Preserve the content `version` (ETag) -- a metadata write does not
2519
+ // change the stored representation.
2520
+ ...(prior?.version !== undefined && { version: prior.version }),
2521
+ metaVersion,
2522
+ ...(hasCustom && { custom }),
2523
+ ...(resolvedEpoch !== undefined && { epoch: resolvedEpoch })
2524
+ }
2525
+ })
2526
+ return { metaVersion }
2527
+ }
2528
+ return this._writeMutex.run(
2529
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
2530
+ writeMeta
2531
+ )
2532
+ }
2533
+
2534
+ /**
2535
+ * Soft-deletes a Resource: drops its content representation but keeps the
2536
+ * sidecar as a **tombstone** (`deleted: true`, a bumped `version` and
2537
+ * `updatedAt`, the last-known `contentType` retained) so the change feed
2538
+ * (replication) still surfaces it until clients catch up (GC of tombstones is
2539
+ * future work). With no content file left, the tombstone is invisible to
2540
+ * every normal read path
2541
+ * (`getResource` / `getResourceMetadata` / `listCollectionItems` all gate on
2542
+ * the content file via `_findFile`, so they 404 / skip it), making soft delete
2543
+ * transparent to the existing API.
2544
+ *
2545
+ * When `ifMatch` is supplied (the `conditional-writes` feature), the delete
2546
+ * proceeds only if the Resource exists and its current `ETag` matches, else
2547
+ * `PreconditionFailedError` (412). The whole read-modify-write runs under the
2548
+ * per-Resource write lock so it serializes with concurrent writes. The delete
2549
+ * is idempotent: an already-absent Resource (never created, or an existing
2550
+ * tombstone) is a no-op, leaving any tombstone's change-feed entry stable.
2551
+ * @param options {object}
2552
+ * @param options.spaceId {string}
2553
+ * @param options.collectionId {string}
2554
+ * @param options.resourceId {string}
2555
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
2556
+ * @returns {Promise<void>}
2557
+ */
2558
+ async deleteResource({
2559
+ spaceId,
2560
+ collectionId,
2561
+ resourceId,
2562
+ ifMatch
2563
+ }: {
2564
+ spaceId: string
2565
+ collectionId: string
2566
+ resourceId: string
2567
+ ifMatch?: string
2568
+ }): Promise<void> {
2569
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2570
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
2571
+ this._usageCache.delete(spaceId)
2572
+ const softDelete = async (): Promise<void> => {
2573
+ if (ifMatch !== undefined) {
2574
+ await this._assertWritePrecondition({
2575
+ collectionDir,
2576
+ resourceId,
2577
+ ifMatch
2578
+ })
2579
+ }
2580
+ // A Resource has a single current representation, so this normally matches
2581
+ // one file. The segment-anchored match in `_resourceFilesFor` keeps a
2582
+ // prefix id (e.g. `note` vs `notebook`) from being swept up too.
2583
+ const filesForResource = await this._resourceFilesFor({
2584
+ collectionDir,
2585
+ resourceId
2586
+ })
2587
+ if (filesForResource.length === 0) {
2588
+ // Already absent (never existed, or already a tombstone): idempotent
2589
+ // no-op. Leaving an existing tombstone untouched keeps its change-feed
2590
+ // entry (its `updatedAt` / `version`) stable.
2591
+ return
2592
+ }
2593
+ // Capture the representation's last-known content-type from its filename
2594
+ // before removing it: once the content file is gone the tombstone sidecar
2595
+ // is the only record of it, and the change feed reports it.
2596
+ const { contentType } = parseResourceFileName(
2597
+ path.basename(filesForResource[0]!)
2598
+ )
2599
+ // Drop the content representation(s) but KEEP the sidecar as the tombstone.
2600
+ await Promise.all(filesForResource.map(filename => rm(filename)))
2601
+ // Bump `version` / `updatedAt` so the tombstone sorts after the Resource's
2602
+ // prior state in the change feed, and continues the monotonic version (a
2603
+ // later re-create reads this sidecar and keeps counting up). `custom` is
2604
+ // dropped: the user Metadata goes with the deleted Resource. `createdAt` /
2605
+ // `createdBy` are kept: they are the server's record of the Resource's
2606
+ // origin, which a re-create under the same id continues.
2607
+ const now = new Date().toISOString()
2608
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2609
+ await this._writeMetaSidecar({
2610
+ collectionDir,
2611
+ resourceId,
2612
+ sidecar: {
2613
+ createdAt: prior?.createdAt ?? now,
2614
+ updatedAt: now,
2615
+ ...(prior?.createdBy !== undefined && {
2616
+ createdBy: prior.createdBy
2617
+ }),
2618
+ version: (prior?.version ?? 0) + 1,
2619
+ deleted: true,
2620
+ contentType
2621
+ }
2622
+ })
2623
+ }
2624
+ // The soft delete is a read-modify-write on the sidecar, so it always
2625
+ // serializes with concurrent writes under the per-Resource lock (not only
2626
+ // for a conditional delete, as the old unconditional removal did).
2627
+ return this._writeMutex.run(
2628
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
2629
+ softDelete
2630
+ )
2631
+ }
2632
+
2633
+ /**
2634
+ * Replication change feed (the `changes` query profile; see the
2635
+ * `StorageBackend.changesSince` contract.
2636
+ * Enumerates the Collection once, builds a lightweight descriptor for every
2637
+ * JSON-document Resource (live) and JSON tombstone, orders them by
2638
+ * `(updatedAt, resourceId)`, seeks strictly past `checkpoint`, takes a page of
2639
+ * `limit`, and reads JSON bodies ONLY for that page. O(n) over the Collection
2640
+ * per call (it must read every sidecar to order by `updatedAt`) -- acceptable
2641
+ * for this teaching backend; an indexed backend would answer it from an
2642
+ * `updatedAt` index.
2643
+ * @param options {object}
2644
+ * @param options.spaceId {string}
2645
+ * @param options.collectionId {string}
2646
+ * @param [options.checkpoint] {{ id: string, updatedAt: string }} resume position
2647
+ * @param options.limit {number} page cap (clamped to the backend maximum)
2648
+ * @returns {Promise<{ documents: Array<object>, checkpoint: object | null }>}
2649
+ */
2650
+ async changesSince({
2651
+ spaceId,
2652
+ collectionId,
2653
+ checkpoint,
2654
+ limit
2655
+ }: {
2656
+ spaceId: string
2657
+ collectionId: string
2658
+ checkpoint?: { id: string; updatedAt: string }
2659
+ limit: number
2660
+ }): Promise<{
2661
+ documents: Array<{
2662
+ resourceId: string
2663
+ version: number
2664
+ metaVersion?: number
2665
+ createdBy?: IDID
2666
+ updatedAt: string
2667
+ deleted: boolean
2668
+ data?: unknown
2669
+ custom?: ResourceMetadataCustom | Record<string, unknown>
2670
+ epoch?: string
2671
+ }>
2672
+ checkpoint: { id: string; updatedAt: string } | null
2673
+ }> {
2674
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2675
+
2676
+ let entries: fs.Dirent[] = []
2677
+ try {
2678
+ entries = await fs.promises.readdir(collectionDir, {
2679
+ withFileTypes: true
2680
+ })
2681
+ } catch (err) {
2682
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
2683
+ throw err
2684
+ }
2685
+ }
2686
+
2687
+ // Index the dir: live content files by id, and the set of ids that have a
2688
+ // `.meta.` sidecar (a sidecar with no live file is a tombstone candidate).
2689
+ const liveFileById = new Map<
2690
+ string,
2691
+ { fileName: string; contentType: string }
2692
+ >()
2693
+ const sidecarIds = new Set<string>()
2694
+ for (const entry of entries) {
2695
+ if (!entry.isFile()) {
2696
+ continue
2697
+ }
2698
+ if (entry.name.startsWith('r.')) {
2699
+ const { resourceId, contentType } = parseResourceFileName(entry.name)
2700
+ liveFileById.set(resourceId, { fileName: entry.name, contentType })
2701
+ } else {
2702
+ const sidecarId = metaSidecarFileId(entry.name)
2703
+ if (sidecarId !== undefined) {
2704
+ sidecarIds.add(sidecarId)
2705
+ }
2706
+ }
2707
+ }
2708
+
2709
+ // Build descriptors (no body reads yet): one per JSON-document Resource
2710
+ // (live) or JSON tombstone. Binary Resources and anomalous orphan sidecars
2711
+ // (sidecar present but not a tombstone) are excluded. Each sidecar read is
2712
+ // an independent file read, so the whole pass runs in parallel. A live
2713
+ // descriptor carries the `fileName` to read its body from; a tombstone has
2714
+ // none -- the discriminated union ties that to `deleted`.
2715
+ type Descriptor =
2716
+ | {
2717
+ resourceId: string
2718
+ version: number
2719
+ metaVersion?: number
2720
+ createdBy?: IDID
2721
+ updatedAt: string
2722
+ deleted: false
2723
+ fileName: string
2724
+ custom?: ResourceMetadataCustom | Record<string, unknown>
2725
+ epoch?: string
2726
+ }
2727
+ | {
2728
+ resourceId: string
2729
+ version: number
2730
+ metaVersion?: number
2731
+ createdBy?: IDID
2732
+ updatedAt: string
2733
+ deleted: true
2734
+ }
2735
+ const liveDescriptors = [...liveFileById].map(
2736
+ async ([resourceId, live]): Promise<Descriptor | undefined> => {
2737
+ if (!isJson({ contentType: live.contentType })) {
2738
+ return undefined
2739
+ }
2740
+ const sidecar = await this._readMetaSidecar({
2741
+ collectionDir,
2742
+ resourceId
2743
+ })
2744
+ // `updatedAt` / `version` come from the sidecar; fall back to the file's
2745
+ // mtime for a legacy Resource written before sidecars existed.
2746
+ let updatedAt = sidecar?.updatedAt
2747
+ if (!updatedAt) {
2748
+ try {
2749
+ updatedAt = (
2750
+ await fsStat(path.join(collectionDir, live.fileName))
2751
+ ).mtime.toISOString()
2752
+ } catch {
2753
+ return undefined
2754
+ }
2755
+ }
2756
+ return {
2757
+ resourceId,
2758
+ version: sidecar?.version ?? 0,
2759
+ ...(sidecar?.metaVersion !== undefined && {
2760
+ metaVersion: sidecar.metaVersion
2761
+ }),
2762
+ // The creator's DID rides the feed so provenance replicates with the
2763
+ // document, rather than needing a `/meta` fetch per Resource.
2764
+ ...(sidecar?.createdBy !== undefined && {
2765
+ createdBy: sidecar.createdBy
2766
+ }),
2767
+ updatedAt,
2768
+ deleted: false,
2769
+ fileName: live.fileName,
2770
+ // The user-writable `custom` (the opaque encryption envelope on an
2771
+ // encrypted Collection) rides the feed so metadata replicates
2772
+ // alongside content; read from the sidecar already loaded here.
2773
+ ...(sidecar?.custom !== undefined && { custom: sidecar.custom }),
2774
+ // The client-declared key epoch (the `key-epochs` feature) rides the
2775
+ // feed so a replicating reader picks the right epoch key.
2776
+ ...(sidecar?.epoch !== undefined && { epoch: sidecar.epoch })
2777
+ }
2778
+ }
2779
+ )
2780
+ // A sidecar with no live file is a tombstone candidate; keep only the ones
2781
+ // that are actually tombstones (`deleted: true`) and JSON.
2782
+ const tombstoneDescriptors = [...sidecarIds]
2783
+ .filter(resourceId => !liveFileById.has(resourceId))
2784
+ .map(async (resourceId): Promise<Descriptor | undefined> => {
2785
+ const sidecar = await this._readMetaSidecar({
2786
+ collectionDir,
2787
+ resourceId
2788
+ })
2789
+ if (
2790
+ sidecar?.deleted !== true ||
2791
+ !isJson({ contentType: sidecar.contentType })
2792
+ ) {
2793
+ return undefined
2794
+ }
2795
+ return {
2796
+ resourceId,
2797
+ version: sidecar.version ?? 0,
2798
+ ...(sidecar.metaVersion !== undefined && {
2799
+ metaVersion: sidecar.metaVersion
2800
+ }),
2801
+ // A tombstone keeps its creator, as it keeps its `createdAt`.
2802
+ ...(sidecar.createdBy !== undefined && {
2803
+ createdBy: sidecar.createdBy
2804
+ }),
2805
+ updatedAt: sidecar.updatedAt,
2806
+ deleted: true
2807
+ }
2808
+ })
2809
+ const descriptors = (
2810
+ await Promise.all([...liveDescriptors, ...tombstoneDescriptors])
2811
+ ).filter((desc): desc is Descriptor => desc !== undefined)
2812
+
2813
+ // Order by `(updatedAt, resourceId)` ascending -- the SAME total order the
2814
+ // checkpoint seek uses (ISO-8601 `updatedAt` sorts chronologically as a
2815
+ // string; `resourceId` breaks same-instant ties), so the keyset is stable.
2816
+ descriptors.sort(
2817
+ (left, right) =>
2818
+ compareCodeUnits(left.updatedAt, right.updatedAt) ||
2819
+ compareCodeUnits(left.resourceId, right.resourceId)
2820
+ )
2821
+
2822
+ // Seek to the first descriptor strictly after the checkpoint's position.
2823
+ let startIndex = 0
2824
+ if (checkpoint !== undefined) {
2825
+ const found = descriptors.findIndex(
2826
+ desc =>
2827
+ desc.updatedAt > checkpoint.updatedAt ||
2828
+ (desc.updatedAt === checkpoint.updatedAt &&
2829
+ desc.resourceId > checkpoint.id)
2830
+ )
2831
+ startIndex = found === -1 ? descriptors.length : found
2832
+ }
2833
+
2834
+ const pageSize = clampPageSize(limit)
2835
+ const pageDescriptors = descriptors.slice(startIndex, startIndex + pageSize)
2836
+
2837
+ // Read JSON bodies only for this page. A tombstone carries no `data` (the
2838
+ // delete replicates on `deleted: true` alone).
2839
+ const documents = await Promise.all(
2840
+ pageDescriptors.map(async desc => {
2841
+ if (desc.deleted) {
2842
+ return {
2843
+ resourceId: desc.resourceId,
2844
+ version: desc.version,
2845
+ ...(desc.metaVersion !== undefined && {
2846
+ metaVersion: desc.metaVersion
2847
+ }),
2848
+ ...(desc.createdBy !== undefined && { createdBy: desc.createdBy }),
2849
+ updatedAt: desc.updatedAt,
2850
+ deleted: true
2851
+ }
2852
+ }
2853
+ let data: unknown
2854
+ try {
2855
+ data = JSON.parse(
2856
+ await fs.promises.readFile(
2857
+ path.join(collectionDir, desc.fileName),
2858
+ 'utf8'
2859
+ )
2860
+ )
2861
+ } catch {
2862
+ data = undefined
2863
+ }
2864
+ return {
2865
+ resourceId: desc.resourceId,
2866
+ version: desc.version,
2867
+ ...(desc.metaVersion !== undefined && {
2868
+ metaVersion: desc.metaVersion
2869
+ }),
2870
+ ...(desc.createdBy !== undefined && { createdBy: desc.createdBy }),
2871
+ updatedAt: desc.updatedAt,
2872
+ deleted: false,
2873
+ data,
2874
+ // Surface the user-writable `custom` (opaque envelope on an encrypted
2875
+ // Collection) so a metadata-only edit replicates.
2876
+ ...(desc.custom !== undefined && { custom: desc.custom }),
2877
+ // Surface the client-declared key epoch (the `key-epochs` feature).
2878
+ ...(desc.epoch !== undefined && { epoch: desc.epoch })
2879
+ }
2880
+ })
2881
+ )
2882
+
2883
+ const last = documents[documents.length - 1]
2884
+ return {
2885
+ documents,
2886
+ checkpoint: last
2887
+ ? { id: last.resourceId, updatedAt: last.updatedAt }
2888
+ : null
2889
+ }
2890
+ }
2891
+
2892
+ /**
2893
+ * Blinded-index query (the `blinded-index` query profile; see the
2894
+ * `StorageBackend.queryByBlindedIndex` contract). Enumerates the Collection
2895
+ * dir, reads and parses every live JSON Resource, and hands the candidates
2896
+ * to the shared evaluator (`lib/blindedIndex.ts`) for matching, ordering,
2897
+ * and cursor pagination. O(n) over the Collection per call, with every JSON
2898
+ * body read -- acceptable for this teaching backend; an indexed backend
2899
+ * would answer from flattened attribute tokens. Tombstones are excluded
2900
+ * naturally (no live content file); binary Resources and unparsable JSON
2901
+ * are skipped.
2902
+ * @param options {object}
2903
+ * @param options.spaceId {string}
2904
+ * @param options.collectionId {string}
2905
+ * @param options.query {BlindedIndexQuery}
2906
+ * @param [options.count] {boolean} return only the match count
2907
+ * @param [options.limit] {number} requested page size
2908
+ * @param [options.cursor] {string} opaque cursor from a prior page
2909
+ * @returns {Promise<{ count: number } | BlindedIndexQueryPage>}
2910
+ */
2911
+ async queryByBlindedIndex({
2912
+ spaceId,
2913
+ collectionId,
2914
+ query,
2915
+ count,
2916
+ limit,
2917
+ cursor
2918
+ }: {
2919
+ spaceId: string
2920
+ collectionId: string
2921
+ query: BlindedIndexQuery
2922
+ count?: boolean
2923
+ limit?: number
2924
+ cursor?: string
2925
+ }): Promise<{ count: number } | BlindedIndexQueryPage> {
2926
+ const candidates = await this._readJsonCandidates({
2927
+ spaceId,
2928
+ collectionId
2929
+ })
2930
+ return runBlindedIndexQuery({ candidates, query, count, limit, cursor })
2931
+ }
2932
+
2933
+ /**
2934
+ * Reads and parses every live JSON Resource of a Collection -- the candidate
2935
+ * set for both the blinded-index query and the unique-attribute conflict
2936
+ * scan. Tombstones are excluded naturally (no live content file); binary
2937
+ * Resources, unparsable JSON, and (optionally) one excluded Resource are
2938
+ * skipped. An absent Collection dir resolves empty.
2939
+ * @param options {object}
2940
+ * @param options.spaceId {string}
2941
+ * @param options.collectionId {string}
2942
+ * @param [options.excludeResourceId] {string} omit this Resource (the
2943
+ * conflict scan excludes the document being written)
2944
+ * @returns {Promise<Array<{ resourceId: string, document: unknown }>>}
2945
+ */
2946
+ private async _readJsonCandidates({
2947
+ spaceId,
2948
+ collectionId,
2949
+ excludeResourceId
2950
+ }: {
2951
+ spaceId: string
2952
+ collectionId: string
2953
+ excludeResourceId?: string
2954
+ }): Promise<Array<{ resourceId: string; document: unknown }>> {
2955
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2956
+
2957
+ let entries: fs.Dirent[] = []
2958
+ try {
2959
+ entries = await fs.promises.readdir(collectionDir, {
2960
+ withFileTypes: true
2961
+ })
2962
+ } catch (err) {
2963
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
2964
+ throw err
2965
+ }
2966
+ }
2967
+
2968
+ return (
2969
+ await Promise.all(
2970
+ entries
2971
+ .filter(entry => entry.isFile() && entry.name.startsWith('r.'))
2972
+ .map(entry => ({
2973
+ fileName: entry.name,
2974
+ ...parseResourceFileName(entry.name)
2975
+ }))
2976
+ .filter(
2977
+ ({ resourceId, contentType }) =>
2978
+ resourceId !== excludeResourceId && isJson({ contentType })
2979
+ )
2980
+ .map(async ({ resourceId, fileName }) => {
2981
+ try {
2982
+ return {
2983
+ resourceId,
2984
+ document: JSON.parse(
2985
+ await fs.promises.readFile(
2986
+ path.join(collectionDir, fileName),
2987
+ 'utf8'
2988
+ )
2989
+ ) as unknown
2990
+ }
2991
+ } catch {
2992
+ return undefined
2993
+ }
2994
+ })
2995
+ )
2996
+ ).filter(candidate => candidate !== undefined)
2997
+ }
2998
+
2999
+ // Policies
3000
+
3001
+ /**
3002
+ * Builds the on-disk path for a policy document. Stored as a dot-file keyed by
3003
+ * the entity id, alongside the matching `.space.` / `.collection.` description:
3004
+ * Space policy in the space dir, Collection and Resource policy in the
3005
+ * collection dir (the keying id differs, so they never collide).
3006
+ * @param options {object}
3007
+ * @param options.spaceId {string}
3008
+ * @param [options.collectionId] {string}
3009
+ * @param [options.resourceId] {string}
3010
+ * @returns {string}
3011
+ */
3012
+ _policyFile({
3013
+ spaceId,
3014
+ collectionId,
3015
+ resourceId
3016
+ }: {
3017
+ spaceId: string
3018
+ collectionId?: string
3019
+ resourceId?: string
3020
+ }): string {
3021
+ const dir =
3022
+ collectionId !== undefined
3023
+ ? this._collectionDir({ spaceId, collectionId })
3024
+ : this._spaceDir(spaceId)
3025
+ const filename = `.policy.${resourceId ?? collectionId ?? spaceId}.json`
3026
+ const filePath = path.join(dir, filename)
3027
+ this._assertContained(filePath)
3028
+ return filePath
3029
+ }
3030
+
3031
+ /**
3032
+ * @param options {object}
3033
+ * @param options.spaceId {string}
3034
+ * @param [options.collectionId] {string}
3035
+ * @param [options.resourceId] {string}
3036
+ * @returns {Promise<PolicyDocument|undefined>}
3037
+ * Resolves falsy when no policy is set at that level (must not throw).
3038
+ */
3039
+ async getPolicy({
3040
+ spaceId,
3041
+ collectionId,
3042
+ resourceId
3043
+ }: {
3044
+ spaceId: string
3045
+ collectionId?: string
3046
+ resourceId?: string
3047
+ }): Promise<PolicyDocument | undefined> {
3048
+ const metaStore = new MetadataJsonStore<PolicyDocument>({
3049
+ file: this._policyFile({ spaceId, collectionId, resourceId })
3050
+ })
3051
+ return await metaStore.read()
3052
+ }
3053
+
3054
+ /**
3055
+ * @param options {object}
3056
+ * @param options.spaceId {string}
3057
+ * @param [options.collectionId] {string}
3058
+ * @param [options.resourceId] {string}
3059
+ * @param options.policy {PolicyDocument}
3060
+ * @returns {Promise<void>}
3061
+ */
3062
+ async writePolicy({
3063
+ spaceId,
3064
+ collectionId,
3065
+ resourceId,
3066
+ policy
3067
+ }: {
3068
+ spaceId: string
3069
+ collectionId?: string
3070
+ resourceId?: string
3071
+ policy: PolicyDocument
3072
+ }): Promise<void> {
3073
+ // Ensure the containing directory exists (Space or Collection dir).
3074
+ if (collectionId !== undefined) {
3075
+ await this._ensureCollectionDir({ spaceId, collectionId })
3076
+ } else {
3077
+ await this._ensureSpaceDir({ spaceId })
3078
+ }
3079
+ await atomicWriteFile({
3080
+ filePath: this._policyFile({ spaceId, collectionId, resourceId }),
3081
+ data: JSON.stringify(policy)
3082
+ })
3083
+ }
3084
+
3085
+ /**
3086
+ * @param options {object}
3087
+ * @param options.spaceId {string}
3088
+ * @param [options.collectionId] {string}
3089
+ * @param [options.resourceId] {string}
3090
+ * @returns {Promise<void>} idempotent (no error if absent)
3091
+ */
3092
+ async deletePolicy({
3093
+ spaceId,
3094
+ collectionId,
3095
+ resourceId
3096
+ }: {
3097
+ spaceId: string
3098
+ collectionId?: string
3099
+ resourceId?: string
3100
+ }): Promise<void> {
3101
+ await rm(this._policyFile({ spaceId, collectionId, resourceId }), {
3102
+ force: true
3103
+ })
3104
+ }
3105
+
3106
+ // Registered external backends (spec "Backends")
3107
+
3108
+ /**
3109
+ * Builds the on-disk path for a registered backend record: a
3110
+ * `.backend.<backendId>.json` dot-file in the Space dir (the same per-file
3111
+ * convention as `.policy.` / `.space.`). One file per backend id.
3112
+ * @param options {object}
3113
+ * @param options.spaceId {string}
3114
+ * @param options.backendId {string}
3115
+ * @returns {string}
3116
+ */
3117
+ _backendFile({
3118
+ spaceId,
3119
+ backendId
3120
+ }: {
3121
+ spaceId: string
3122
+ backendId: string
3123
+ }): string {
3124
+ const filePath = path.join(
3125
+ this._spaceDir(spaceId),
3126
+ `.backend.${backendId}.json`
3127
+ )
3128
+ this._assertContained(filePath)
3129
+ return filePath
3130
+ }
3131
+
3132
+ /**
3133
+ * Persists a full (secret-bearing) backend-registration record. Upsert.
3134
+ * @param options {object}
3135
+ * @param options.spaceId {string}
3136
+ * @param options.backendId {string}
3137
+ * @param options.record {StoredBackendRecord}
3138
+ * @returns {Promise<void>}
3139
+ */
3140
+ async writeBackend({
3141
+ spaceId,
3142
+ backendId,
3143
+ record
3144
+ }: {
3145
+ spaceId: string
3146
+ backendId: string
3147
+ record: StoredBackendRecord
3148
+ }): Promise<void> {
3149
+ await this._ensureSpaceDir({ spaceId })
3150
+ await atomicWriteFile({
3151
+ filePath: this._backendFile({ spaceId, backendId }),
3152
+ data: JSON.stringify(record)
3153
+ })
3154
+ }
3155
+
3156
+ /**
3157
+ * Reads the full (secret-bearing) record for one backend, for internal use
3158
+ * (existence checks, the future provider adapter). Resolves `undefined` when
3159
+ * absent. The only method that exposes secret connection material.
3160
+ * @param options {object}
3161
+ * @param options.spaceId {string}
3162
+ * @param options.backendId {string}
3163
+ * @returns {Promise<StoredBackendRecord|undefined>}
3164
+ */
3165
+ async getBackend({
3166
+ spaceId,
3167
+ backendId
3168
+ }: {
3169
+ spaceId: string
3170
+ backendId: string
3171
+ }): Promise<StoredBackendRecord | undefined> {
3172
+ const metaStore = new MetadataJsonStore<StoredBackendRecord>({
3173
+ file: this._backendFile({ spaceId, backendId })
3174
+ })
3175
+ return await metaStore.read()
3176
+ }
3177
+
3178
+ /**
3179
+ * Enumerates the Space's registered backends and returns them **sanitized**
3180
+ * (each mapped through `sanitizeBackendRecord`, so the secret connection
3181
+ * material never reaches the listing), sorted by id. An absent Space dir
3182
+ * reports no registered backends rather than throwing.
3183
+ * @param options {object}
3184
+ * @param options.spaceId {string}
3185
+ * @returns {Promise<BackendDescriptor[]>}
3186
+ */
3187
+ async listBackends({
3188
+ spaceId
3189
+ }: {
3190
+ spaceId: string
3191
+ }): Promise<BackendDescriptor[]> {
3192
+ const spaceDir = this._spaceDir(spaceId)
3193
+ let entries
3194
+ try {
3195
+ entries = await fs.promises.readdir(spaceDir, { withFileTypes: true })
3196
+ } catch (err) {
3197
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3198
+ return []
3199
+ }
3200
+ throw err
3201
+ }
3202
+ const backendFile = /^\.backend\.(.+)\.json$/
3203
+ const reads = entries
3204
+ .filter(entry => entry.isFile() && backendFile.test(entry.name))
3205
+ .map(entry =>
3206
+ new MetadataJsonStore<StoredBackendRecord>({
3207
+ file: path.join(spaceDir, entry.name)
3208
+ }).read()
3209
+ )
3210
+ const records = (await Promise.all(reads)).filter(
3211
+ (record): record is StoredBackendRecord => Boolean(record)
3212
+ )
3213
+ records.sort((a, b) => a.id.localeCompare(b.id))
3214
+ return records.map(sanitizeBackendRecord)
3215
+ }
3216
+
3217
+ /**
3218
+ * Removes a registered backend record. Idempotent (no error if absent).
3219
+ * @param options {object}
3220
+ * @param options.spaceId {string}
3221
+ * @param options.backendId {string}
3222
+ * @returns {Promise<void>}
3223
+ */
3224
+ async deleteBackend({
3225
+ spaceId,
3226
+ backendId
3227
+ }: {
3228
+ spaceId: string
3229
+ backendId: string
3230
+ }): Promise<void> {
3231
+ await rm(this._backendFile({ spaceId, backendId }), { force: true })
3232
+ }
3233
+
3234
+ /**
3235
+ * WebKMS keystores (the `/kms` facet). A sibling tree to Spaces:
3236
+ * `keystores/<localId>/` holds a keystore's `config.json` now, and its key
3237
+ * records / revocations in later tracks -- hence a directory per keystore
3238
+ * rather than a flat file.
3239
+ *
3240
+ * The directory holding one keystore's records, contained in `keystoresDir`.
3241
+ * @param keystoreId {string} the keystore's server-generated local id
3242
+ * @returns {string}
3243
+ */
3244
+ _keystoreDir(keystoreId: string): string {
3245
+ const keystoreDir = path.join(this.keystoresDir, keystoreId)
3246
+ this._assertContained(keystoreDir, this.keystoresDir)
3247
+ return keystoreDir
3248
+ }
3249
+
3250
+ _keystoreConfigFile(keystoreId: string): string {
3251
+ return path.join(this._keystoreDir(keystoreId), 'config.json')
3252
+ }
3253
+
3254
+ /**
3255
+ * Persists a keystore config unconditionally (the create path -- local ids
3256
+ * are server-generated random values, so create never collides). The
3257
+ * sequence-gated update path is `updateKeystore`.
3258
+ * @param options {object}
3259
+ * @param options.keystoreId {string} the keystore's local id
3260
+ * @param options.config {KeystoreConfig}
3261
+ * @returns {Promise<void>}
3262
+ */
3263
+ async writeKeystore({
3264
+ keystoreId,
3265
+ config
3266
+ }: {
3267
+ keystoreId: string
3268
+ config: KeystoreConfig
3269
+ }): Promise<void> {
3270
+ await mkdir(this._keystoreDir(keystoreId), { recursive: true })
3271
+ await atomicWriteFile({
3272
+ filePath: this._keystoreConfigFile(keystoreId),
3273
+ data: JSON.stringify(config)
3274
+ })
3275
+ }
3276
+
3277
+ /**
3278
+ * @param options {object}
3279
+ * @param options.keystoreId {string} the keystore's local id
3280
+ * @returns {Promise<KeystoreConfig|undefined>}
3281
+ * Resolves falsy when the keystore does not exist (must not throw).
3282
+ */
3283
+ async getKeystore({
3284
+ keystoreId
3285
+ }: {
3286
+ keystoreId: string
3287
+ }): Promise<KeystoreConfig | undefined> {
3288
+ const metaStore = new MetadataJsonStore<KeystoreConfig>({
3289
+ file: this._keystoreConfigFile(keystoreId)
3290
+ })
3291
+ return await metaStore.read()
3292
+ }
3293
+
3294
+ /**
3295
+ * Replaces a keystore config, gated atomically (under the per-keystore write
3296
+ * mutex) on: the keystore existing, `config.sequence` being exactly the
3297
+ * stored sequence + 1, and `config.kmsModule` matching the stored one (the
3298
+ * module is immutable). Any other state rejects with the protocol's single
3299
+ * merged 409 conflict.
3300
+ * @param options {object}
3301
+ * @param options.keystoreId {string} the keystore's local id
3302
+ * @param options.config {KeystoreConfig}
3303
+ * @returns {Promise<void>}
3304
+ */
3305
+ async updateKeystore({
3306
+ keystoreId,
3307
+ config
3308
+ }: {
3309
+ keystoreId: string
3310
+ config: KeystoreConfig
3311
+ }): Promise<void> {
3312
+ await this._writeMutex.run(`keystore:${keystoreId}`, async () => {
3313
+ const existing = await this.getKeystore({ keystoreId })
3314
+ if (
3315
+ !existing ||
3316
+ config.sequence !== existing.sequence + 1 ||
3317
+ config.kmsModule !== existing.kmsModule
3318
+ ) {
3319
+ throw new KeystoreStateConflictError()
3320
+ }
3321
+ await atomicWriteFile({
3322
+ filePath: this._keystoreConfigFile(keystoreId),
3323
+ data: JSON.stringify(config)
3324
+ })
3325
+ })
3326
+ }
3327
+
3328
+ /**
3329
+ * Every stored keystore config whose `controller` matches, sorted by local
3330
+ * id (the request layer caps the wire result). An absent keystores root
3331
+ * (nothing stored yet) resolves an empty list; a directory without a
3332
+ * readable config file is skipped.
3333
+ * @param options {object}
3334
+ * @param options.controller {IDID}
3335
+ * @returns {Promise<KeystoreConfig[]>}
3336
+ */
3337
+ async listKeystoresByController({
3338
+ controller
3339
+ }: {
3340
+ controller: IDID
3341
+ }): Promise<KeystoreConfig[]> {
3342
+ let rootEntries: fs.Dirent[]
3343
+ try {
3344
+ rootEntries = await fs.promises.readdir(this.keystoresDir, {
3345
+ withFileTypes: true
3346
+ })
3347
+ } catch (err) {
3348
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3349
+ return []
3350
+ }
3351
+ throw new StorageError({ cause: err as Error })
3352
+ }
3353
+ const keystoreEntries = rootEntries
3354
+ .filter(entry => entry.isDirectory())
3355
+ .sort((a, b) => a.name.localeCompare(b.name))
3356
+ const configs: KeystoreConfig[] = []
3357
+ for (const entry of keystoreEntries) {
3358
+ const config = await this.getKeystore({ keystoreId: entry.name })
3359
+ if (config && config.controller === controller) {
3360
+ configs.push(config)
3361
+ }
3362
+ }
3363
+ return configs
3364
+ }
3365
+
3366
+ /**
3367
+ * The file holding one key record, contained in its keystore's `keys/`
3368
+ * subdirectory. The record is a plain JSON file (not a metadata store):
3369
+ * records are immutable once inserted, so there is no read-modify-write to
3370
+ * protect.
3371
+ * @param options {object}
3372
+ * @param options.keystoreId {string} the owning keystore's local id
3373
+ * @param options.localId {string} the key's local id
3374
+ * @returns {string}
3375
+ */
3376
+ _keyFile({
3377
+ keystoreId,
3378
+ localId
3379
+ }: {
3380
+ keystoreId: string
3381
+ localId: string
3382
+ }): string {
3383
+ const keysDir = path.join(this._keystoreDir(keystoreId), 'keys')
3384
+ const keyFile = path.join(keysDir, `${localId}.json`)
3385
+ this._assertContained(keyFile, keysDir)
3386
+ return keyFile
3387
+ }
3388
+
3389
+ /**
3390
+ * Inserts a key record, create-only: the exclusive-create write (`wx`)
3391
+ * enforces the `(keystoreId, localId)` uniqueness atomically, rejecting a
3392
+ * duplicate with the protocol's 409 (`KeyIdConflictError`).
3393
+ * @param options {object}
3394
+ * @param options.keystoreId {string} the owning keystore's local id
3395
+ * @param options.localId {string} the key's local id
3396
+ * @param options.record {KmsKeyRecord} the full (secret-bearing) record
3397
+ * @returns {Promise<void>}
3398
+ */
3399
+ async insertKey({
3400
+ keystoreId,
3401
+ localId,
3402
+ record
3403
+ }: {
3404
+ keystoreId: string
3405
+ localId: string
3406
+ record: KmsKeyRecord
3407
+ }): Promise<void> {
3408
+ const keyFile = this._keyFile({ keystoreId, localId })
3409
+ await mkdir(path.dirname(keyFile), { recursive: true })
3410
+ try {
3411
+ // Durable, atomic create-only write: `atomicCreateFile` preserves the
3412
+ // `wx` uniqueness (an existing key file rejects with EEXIST) while also
3413
+ // fsyncing the record and its directory.
3414
+ await atomicCreateFile({
3415
+ filePath: keyFile,
3416
+ data: JSON.stringify(record, null, 2)
3417
+ })
3418
+ } catch (err) {
3419
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
3420
+ throw new KeyIdConflictError()
3421
+ }
3422
+ throw new StorageError({ cause: err as Error })
3423
+ }
3424
+ }
3425
+
3426
+ /**
3427
+ * @param options {object}
3428
+ * @param options.keystoreId {string} the owning keystore's local id
3429
+ * @param options.localId {string} the key's local id
3430
+ * @returns {Promise<KmsKeyRecord|undefined>}
3431
+ * Resolves falsy when the key does not exist (must not throw).
3432
+ */
3433
+ async getKey({
3434
+ keystoreId,
3435
+ localId
3436
+ }: {
3437
+ keystoreId: string
3438
+ localId: string
3439
+ }): Promise<KmsKeyRecord | undefined> {
3440
+ try {
3441
+ const raw = await fs.promises.readFile(
3442
+ this._keyFile({ keystoreId, localId }),
3443
+ 'utf8'
3444
+ )
3445
+ return JSON.parse(raw) as KmsKeyRecord
3446
+ } catch (err) {
3447
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3448
+ return undefined
3449
+ }
3450
+ throw new StorageError({ cause: err as Error })
3451
+ }
3452
+ }
3453
+
3454
+ /**
3455
+ * Every stored key record under the keystore (`keys/*.json`), sorted by local
3456
+ * id (the file name's stem). An absent keystore or `keys/` directory (no keys
3457
+ * yet) resolves an empty list; a non-`.json` entry is skipped. The record is
3458
+ * returned verbatim -- the at-rest cipher applies above the backend.
3459
+ * @param options {object}
3460
+ * @param options.keystoreId {string} the owning keystore's local id
3461
+ * @returns {Promise<Array<{ localId: string, record: KmsKeyRecord }>>}
3462
+ */
3463
+ async listKeys({
3464
+ keystoreId
3465
+ }: {
3466
+ keystoreId: string
3467
+ }): Promise<Array<{ localId: string; record: KmsKeyRecord }>> {
3468
+ const keysDir = path.join(this._keystoreDir(keystoreId), 'keys')
3469
+ let entries: fs.Dirent[]
3470
+ try {
3471
+ entries = await fs.promises.readdir(keysDir, { withFileTypes: true })
3472
+ } catch (err) {
3473
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3474
+ return []
3475
+ }
3476
+ throw new StorageError({ cause: err as Error })
3477
+ }
3478
+ const localIds = entries
3479
+ .filter(entry => entry.isFile() && entry.name.endsWith('.json'))
3480
+ .map(entry => entry.name.slice(0, -'.json'.length))
3481
+ .sort((a, b) => a.localeCompare(b))
3482
+ const keys: Array<{ localId: string; record: KmsKeyRecord }> = []
3483
+ for (const localId of localIds) {
3484
+ const record = await this.getKey({ keystoreId, localId })
3485
+ // A record readable at readdir time but gone by getKey (a concurrent
3486
+ // prune) is simply skipped; the listing is a snapshot, not a lock.
3487
+ if (record) {
3488
+ keys.push({ localId, record })
3489
+ }
3490
+ }
3491
+ return keys
3492
+ }
3493
+
3494
+ /**
3495
+ * The file holding one zcap revocation record. For a keystore scope it lives
3496
+ * in that keystore's `revocations/` subdirectory; for a Space scope it lives
3497
+ * under the sibling `spaceRevocationsDir` root (NOT inside the Space dir --
3498
+ * see the `spaceRevocationsDir` property doc). The file name folds the
3499
+ * `(delegator, capabilityId)` unique key into a SHA-256 digest (the shared
3500
+ * `revocationFileName` codec).
3501
+ * @param options {object}
3502
+ * @param options.scope {RevocationScope} the owning keystore or Space
3503
+ * @param options.delegator {string} the revoked capability's delegator
3504
+ * @param options.capabilityId {string} the revoked capability's id
3505
+ * @returns {string}
3506
+ */
3507
+ _revocationFile({
3508
+ scope,
3509
+ delegator,
3510
+ capabilityId
3511
+ }: {
3512
+ scope: RevocationScope
3513
+ delegator: string
3514
+ capabilityId: string
3515
+ }): string {
3516
+ const fileName = revocationFileName({ delegator, capabilityId })
3517
+ if ('keystoreId' in scope) {
3518
+ return path.join(
3519
+ this._keystoreDir(scope.keystoreId),
3520
+ 'revocations',
3521
+ fileName
3522
+ )
3523
+ }
3524
+ return path.join(this._spaceRevocationDir(scope.spaceId), fileName)
3525
+ }
3526
+
3527
+ /**
3528
+ * Inserts a revocation record, create-only: the exclusive-create write
3529
+ * (`wx`) enforces the `(delegator, capability.id)` uniqueness atomically,
3530
+ * rejecting a duplicate with the protocol's 409
3531
+ * (`DuplicateRevocationError`).
3532
+ * @param options {object}
3533
+ * @param options.scope {RevocationScope} the owning keystore or Space
3534
+ * @param options.record {RevocationRecord} the revocation to store
3535
+ * @returns {Promise<void>}
3536
+ */
3537
+ async insertRevocation({
3538
+ scope,
3539
+ record
3540
+ }: {
3541
+ scope: RevocationScope
3542
+ record: RevocationRecord
3543
+ }): Promise<void> {
3544
+ // The scope must already exist -- the postgres backend enforces this via
3545
+ // its foreign keys, so an absent-parent insert rejects identically on
3546
+ // both backends instead of mkdir-ing an orphan record dir here. (The HTTP
3547
+ // route 404-masks unknown scopes before reaching the store; this guards
3548
+ // direct backend use.)
3549
+ const scopeExists =
3550
+ 'keystoreId' in scope
3551
+ ? Boolean(await this.getKeystore({ keystoreId: scope.keystoreId }))
3552
+ : Boolean(await this.getSpaceDescription({ spaceId: scope.spaceId }))
3553
+ if (!scopeExists) {
3554
+ throw new StorageError({
3555
+ cause: new Error(
3556
+ 'Cannot insert a revocation under an absent keystore or Space.'
3557
+ )
3558
+ })
3559
+ }
3560
+ const revocationFile = this._revocationFile({
3561
+ scope,
3562
+ delegator: record.meta.delegator,
3563
+ capabilityId: record.capability.id
3564
+ })
3565
+ await mkdir(path.dirname(revocationFile), { recursive: true })
3566
+ try {
3567
+ // Durable, atomic create-only write: `atomicCreateFile` preserves the
3568
+ // `wx` uniqueness (an existing revocation rejects with EEXIST) while also
3569
+ // fsyncing the record and its directory.
3570
+ await atomicCreateFile({
3571
+ filePath: revocationFile,
3572
+ data: JSON.stringify(record, null, 2)
3573
+ })
3574
+ } catch (err) {
3575
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
3576
+ throw new DuplicateRevocationError()
3577
+ }
3578
+ throw new StorageError({ cause: err as Error })
3579
+ }
3580
+ }
3581
+
3582
+ /**
3583
+ * True when any of the given capabilities has a stored, unexpired
3584
+ * revocation under the scope (keystore or Space). A record past its
3585
+ * `meta.expires` GC horizon is pruned on the way through and counts as not
3586
+ * revoked -- the capability itself has expired, so verification already
3587
+ * rejects it on expiry (this is the filesystem analogue of a TTL index).
3588
+ * @param options {object}
3589
+ * @param options.scope {RevocationScope} the owning keystore or Space
3590
+ * @param options.capabilities {CapabilitySummary[]} the
3591
+ * `(capabilityId, delegator)` pairs to check
3592
+ * @returns {Promise<boolean>}
3593
+ */
3594
+ async isRevoked({
3595
+ scope,
3596
+ capabilities
3597
+ }: {
3598
+ scope: RevocationScope
3599
+ capabilities: CapabilitySummary[]
3600
+ }): Promise<boolean> {
3601
+ const now = Date.now()
3602
+ for (const { capabilityId, delegator } of capabilities) {
3603
+ const revocationFile = this._revocationFile({
3604
+ scope,
3605
+ delegator,
3606
+ capabilityId
3607
+ })
3608
+ let raw: string
3609
+ try {
3610
+ raw = await fs.promises.readFile(revocationFile, 'utf8')
3611
+ } catch (err) {
3612
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3613
+ continue
3614
+ }
3615
+ throw new StorageError({ cause: err as Error })
3616
+ }
3617
+ const record = JSON.parse(raw) as RevocationRecord
3618
+ if (record.meta.expires && Date.parse(record.meta.expires) <= now) {
3619
+ await rm(revocationFile, { force: true })
3620
+ continue
3621
+ }
3622
+ return true
3623
+ }
3624
+ return false
3625
+ }
3626
+ }