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,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request handlers for Collection operations: get/update/delete a Collection,
|
|
3
|
+
* list its items, and add a Resource to it.
|
|
4
|
+
*/
|
|
5
|
+
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
7
|
+
import type { ChangeDocument } from '@interop/storage-core'
|
|
8
|
+
|
|
9
|
+
import { buildLinkset } from '../policy.js'
|
|
10
|
+
import { fetchSpaceAndAuthorize, fetchSpaceAndVerify } from './spaceContext.js'
|
|
11
|
+
import { getCollectionOrThrow } from './collectionContext.js'
|
|
12
|
+
import { resolveResourceInput } from './resourceInput.js'
|
|
13
|
+
import { invokerDid } from '../auth-header-hooks.js'
|
|
14
|
+
import { assertValidIds } from '../lib/validateId.js'
|
|
15
|
+
import type { CollectionDescription, StorageBackend } from '../types.js'
|
|
16
|
+
import { parseBlindedIndexQueryBody } from '../lib/blindedIndex.js'
|
|
17
|
+
import {
|
|
18
|
+
assertSupportedBackend,
|
|
19
|
+
resolveBackendDescriptor,
|
|
20
|
+
DEFAULT_BACKEND_ID
|
|
21
|
+
} from '../lib/backends.js'
|
|
22
|
+
import {
|
|
23
|
+
assertSupportedEncryption,
|
|
24
|
+
assertEncryptionMarkerTransition,
|
|
25
|
+
assertEncryptedWriteConforms
|
|
26
|
+
} from '../lib/encryption.js'
|
|
27
|
+
import { parseKeyEpochHeader } from '../lib/keyEpoch.js'
|
|
28
|
+
import { resolveBackend } from '../lib/backendRegistry.js'
|
|
29
|
+
import {
|
|
30
|
+
collectionPath,
|
|
31
|
+
resourcePath,
|
|
32
|
+
linksetPath,
|
|
33
|
+
backendPath,
|
|
34
|
+
quotaPath,
|
|
35
|
+
queryPath
|
|
36
|
+
} from '../lib/paths.js'
|
|
37
|
+
import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
|
|
38
|
+
import {
|
|
39
|
+
InvalidCollectionError,
|
|
40
|
+
InvalidRequestBodyError,
|
|
41
|
+
UnsupportedOperationError,
|
|
42
|
+
rethrowOrWrapStorageError
|
|
43
|
+
} from '../errors.js'
|
|
44
|
+
|
|
45
|
+
export class CollectionRequest {
|
|
46
|
+
/**
|
|
47
|
+
* POST /space/:spaceId/:collectionId/
|
|
48
|
+
* Request handler for "Create Resource" request
|
|
49
|
+
* Before this, `parseAuthHeaders()` hook executed, resulting in:
|
|
50
|
+
* request.zcap: {
|
|
51
|
+
* keyId, headers, signature, created, expires, invocation, digest
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* @param request {import('fastify').FastifyRequest}
|
|
55
|
+
* @param reply {import('fastify').FastifyReply}
|
|
56
|
+
* @returns {Promise<FastifyReply>}
|
|
57
|
+
*/
|
|
58
|
+
static async post(
|
|
59
|
+
request: FastifyRequest<{
|
|
60
|
+
Params: { spaceId: string; collectionId: string }
|
|
61
|
+
}>,
|
|
62
|
+
reply: FastifyReply
|
|
63
|
+
): Promise<FastifyReply> {
|
|
64
|
+
const {
|
|
65
|
+
params: { spaceId, collectionId }
|
|
66
|
+
} = request
|
|
67
|
+
const { serverUrl, storage } = request.server
|
|
68
|
+
const requestName = 'Create Resource'
|
|
69
|
+
|
|
70
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
71
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
72
|
+
|
|
73
|
+
// Verify (capability-only): creating a Resource requires a valid capability
|
|
74
|
+
// invocation; no access-control-policy fallback.
|
|
75
|
+
await fetchSpaceAndVerify({
|
|
76
|
+
request,
|
|
77
|
+
spaceId,
|
|
78
|
+
targetPath: collectionPath({
|
|
79
|
+
spaceId,
|
|
80
|
+
collectionId,
|
|
81
|
+
trailingSlash: true
|
|
82
|
+
}),
|
|
83
|
+
requestName
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Fetch collection by id
|
|
87
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
88
|
+
storage,
|
|
89
|
+
spaceId,
|
|
90
|
+
collectionId,
|
|
91
|
+
requestName
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Fail-closed encryption enforcement: if the Collection declares a recognized
|
|
95
|
+
// `encryption` scheme, the content write MUST be a conforming envelope of it
|
|
96
|
+
// (right media type + envelope shape), else `encryption-scheme-mismatch`
|
|
97
|
+
// (422). Runs after auth (above) and the 404, before the body is resolved --
|
|
98
|
+
// so a wrong content type is rejected without consuming the upload, and the
|
|
99
|
+
// 422 is only observable by a caller already authorized to write here.
|
|
100
|
+
assertEncryptedWriteConforms({
|
|
101
|
+
collectionDescription,
|
|
102
|
+
contentType: request.headers['content-type'],
|
|
103
|
+
body: request.body
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// zCap checks out, continue
|
|
107
|
+
const resourceId = uuidv4()
|
|
108
|
+
let response: { id: string; 'content-type'?: string; url?: string }
|
|
109
|
+
let written: { version: number }
|
|
110
|
+
|
|
111
|
+
// Route resource bytes to the Collection's selected (data-plane) backend.
|
|
112
|
+
const dataBackend = await resolveBackend({
|
|
113
|
+
request,
|
|
114
|
+
spaceId,
|
|
115
|
+
collectionId,
|
|
116
|
+
collectionDescription
|
|
117
|
+
})
|
|
118
|
+
const input = await resolveResourceInput(request, dataBackend)
|
|
119
|
+
// A content write into an encrypted Collection MAY declare the key epoch it
|
|
120
|
+
// encrypted under via the `WAS-Key-Epoch` header (the `key-epochs` feature);
|
|
121
|
+
// the server stores it opaquely and clears it when absent (the new
|
|
122
|
+
// ciphertext's epoch is unknown). Advisory, non-signature-covered metadata.
|
|
123
|
+
const { epoch } = parseKeyEpochHeader({
|
|
124
|
+
headers: request.headers,
|
|
125
|
+
requestName
|
|
126
|
+
})
|
|
127
|
+
try {
|
|
128
|
+
written = await dataBackend.writeResource({
|
|
129
|
+
spaceId,
|
|
130
|
+
collectionId,
|
|
131
|
+
resourceId,
|
|
132
|
+
input,
|
|
133
|
+
createdBy: invokerDid(request),
|
|
134
|
+
epoch
|
|
135
|
+
})
|
|
136
|
+
response = {
|
|
137
|
+
id: resourceId,
|
|
138
|
+
'content-type': request.headers['content-type']
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const createdUrl = new URL(
|
|
145
|
+
resourcePath({ spaceId, collectionId, resourceId }),
|
|
146
|
+
serverUrl
|
|
147
|
+
).toString()
|
|
148
|
+
reply.header('Location', createdUrl)
|
|
149
|
+
// Surface the created Resource's ETag so a client can chain a conditional
|
|
150
|
+
// write (the conditional-writes feature).
|
|
151
|
+
reply.header('etag', formatEtag(written.version))
|
|
152
|
+
response.url = createdUrl
|
|
153
|
+
|
|
154
|
+
return reply.status(201).send(response)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* PUT /space/:spaceId/:collectionId
|
|
159
|
+
* Request handler for "Update (or Create By Id) Collection" request
|
|
160
|
+
* Before this, `parseAuthHeaders()` hook executed, resulting in:
|
|
161
|
+
* request.zcap: {
|
|
162
|
+
* keyId, headers, signature, created, expires, invocation, digest
|
|
163
|
+
* }
|
|
164
|
+
*
|
|
165
|
+
* @param request {import('fastify').FastifyRequest}
|
|
166
|
+
* @param reply {import('fastify').FastifyReply}
|
|
167
|
+
* @returns {Promise<FastifyReply>}
|
|
168
|
+
*/
|
|
169
|
+
static async put(
|
|
170
|
+
request: FastifyRequest<{
|
|
171
|
+
Params: { spaceId: string; collectionId: string }
|
|
172
|
+
Body: {
|
|
173
|
+
id?: string
|
|
174
|
+
name?: string
|
|
175
|
+
backend?: unknown
|
|
176
|
+
encryption?: unknown
|
|
177
|
+
}
|
|
178
|
+
}>,
|
|
179
|
+
reply: FastifyReply
|
|
180
|
+
): Promise<FastifyReply> {
|
|
181
|
+
const {
|
|
182
|
+
params: { spaceId, collectionId },
|
|
183
|
+
body
|
|
184
|
+
} = request
|
|
185
|
+
if (!body) {
|
|
186
|
+
throw new InvalidCollectionError()
|
|
187
|
+
}
|
|
188
|
+
const { storage } = request.server
|
|
189
|
+
const requestName = 'Update Collection'
|
|
190
|
+
|
|
191
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
192
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
193
|
+
|
|
194
|
+
// The Collection `id` is immutable: when the PUT body carries one, it must
|
|
195
|
+
// match the `{collection_id}` in the URL (spec spells this out for Update
|
|
196
|
+
// Space; applied here for parity). `invalid-request-body` (400).
|
|
197
|
+
if (body.id !== undefined && body.id !== collectionId) {
|
|
198
|
+
throw new InvalidRequestBodyError({
|
|
199
|
+
requestName,
|
|
200
|
+
detail: `Collection Description "id" (${body.id}) does not match the URL Collection id (${collectionId}).`,
|
|
201
|
+
pointer: '#/id'
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
// Validate the optional encryption marker (shape only); an absent
|
|
205
|
+
// `encryption` validates to `undefined` and leaves the existing marker
|
|
206
|
+
// untouched. The set-once immutability transition is enforced below, after
|
|
207
|
+
// the existing Collection is fetched (so an unauthorized caller cannot probe
|
|
208
|
+
// encryption state).
|
|
209
|
+
const suppliedEncryption = assertSupportedEncryption({
|
|
210
|
+
encryption: body.encryption,
|
|
211
|
+
requestName
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Verify (capability-only): updating a Collection requires a valid
|
|
215
|
+
// capability invocation; no access-control-policy fallback.
|
|
216
|
+
const { allowedTarget: collectionUrl } = await fetchSpaceAndVerify({
|
|
217
|
+
request,
|
|
218
|
+
spaceId,
|
|
219
|
+
targetPath: collectionPath({ spaceId, collectionId }),
|
|
220
|
+
requestName
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// zCap checks out, continue.
|
|
224
|
+
// Validate a supplied backend against the Space's backends-available (bad
|
|
225
|
+
// shape 400, unknown id 409). Checked AFTER verification (it reads the
|
|
226
|
+
// Space's registered backend ids) so an unauthorized caller cannot probe
|
|
227
|
+
// which ids are registered by distinguishing a 409 from the masked 404 --
|
|
228
|
+
// like the `id-conflict` / `encryption-immutable` conflict checks. An absent
|
|
229
|
+
// `backend` resolves to undefined so an update leaves the existing selection
|
|
230
|
+
// untouched; a create defaults it to the server default below.
|
|
231
|
+
const suppliedBackend =
|
|
232
|
+
body.backend !== undefined
|
|
233
|
+
? await assertSupportedBackend({
|
|
234
|
+
storage,
|
|
235
|
+
spaceId,
|
|
236
|
+
backend: body.backend,
|
|
237
|
+
requestName
|
|
238
|
+
})
|
|
239
|
+
: undefined
|
|
240
|
+
|
|
241
|
+
const existingCollection = await storage.getCollectionDescription({
|
|
242
|
+
spaceId,
|
|
243
|
+
collectionId
|
|
244
|
+
})
|
|
245
|
+
// The encryption marker is set-once (an update may declare one on a
|
|
246
|
+
// Collection that lacks it, but may not change/clear an existing one --
|
|
247
|
+
// `encryption-immutable` 409), and the key-epoch safety rails (the
|
|
248
|
+
// `key-epochs` feature) make epochs append-only with a `currentEpoch` that
|
|
249
|
+
// never moves backwards; recipient churn within an epoch stays free.
|
|
250
|
+
// Checked here, after verification, for a clean early rejection --
|
|
251
|
+
// re-evaluated atomically with the write via `assertTransition` below.
|
|
252
|
+
if (suppliedEncryption !== undefined) {
|
|
253
|
+
assertEncryptionMarkerTransition({
|
|
254
|
+
existing: existingCollection?.encryption,
|
|
255
|
+
incoming: suppliedEncryption
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
// `name`, `backend`, and `encryption` are optional. On update, only
|
|
259
|
+
// overwrite each when supplied (otherwise keep the existing value); on
|
|
260
|
+
// create, default `name` to the Collection id and `backend` to the server
|
|
261
|
+
// default (spec).
|
|
262
|
+
const collectionDescription = existingCollection
|
|
263
|
+
? // Existing: Update only the allowed fields
|
|
264
|
+
{
|
|
265
|
+
...existingCollection,
|
|
266
|
+
id: collectionId,
|
|
267
|
+
...(body.name !== undefined && { name: body.name }),
|
|
268
|
+
...(suppliedBackend !== undefined && { backend: suppliedBackend }),
|
|
269
|
+
...(suppliedEncryption !== undefined && {
|
|
270
|
+
encryption: suppliedEncryption
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
: // New Collection
|
|
274
|
+
{
|
|
275
|
+
id: collectionId,
|
|
276
|
+
type: ['Collection'],
|
|
277
|
+
name: body.name ?? collectionId,
|
|
278
|
+
backend: suppliedBackend ?? { id: DEFAULT_BACKEND_ID },
|
|
279
|
+
...(suppliedEncryption !== undefined && {
|
|
280
|
+
encryption: suppliedEncryption
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// `If-Match` (the `key-epochs` / conditional-Collection-write feature) makes
|
|
285
|
+
// a Collection Description update a compare-and-swap on its monotonic
|
|
286
|
+
// description version, so two clients concurrently editing the marker (e.g.
|
|
287
|
+
// both adding a recipient) cannot silently clobber one another. Opt-in: an
|
|
288
|
+
// unconditional PUT still upserts as before. Evaluated atomically with the
|
|
289
|
+
// write inside the backend; a stale validator surfaces as 412
|
|
290
|
+
// `precondition-failed` (rethrown unchanged).
|
|
291
|
+
const { ifMatch } = parseWritePreconditions(request.headers)
|
|
292
|
+
let written: { version: number }
|
|
293
|
+
try {
|
|
294
|
+
written = await storage.writeCollection({
|
|
295
|
+
spaceId,
|
|
296
|
+
collectionId,
|
|
297
|
+
collectionDescription,
|
|
298
|
+
createdBy: invokerDid(request),
|
|
299
|
+
...(ifMatch !== undefined && { ifMatch }),
|
|
300
|
+
// Re-evaluate the encryption-marker rails atomically with the write,
|
|
301
|
+
// against the prior the backend re-reads under its lock: the early
|
|
302
|
+
// check above ran against a pre-lock read, so without this a
|
|
303
|
+
// concurrent marker write in between could be silently clobbered (an
|
|
304
|
+
// appended epoch dropped by this full replacement) even though both
|
|
305
|
+
// writers passed the rails -- the append-only guarantee must hold
|
|
306
|
+
// unconditionally, not just under `If-Match`.
|
|
307
|
+
assertTransition: prior =>
|
|
308
|
+
assertEncryptionMarkerTransition({
|
|
309
|
+
existing: prior?.encryption,
|
|
310
|
+
incoming: collectionDescription.encryption
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
} catch (err) {
|
|
314
|
+
// Rethrow a typed ProblemError from the data-plane backend unchanged
|
|
315
|
+
// (e.g. a 507 quota / 412 precondition) rather than flattening it to a
|
|
316
|
+
// 500; wrap anything genuinely unexpected. `handleError` logs the 5xx once.
|
|
317
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
reply.header('Location', collectionUrl)
|
|
321
|
+
// Surface the new description ETag so a client can chain a conditional
|
|
322
|
+
// update (read-modify-CAS on the marker).
|
|
323
|
+
reply.header('etag', formatEtag(written.version))
|
|
324
|
+
return existingCollection
|
|
325
|
+
? reply.status(204).send() // update
|
|
326
|
+
: reply.status(201).send(collectionDescription) // create
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* GET /space/:spaceId/:collectionId (no trailing slash): Get Collection details
|
|
331
|
+
*
|
|
332
|
+
* @param request {import('fastify').FastifyRequest}
|
|
333
|
+
* @param reply {import('fastify').FastifyReply}
|
|
334
|
+
* @returns {Promise<FastifyReply>}
|
|
335
|
+
*/
|
|
336
|
+
static async get(
|
|
337
|
+
request: FastifyRequest<{
|
|
338
|
+
Params: { spaceId: string; collectionId: string }
|
|
339
|
+
}>,
|
|
340
|
+
reply: FastifyReply
|
|
341
|
+
): Promise<FastifyReply> {
|
|
342
|
+
const {
|
|
343
|
+
params: { spaceId, collectionId }
|
|
344
|
+
} = request
|
|
345
|
+
const { storage } = request.server
|
|
346
|
+
const requestName = 'Get Collection'
|
|
347
|
+
|
|
348
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
349
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
350
|
+
|
|
351
|
+
// Authorize (capability-or-policy): capability invocation first, then the
|
|
352
|
+
// effective access-control policy as a fallback (a public-readable Collection).
|
|
353
|
+
await fetchSpaceAndAuthorize({
|
|
354
|
+
request,
|
|
355
|
+
spaceId,
|
|
356
|
+
collectionId,
|
|
357
|
+
targetPath: collectionPath({ spaceId, collectionId }),
|
|
358
|
+
requestName
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
// Fetch collection by id
|
|
362
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
363
|
+
storage,
|
|
364
|
+
spaceId,
|
|
365
|
+
collectionId,
|
|
366
|
+
requestName
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// Advertise the Collection's self `url` and linkset (policy discovery) on
|
|
370
|
+
// the description; both relative, consistent with the other URL fields the
|
|
371
|
+
// API returns.
|
|
372
|
+
const url = collectionPath({ spaceId, collectionId })
|
|
373
|
+
const linkset = linksetPath({ spaceId, collectionId })
|
|
374
|
+
|
|
375
|
+
// Report the selected backend, default-filled for Collections created before
|
|
376
|
+
// the `backend` property existed (spec: an unset backend is `default`).
|
|
377
|
+
const backend = collectionDescription.backend ?? { id: DEFAULT_BACKEND_ID }
|
|
378
|
+
|
|
379
|
+
// The description `version` is the out-of-band ETag validator, not part of
|
|
380
|
+
// the Collection Description wire body, so strip it before serializing and
|
|
381
|
+
// surface it as the `ETag` header (so a client can read-modify-CAS the
|
|
382
|
+
// marker). Present only once the Collection has been written under
|
|
383
|
+
// versioning; a legacy Collection reports none.
|
|
384
|
+
const { descriptionVersion, ...descriptionBody } = collectionDescription
|
|
385
|
+
const getReply = reply.status(200).type('application/json')
|
|
386
|
+
if (descriptionVersion !== undefined) {
|
|
387
|
+
getReply.header('etag', formatEtag(descriptionVersion))
|
|
388
|
+
}
|
|
389
|
+
return getReply.send(
|
|
390
|
+
JSON.stringify({
|
|
391
|
+
...descriptionBody,
|
|
392
|
+
type: [...collectionDescription.type].sort(),
|
|
393
|
+
backend,
|
|
394
|
+
url,
|
|
395
|
+
linkset
|
|
396
|
+
} satisfies CollectionDescription)
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* GET /space/:spaceId/:collectionId/linkset
|
|
402
|
+
* Request handler for the Collection's linkset (RFC9264): advertises the
|
|
403
|
+
* Collection's access-control `policy` and selected `backend` resources for
|
|
404
|
+
* discovery. Readable by whoever may read the Collection (capability or
|
|
405
|
+
* fallback policy).
|
|
406
|
+
*
|
|
407
|
+
* @param request {import('fastify').FastifyRequest}
|
|
408
|
+
* @param reply {import('fastify').FastifyReply}
|
|
409
|
+
* @returns {Promise<FastifyReply>}
|
|
410
|
+
*/
|
|
411
|
+
static async linkset(
|
|
412
|
+
request: FastifyRequest<{
|
|
413
|
+
Params: { spaceId: string; collectionId: string }
|
|
414
|
+
}>,
|
|
415
|
+
reply: FastifyReply
|
|
416
|
+
): Promise<FastifyReply> {
|
|
417
|
+
const {
|
|
418
|
+
params: { spaceId, collectionId }
|
|
419
|
+
} = request
|
|
420
|
+
const { storage } = request.server
|
|
421
|
+
const requestName = 'Get Collection Linkset'
|
|
422
|
+
|
|
423
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
424
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
425
|
+
|
|
426
|
+
// Authorize (capability-or-policy): readable by whoever may read the
|
|
427
|
+
// Collection (capability invocation, else the effective policy).
|
|
428
|
+
await fetchSpaceAndAuthorize({
|
|
429
|
+
request,
|
|
430
|
+
spaceId,
|
|
431
|
+
collectionId,
|
|
432
|
+
targetPath: linksetPath({ spaceId, collectionId }),
|
|
433
|
+
requestName
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const linkset = await buildLinkset({ storage, spaceId, collectionId })
|
|
437
|
+
return reply
|
|
438
|
+
.status(200)
|
|
439
|
+
.type('application/linkset+json')
|
|
440
|
+
.send(JSON.stringify(linkset))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* GET /space/:spaceId/:collectionId/backend
|
|
445
|
+
* Request handler for "Collection Backend Selected": returns the detailed
|
|
446
|
+
* backend description object for the Collection's selected backend (resolved
|
|
447
|
+
* from the Collection's stored `{ id }` against the Space's backends-available;
|
|
448
|
+
* default-filled for Collections created before the property existed).
|
|
449
|
+
*
|
|
450
|
+
* Authorization is capability-or-policy, the same as Get Collection: the
|
|
451
|
+
* selected backend is no more sensitive than the Collection description, so a
|
|
452
|
+
* public-readable Collection may also read its backend.
|
|
453
|
+
*
|
|
454
|
+
* @param request {import('fastify').FastifyRequest}
|
|
455
|
+
* @param reply {import('fastify').FastifyReply}
|
|
456
|
+
* @returns {Promise<FastifyReply>}
|
|
457
|
+
*/
|
|
458
|
+
static async getBackend(
|
|
459
|
+
request: FastifyRequest<{
|
|
460
|
+
Params: { spaceId: string; collectionId: string }
|
|
461
|
+
}>,
|
|
462
|
+
reply: FastifyReply
|
|
463
|
+
): Promise<FastifyReply> {
|
|
464
|
+
const {
|
|
465
|
+
params: { spaceId, collectionId }
|
|
466
|
+
} = request
|
|
467
|
+
const { storage } = request.server
|
|
468
|
+
const requestName = 'Get Collection Backend'
|
|
469
|
+
|
|
470
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
471
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
472
|
+
|
|
473
|
+
// Authorize (capability-or-policy): readable by whoever may read the
|
|
474
|
+
// Collection (capability invocation, else the effective policy).
|
|
475
|
+
await fetchSpaceAndAuthorize({
|
|
476
|
+
request,
|
|
477
|
+
spaceId,
|
|
478
|
+
collectionId,
|
|
479
|
+
targetPath: backendPath({ spaceId, collectionId }),
|
|
480
|
+
requestName
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Fetch collection by id
|
|
484
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
485
|
+
storage,
|
|
486
|
+
spaceId,
|
|
487
|
+
collectionId,
|
|
488
|
+
requestName
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const backend = await resolveBackendDescriptor({
|
|
492
|
+
storage,
|
|
493
|
+
spaceId,
|
|
494
|
+
collectionDescription
|
|
495
|
+
})
|
|
496
|
+
return reply.status(200).type('application/json').send(backend)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* GET /space/:spaceId/:collectionId/quota
|
|
501
|
+
* Request handler for the per-Collection "Quotas" report (spec "Quotas"):
|
|
502
|
+
* the storage report for a single Collection, scoped to its backend (a single
|
|
503
|
+
* backend-usage entry whose `usageBytes` reflects only this Collection). A
|
|
504
|
+
* backend that cannot account per-Collection yields `unsupported-operation`
|
|
505
|
+
* (501); the filesystem backend supports it.
|
|
506
|
+
*
|
|
507
|
+
* Authorization is capability-or-policy, the same as the Space Quota report:
|
|
508
|
+
* a caller not authorized to read it receives a 404 (maximum-privacy
|
|
509
|
+
* invariant), and a public-readable Collection may read its quota.
|
|
510
|
+
*
|
|
511
|
+
* @param request {import('fastify').FastifyRequest}
|
|
512
|
+
* @param reply {import('fastify').FastifyReply}
|
|
513
|
+
* @returns {Promise<FastifyReply>}
|
|
514
|
+
*/
|
|
515
|
+
static async getQuota(
|
|
516
|
+
request: FastifyRequest<{
|
|
517
|
+
Params: { spaceId: string; collectionId: string }
|
|
518
|
+
}>,
|
|
519
|
+
reply: FastifyReply
|
|
520
|
+
): Promise<FastifyReply> {
|
|
521
|
+
const {
|
|
522
|
+
params: { spaceId, collectionId }
|
|
523
|
+
} = request
|
|
524
|
+
const { storage } = request.server
|
|
525
|
+
const requestName = 'Get Collection Quota'
|
|
526
|
+
|
|
527
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
528
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
529
|
+
|
|
530
|
+
// Authorize (capability-or-policy): readable by whoever may read the
|
|
531
|
+
// Collection (capability invocation, else the effective policy).
|
|
532
|
+
await fetchSpaceAndAuthorize({
|
|
533
|
+
request,
|
|
534
|
+
spaceId,
|
|
535
|
+
collectionId,
|
|
536
|
+
targetPath: quotaPath({ spaceId, collectionId }),
|
|
537
|
+
requestName
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// Fetch collection by id
|
|
541
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
542
|
+
storage,
|
|
543
|
+
spaceId,
|
|
544
|
+
collectionId,
|
|
545
|
+
requestName
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Report against the Collection's selected (data-plane) backend. A backend
|
|
549
|
+
// that cannot account per-Collection omits `reportCollectionUsage`; the spec
|
|
550
|
+
// sanctions a 501 there.
|
|
551
|
+
const dataBackend = await resolveBackend({
|
|
552
|
+
request,
|
|
553
|
+
spaceId,
|
|
554
|
+
collectionId,
|
|
555
|
+
collectionDescription
|
|
556
|
+
})
|
|
557
|
+
if (!dataBackend.reportCollectionUsage) {
|
|
558
|
+
throw new UnsupportedOperationError({ requestName })
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const usage = await dataBackend.reportCollectionUsage({
|
|
562
|
+
spaceId,
|
|
563
|
+
collectionId
|
|
564
|
+
})
|
|
565
|
+
return reply.status(200).type('application/json').send(usage)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* POST /space/:spaceId/:collectionId/query
|
|
570
|
+
* The reserved Collection `query` endpoint (spec "Collection-level reserved
|
|
571
|
+
* endpoints"). This server serves two profiles, selected by the body's
|
|
572
|
+
* `profile`:
|
|
573
|
+
*
|
|
574
|
+
* - `changes` -- the replication change feed: the Collection's JSON
|
|
575
|
+
* documents and tombstones changed strictly after `checkpoint`, in change
|
|
576
|
+
* order, capped at `limit`.
|
|
577
|
+
* - `blinded-index` -- the EDV blinded-attribute query (the
|
|
578
|
+
* `blinded-index-query` backend feature): `{index, equals | has, count,
|
|
579
|
+
* limit, cursor}` evaluated against the HMAC-blinded `indexed` entries of
|
|
580
|
+
* the Collection's stored documents, answering `{documents, hasMore,
|
|
581
|
+
* cursor?}` (matching documents verbatim, opaque-cursor paginated) or
|
|
582
|
+
* `{count}`.
|
|
583
|
+
*
|
|
584
|
+
* The query parameters ride the signed JSON POST body (covered by the
|
|
585
|
+
* `Digest`), so no `allowTargetQuery` is needed. A body naming any other
|
|
586
|
+
* profile, or a backend without the profile's method, yields
|
|
587
|
+
* `unsupported-operation` (501). Authorization is capability-or-policy, the
|
|
588
|
+
* same read semantics as List Collection: an under-authorized caller
|
|
589
|
+
* receives a 404.
|
|
590
|
+
*
|
|
591
|
+
* @param request {import('fastify').FastifyRequest}
|
|
592
|
+
* @param reply {import('fastify').FastifyReply}
|
|
593
|
+
* @returns {Promise<FastifyReply>}
|
|
594
|
+
*/
|
|
595
|
+
static async query(
|
|
596
|
+
request: FastifyRequest<{
|
|
597
|
+
Params: { spaceId: string; collectionId: string }
|
|
598
|
+
Body: {
|
|
599
|
+
profile?: string
|
|
600
|
+
checkpoint?: { id?: unknown; updatedAt?: unknown }
|
|
601
|
+
limit?: unknown
|
|
602
|
+
index?: unknown
|
|
603
|
+
equals?: unknown
|
|
604
|
+
has?: unknown
|
|
605
|
+
count?: unknown
|
|
606
|
+
cursor?: unknown
|
|
607
|
+
}
|
|
608
|
+
}>,
|
|
609
|
+
reply: FastifyReply
|
|
610
|
+
): Promise<FastifyReply> {
|
|
611
|
+
const {
|
|
612
|
+
params: { spaceId, collectionId },
|
|
613
|
+
body
|
|
614
|
+
} = request
|
|
615
|
+
const { storage } = request.server
|
|
616
|
+
const requestName = 'Collection Query'
|
|
617
|
+
|
|
618
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
619
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
620
|
+
|
|
621
|
+
// Authorize (capability-or-policy): readable by whoever may read the
|
|
622
|
+
// Collection (capability invocation, else the effective policy). The signed
|
|
623
|
+
// body is covered by the Digest, so the bare `/query` target authorizes it.
|
|
624
|
+
await fetchSpaceAndAuthorize({
|
|
625
|
+
request,
|
|
626
|
+
spaceId,
|
|
627
|
+
collectionId,
|
|
628
|
+
targetPath: queryPath({ spaceId, collectionId }),
|
|
629
|
+
requestName
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// Fetch collection by id
|
|
633
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
634
|
+
storage,
|
|
635
|
+
spaceId,
|
|
636
|
+
collectionId,
|
|
637
|
+
requestName
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
// Serve the query from the Collection's selected (data-plane) backend.
|
|
641
|
+
const dataBackend = await resolveBackend({
|
|
642
|
+
request,
|
|
643
|
+
spaceId,
|
|
644
|
+
collectionId,
|
|
645
|
+
collectionDescription
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
if (body?.profile === 'changes' && dataBackend.changesSince) {
|
|
649
|
+
return CollectionRequest._queryChanges({
|
|
650
|
+
reply,
|
|
651
|
+
dataBackend,
|
|
652
|
+
spaceId,
|
|
653
|
+
collectionId,
|
|
654
|
+
body,
|
|
655
|
+
requestName
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
if (body?.profile === 'blinded-index' && dataBackend.queryByBlindedIndex) {
|
|
659
|
+
// Validate/normalize the EDV query body fields (400 on a malformed
|
|
660
|
+
// query), then let the backend evaluate and paginate.
|
|
661
|
+
const parsed = parseBlindedIndexQueryBody({ body, requestName })
|
|
662
|
+
const result = await dataBackend.queryByBlindedIndex({
|
|
663
|
+
spaceId,
|
|
664
|
+
collectionId,
|
|
665
|
+
...parsed
|
|
666
|
+
})
|
|
667
|
+
return reply
|
|
668
|
+
.status(200)
|
|
669
|
+
.type('application/json')
|
|
670
|
+
.send(JSON.stringify(result))
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Any other profile, or a backend without the profile's method.
|
|
674
|
+
throw new UnsupportedOperationError({ requestName })
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The `changes` profile of the Collection `query` endpoint (see `query`
|
|
679
|
+
* above): parses the checkpoint/limit, pulls the page from the backend's
|
|
680
|
+
* change feed, and projects it to the wire shape.
|
|
681
|
+
*
|
|
682
|
+
* @param options {object}
|
|
683
|
+
* @param options.reply {import('fastify').FastifyReply}
|
|
684
|
+
* @param options.dataBackend {StorageBackend}
|
|
685
|
+
* @param options.spaceId {string}
|
|
686
|
+
* @param options.collectionId {string}
|
|
687
|
+
* @param options.body {object} the query POST body
|
|
688
|
+
* @param options.requestName {string}
|
|
689
|
+
* @returns {Promise<FastifyReply>}
|
|
690
|
+
*/
|
|
691
|
+
static async _queryChanges({
|
|
692
|
+
reply,
|
|
693
|
+
dataBackend,
|
|
694
|
+
spaceId,
|
|
695
|
+
collectionId,
|
|
696
|
+
body,
|
|
697
|
+
requestName
|
|
698
|
+
}: {
|
|
699
|
+
reply: FastifyReply
|
|
700
|
+
dataBackend: StorageBackend
|
|
701
|
+
spaceId: string
|
|
702
|
+
collectionId: string
|
|
703
|
+
body: {
|
|
704
|
+
checkpoint?: { id?: unknown; updatedAt?: unknown }
|
|
705
|
+
limit?: unknown
|
|
706
|
+
}
|
|
707
|
+
requestName: string
|
|
708
|
+
}): Promise<FastifyReply> {
|
|
709
|
+
// Parse the optional checkpoint: when present it must carry both string
|
|
710
|
+
// fields (a malformed one is a client error, 400). Absent = start of feed.
|
|
711
|
+
let checkpoint: { id: string; updatedAt: string } | undefined
|
|
712
|
+
if (body.checkpoint !== undefined) {
|
|
713
|
+
const { id, updatedAt } = body.checkpoint
|
|
714
|
+
if (typeof id !== 'string' || typeof updatedAt !== 'string') {
|
|
715
|
+
throw new InvalidRequestBodyError({
|
|
716
|
+
requestName,
|
|
717
|
+
detail: 'checkpoint must have string "id" and "updatedAt" fields.',
|
|
718
|
+
pointer: '#/checkpoint'
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
checkpoint = { id, updatedAt }
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Coerce `limit` (the requested batch size) to a positive integer, else
|
|
725
|
+
// default; the backend clamps an oversized value to its own maximum.
|
|
726
|
+
const DEFAULT_BATCH = 100
|
|
727
|
+
const parsedLimit = Number(body.limit)
|
|
728
|
+
const limit =
|
|
729
|
+
Number.isFinite(parsedLimit) && parsedLimit >= 1
|
|
730
|
+
? parsedLimit
|
|
731
|
+
: DEFAULT_BATCH
|
|
732
|
+
|
|
733
|
+
const result = await dataBackend.changesSince!({
|
|
734
|
+
spaceId,
|
|
735
|
+
collectionId,
|
|
736
|
+
...(checkpoint !== undefined && { checkpoint }),
|
|
737
|
+
limit
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
// Project the change feed to the wire shape: a tombstone's `deleted` becomes
|
|
741
|
+
// RxDB's `_deleted`, and the document body stays under `data` (kept out of
|
|
742
|
+
// the user JSON so arbitrary bodies -- not only objects -- round-trip). The
|
|
743
|
+
// user-writable `custom` (the opaque encryption envelope on an encrypted
|
|
744
|
+
// Collection) and its independent `metaVersion` ride along so a metadata-only
|
|
745
|
+
// edit replicates alongside content, as does the server-managed `createdBy`
|
|
746
|
+
// so a replica learns each Resource's creator without a `/meta` fetch per
|
|
747
|
+
// Resource. The RxDB browser adapter does the final reshape into RxDB
|
|
748
|
+
// documents.
|
|
749
|
+
const documents: ChangeDocument[] = result.documents.map(doc => ({
|
|
750
|
+
id: doc.resourceId,
|
|
751
|
+
_deleted: doc.deleted,
|
|
752
|
+
updatedAt: doc.updatedAt,
|
|
753
|
+
version: doc.version,
|
|
754
|
+
...(doc.metaVersion !== undefined && { metaVersion: doc.metaVersion }),
|
|
755
|
+
...(doc.createdBy !== undefined && { createdBy: doc.createdBy }),
|
|
756
|
+
...(doc.data !== undefined && { data: doc.data }),
|
|
757
|
+
...(doc.custom !== undefined && { custom: doc.custom }),
|
|
758
|
+
// The client-declared key epoch (the `key-epochs` feature) rides the feed
|
|
759
|
+
// so a replicating reader picks the right epoch key without a `/meta` fetch.
|
|
760
|
+
...(doc.epoch !== undefined && { epoch: doc.epoch })
|
|
761
|
+
}))
|
|
762
|
+
|
|
763
|
+
return reply
|
|
764
|
+
.status(200)
|
|
765
|
+
.type('application/json')
|
|
766
|
+
.send(JSON.stringify({ documents, checkpoint: result.checkpoint }))
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* DELETE /space/:spaceId/:collectionId
|
|
771
|
+
* Request handler for "Delete Collection" request
|
|
772
|
+
* Before this, `parseAuthHeaders()` hook executed, resulting in:
|
|
773
|
+
* request.zcap: {
|
|
774
|
+
* keyId, headers, signature, created, expires, invocation, digest
|
|
775
|
+
* }
|
|
776
|
+
*
|
|
777
|
+
* @param request {import('fastify').FastifyRequest}
|
|
778
|
+
* @param reply {import('fastify').FastifyReply}
|
|
779
|
+
* @returns {Promise<FastifyReply>}
|
|
780
|
+
*/
|
|
781
|
+
static async delete(
|
|
782
|
+
request: FastifyRequest<{
|
|
783
|
+
Params: { spaceId: string; collectionId: string }
|
|
784
|
+
}>,
|
|
785
|
+
reply: FastifyReply
|
|
786
|
+
): Promise<FastifyReply> {
|
|
787
|
+
const {
|
|
788
|
+
params: { spaceId, collectionId }
|
|
789
|
+
} = request
|
|
790
|
+
const { storage } = request.server
|
|
791
|
+
const requestName = 'Delete Collection'
|
|
792
|
+
|
|
793
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
794
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
795
|
+
|
|
796
|
+
// Verify (capability-only): deleting a Collection requires a valid
|
|
797
|
+
// capability invocation; no access-control-policy fallback.
|
|
798
|
+
await fetchSpaceAndVerify({
|
|
799
|
+
request,
|
|
800
|
+
spaceId,
|
|
801
|
+
targetPath: collectionPath({ spaceId, collectionId }),
|
|
802
|
+
requestName
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
await storage.deleteCollection({ spaceId, collectionId })
|
|
807
|
+
} catch (err) {
|
|
808
|
+
// Rethrow a typed ProblemError from the data-plane backend unchanged
|
|
809
|
+
// (e.g. a 507 quota / 412 precondition) rather than flattening it to a
|
|
810
|
+
// 500; wrap anything genuinely unexpected. `handleError` logs the 5xx once.
|
|
811
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return reply.status(204).send()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* GET /space/:spaceId/:collectionId/ (with trailing slash):
|
|
819
|
+
* List Collection items
|
|
820
|
+
*
|
|
821
|
+
* @param request {import('fastify').FastifyRequest}
|
|
822
|
+
* @param reply {import('fastify').FastifyReply}
|
|
823
|
+
* @returns {Promise<FastifyReply>}
|
|
824
|
+
*/
|
|
825
|
+
static async list(
|
|
826
|
+
request: FastifyRequest<{
|
|
827
|
+
Params: { spaceId: string; collectionId: string }
|
|
828
|
+
Querystring: { limit?: string; cursor?: string }
|
|
829
|
+
}>,
|
|
830
|
+
reply: FastifyReply
|
|
831
|
+
): Promise<FastifyReply> {
|
|
832
|
+
const {
|
|
833
|
+
params: { spaceId, collectionId },
|
|
834
|
+
query: { limit, cursor }
|
|
835
|
+
} = request
|
|
836
|
+
const { storage } = request.server
|
|
837
|
+
const requestName = 'List Collection'
|
|
838
|
+
|
|
839
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
840
|
+
assertValidIds({ spaceId, collectionId }, { requestName })
|
|
841
|
+
|
|
842
|
+
// Authorize (capability-or-policy): capability invocation first, then the
|
|
843
|
+
// effective access-control policy as a fallback (a public-readable Collection).
|
|
844
|
+
// `allowTargetQuery` lets the signed-request path tolerate the `?limit`/
|
|
845
|
+
// `cursor` pagination query parameters: per the spec they select a page
|
|
846
|
+
// within an already-authorized target and do not change the capability
|
|
847
|
+
// target. Authorization still runs before any cursor validation below, so an
|
|
848
|
+
// under-authorized caller gets the merged 404 -- never an `invalid-cursor`.
|
|
849
|
+
await fetchSpaceAndAuthorize({
|
|
850
|
+
request,
|
|
851
|
+
spaceId,
|
|
852
|
+
collectionId,
|
|
853
|
+
targetPath: collectionPath({
|
|
854
|
+
spaceId,
|
|
855
|
+
collectionId,
|
|
856
|
+
trailingSlash: true
|
|
857
|
+
}),
|
|
858
|
+
requestName,
|
|
859
|
+
allowTargetQuery: true
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
// Fetch collection by id
|
|
863
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
864
|
+
storage,
|
|
865
|
+
spaceId,
|
|
866
|
+
collectionId,
|
|
867
|
+
requestName
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
// Coerce `limit` (a query string) to a positive integer; a non-numeric or
|
|
871
|
+
// `< 1` value is ignored so the backend applies its own default. `cursor` is
|
|
872
|
+
// opaque and passed through verbatim -- the backend validates it and rejects
|
|
873
|
+
// a malformed one with `invalid-cursor` (400).
|
|
874
|
+
// List from the Collection's selected (data-plane) backend.
|
|
875
|
+
const dataBackend = await resolveBackend({
|
|
876
|
+
request,
|
|
877
|
+
spaceId,
|
|
878
|
+
collectionId,
|
|
879
|
+
collectionDescription
|
|
880
|
+
})
|
|
881
|
+
const parsedLimit = limit !== undefined ? Number(limit) : NaN
|
|
882
|
+
const collectionItems = await dataBackend.listCollectionItems({
|
|
883
|
+
spaceId,
|
|
884
|
+
collectionId,
|
|
885
|
+
// Pass the control-plane description: a data-plane (external) backend does
|
|
886
|
+
// not hold it, and the listing's `name`/`type`/encryption flag come from it.
|
|
887
|
+
collectionDescription,
|
|
888
|
+
...(Number.isFinite(parsedLimit) && parsedLimit >= 1
|
|
889
|
+
? { limit: parsedLimit }
|
|
890
|
+
: {}),
|
|
891
|
+
...(cursor !== undefined && { cursor })
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
return reply
|
|
895
|
+
.status(200)
|
|
896
|
+
.type('application/json')
|
|
897
|
+
.send(JSON.stringify(collectionItems))
|
|
898
|
+
}
|
|
899
|
+
}
|