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