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