was-teaching-server 0.10.0 → 0.11.0
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 +68 -0
- package/README.md +23 -11
- package/dist/backends/filesystem.d.ts +86 -2
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +220 -21
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +74 -2
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +175 -13
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +69 -82
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +13 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +22 -12
- package/dist/errors.js.map +1 -1
- package/dist/lib/equalityIndex.d.ts +334 -0
- package/dist/lib/equalityIndex.d.ts.map +1 -0
- package/dist/lib/equalityIndex.js +552 -0
- package/dist/lib/equalityIndex.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -2
- package/dist/plugin.js.map +1 -1
- package/dist/requests/CollectionRequest.d.ts +22 -5
- package/dist/requests/CollectionRequest.d.ts.map +1 -1
- package/dist/requests/CollectionRequest.js +192 -11
- package/dist/requests/CollectionRequest.js.map +1 -1
- package/dist/requests/ResourceRequest.d.ts.map +1 -1
- package/dist/requests/ResourceRequest.js +20 -0
- package/dist/requests/ResourceRequest.js.map +1 -1
- package/dist/requests/SpaceRequest.d.ts +1 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -1
- package/dist/requests/SpaceRequest.js +21 -1
- package/dist/requests/SpaceRequest.js.map +1 -1
- package/dist/types.d.ts +84 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/backends/filesystem.ts +286 -18
- package/src/backends/postgres.ts +247 -11
- package/src/errors.ts +25 -12
- package/src/lib/equalityIndex.ts +769 -0
- package/src/plugin.ts +5 -2
- package/src/requests/CollectionRequest.ts +221 -12
- package/src/requests/ResourceRequest.ts +20 -0
- package/src/requests/SpaceRequest.ts +25 -1
- package/src/types.ts +97 -0
package/src/plugin.ts
CHANGED
|
@@ -212,10 +212,13 @@ async function wasPlugin(
|
|
|
212
212
|
(onboardingToken ? onboardingTokenAuthorizer(onboardingToken) : undefined)
|
|
213
213
|
)
|
|
214
214
|
|
|
215
|
-
// Disable CORS
|
|
215
|
+
// Disable CORS. `exposedHeaders` is required for browser clients: without
|
|
216
|
+
// it, cross-origin JS cannot read `Location` (space/resource creation),
|
|
217
|
+
// `ETag` (metaVersion concurrency), or `Link` (pagination, policy linksets).
|
|
216
218
|
fastify.register(cors, {
|
|
217
219
|
origin: '*',
|
|
218
|
-
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
220
|
+
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
221
|
+
exposedHeaders: ['Location', 'ETag', 'Link']
|
|
219
222
|
})
|
|
220
223
|
|
|
221
224
|
// Multipart file uploading. The cap is `files: 2`, not `1`: a write MUST carry
|
|
@@ -14,6 +14,12 @@ import { invokerDid } from '../auth-header-hooks.js'
|
|
|
14
14
|
import { assertValidIds } from '../lib/validateId.js'
|
|
15
15
|
import type { CollectionDescription, StorageBackend } from '../types.js'
|
|
16
16
|
import { parseBlindedIndexQueryBody } from '../lib/blindedIndex.js'
|
|
17
|
+
import {
|
|
18
|
+
assertSupportedIndexes,
|
|
19
|
+
normalizeIndexes,
|
|
20
|
+
parseEqualityQueryBody,
|
|
21
|
+
parseListFilter
|
|
22
|
+
} from '../lib/equalityIndex.js'
|
|
17
23
|
import {
|
|
18
24
|
assertSupportedBackend,
|
|
19
25
|
resolveBackendDescriptor,
|
|
@@ -39,8 +45,45 @@ import {
|
|
|
39
45
|
InvalidCollectionError,
|
|
40
46
|
InvalidRequestBodyError,
|
|
41
47
|
UnsupportedOperationError,
|
|
48
|
+
UniqueAttributeConflictError,
|
|
42
49
|
rethrowOrWrapStorageError
|
|
43
50
|
} from '../errors.js'
|
|
51
|
+
import type {
|
|
52
|
+
CollectionIndexDeclaration,
|
|
53
|
+
NormalizedIndexDeclaration
|
|
54
|
+
} from '../types.js'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The normalized `unique: true` declarations an `indexes` update ADDS -- names
|
|
58
|
+
* that are unique in the incoming declaration but were not unique (declared, or
|
|
59
|
+
* declared without `unique`) in the existing one. These are the claims a
|
|
60
|
+
* declare-time conflict scan must verify against already-stored Resources; an
|
|
61
|
+
* unchanged or removed unique claim needs no scan (it was enforced at write
|
|
62
|
+
* time).
|
|
63
|
+
*
|
|
64
|
+
* @param options {object}
|
|
65
|
+
* @param [options.existing] {Array<string | CollectionIndexDeclaration>} the
|
|
66
|
+
* Collection's previously-stored `indexes`
|
|
67
|
+
* @param [options.incoming] {Array<string | CollectionIndexDeclaration>} the
|
|
68
|
+
* `indexes` about to be persisted
|
|
69
|
+
* @returns {NormalizedIndexDeclaration[]}
|
|
70
|
+
*/
|
|
71
|
+
function newlyUniqueDeclarations({
|
|
72
|
+
existing,
|
|
73
|
+
incoming
|
|
74
|
+
}: {
|
|
75
|
+
existing?: Array<string | CollectionIndexDeclaration>
|
|
76
|
+
incoming?: Array<string | CollectionIndexDeclaration>
|
|
77
|
+
}): NormalizedIndexDeclaration[] {
|
|
78
|
+
const existingUnique = new Set(
|
|
79
|
+
normalizeIndexes({ indexes: existing })
|
|
80
|
+
.filter(declaration => declaration.unique)
|
|
81
|
+
.map(declaration => declaration.name)
|
|
82
|
+
)
|
|
83
|
+
return normalizeIndexes({ indexes: incoming }).filter(
|
|
84
|
+
declaration => declaration.unique && !existingUnique.has(declaration.name)
|
|
85
|
+
)
|
|
86
|
+
}
|
|
44
87
|
|
|
45
88
|
export class CollectionRequest {
|
|
46
89
|
/**
|
|
@@ -124,6 +167,14 @@ export class CollectionRequest {
|
|
|
124
167
|
headers: request.headers,
|
|
125
168
|
requestName
|
|
126
169
|
})
|
|
170
|
+
// When the Collection declares any `unique: true` index entries (the
|
|
171
|
+
// `equality-query` feature), pass the normalized unique entries so the
|
|
172
|
+
// backend enforces the plaintext uniqueness claim atomically with the write
|
|
173
|
+
// (409). Write authorization has already run, so the existence-revealing 409
|
|
174
|
+
// is observable only to a caller authorized to write here.
|
|
175
|
+
const uniqueIndexes = normalizeIndexes({
|
|
176
|
+
indexes: collectionDescription.indexes
|
|
177
|
+
}).filter(declaration => declaration.unique)
|
|
127
178
|
try {
|
|
128
179
|
written = await dataBackend.writeResource({
|
|
129
180
|
spaceId,
|
|
@@ -131,7 +182,8 @@ export class CollectionRequest {
|
|
|
131
182
|
resourceId,
|
|
132
183
|
input,
|
|
133
184
|
createdBy: invokerDid(request),
|
|
134
|
-
epoch
|
|
185
|
+
epoch,
|
|
186
|
+
...(uniqueIndexes.length > 0 && { uniqueIndexes })
|
|
135
187
|
})
|
|
136
188
|
response = {
|
|
137
189
|
id: resourceId,
|
|
@@ -174,6 +226,7 @@ export class CollectionRequest {
|
|
|
174
226
|
name?: string
|
|
175
227
|
backend?: unknown
|
|
176
228
|
encryption?: unknown
|
|
229
|
+
indexes?: unknown
|
|
177
230
|
}
|
|
178
231
|
}>,
|
|
179
232
|
reply: FastifyReply
|
|
@@ -210,6 +263,16 @@ export class CollectionRequest {
|
|
|
210
263
|
encryption: body.encryption,
|
|
211
264
|
requestName
|
|
212
265
|
})
|
|
266
|
+
// Validate the optional `indexes` declaration (shape only); an absent
|
|
267
|
+
// `indexes` validates to `undefined` and leaves the stored declaration
|
|
268
|
+
// untouched, while a supplied array (an empty one clears it) replaces it --
|
|
269
|
+
// `indexes` is updatable, unlike the set-once `encryption` marker. The
|
|
270
|
+
// mutual-exclusion-with-encryption rail and the unique-add conflict scan are
|
|
271
|
+
// enforced below, against the description about to be persisted.
|
|
272
|
+
const suppliedIndexes = assertSupportedIndexes({
|
|
273
|
+
indexes: body.indexes,
|
|
274
|
+
requestName
|
|
275
|
+
})
|
|
213
276
|
|
|
214
277
|
// Verify (capability-only): updating a Collection requires a valid
|
|
215
278
|
// capability invocation; no access-control-policy fallback.
|
|
@@ -268,7 +331,8 @@ export class CollectionRequest {
|
|
|
268
331
|
...(suppliedBackend !== undefined && { backend: suppliedBackend }),
|
|
269
332
|
...(suppliedEncryption !== undefined && {
|
|
270
333
|
encryption: suppliedEncryption
|
|
271
|
-
})
|
|
334
|
+
}),
|
|
335
|
+
...(suppliedIndexes !== undefined && { indexes: suppliedIndexes })
|
|
272
336
|
}
|
|
273
337
|
: // New Collection
|
|
274
338
|
{
|
|
@@ -278,8 +342,58 @@ export class CollectionRequest {
|
|
|
278
342
|
backend: suppliedBackend ?? { id: DEFAULT_BACKEND_ID },
|
|
279
343
|
...(suppliedEncryption !== undefined && {
|
|
280
344
|
encryption: suppliedEncryption
|
|
281
|
-
})
|
|
345
|
+
}),
|
|
346
|
+
...(suppliedIndexes !== undefined && { indexes: suppliedIndexes })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Mutual exclusion (spec "Collection Data Model"): the description about to
|
|
350
|
+
// be persisted MUST NOT carry both a non-empty `indexes` and an `encryption`
|
|
351
|
+
// marker -- the server cannot extract plaintext attributes from an opaque
|
|
352
|
+
// envelope. Enforced in BOTH directions (adding `indexes` to an encrypted
|
|
353
|
+
// Collection, or `encryption` to an indexed one) against the merged
|
|
354
|
+
// description, so a pre-existing value on the other field is caught too.
|
|
355
|
+
if (
|
|
356
|
+
Array.isArray(collectionDescription.indexes) &&
|
|
357
|
+
collectionDescription.indexes.length > 0 &&
|
|
358
|
+
collectionDescription.encryption !== undefined
|
|
359
|
+
) {
|
|
360
|
+
throw new InvalidRequestBodyError({
|
|
361
|
+
requestName,
|
|
362
|
+
detail:
|
|
363
|
+
'Collection "indexes" must not be combined with an "encryption" marker.',
|
|
364
|
+
pointer: '#/indexes'
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Adding a `unique: true` claim for a name that was not unique before MUST
|
|
369
|
+
// be rejected if the Collection's already-stored Resources already violate
|
|
370
|
+
// it (spec "Collection Data Model"). Scan for a pre-existing conflict before
|
|
371
|
+
// acknowledging the update, when the data-plane backend supports the scan.
|
|
372
|
+
// Best-effort under concurrency (like the count-quota checks): a Resource
|
|
373
|
+
// write racing this update could still slip a conflicting value in, which
|
|
374
|
+
// the write-time uniqueness check then rejects.
|
|
375
|
+
const newlyUnique = newlyUniqueDeclarations({
|
|
376
|
+
existing: existingCollection?.indexes,
|
|
377
|
+
incoming: collectionDescription.indexes
|
|
378
|
+
})
|
|
379
|
+
if (newlyUnique.length > 0) {
|
|
380
|
+
const dataBackend = await resolveBackend({
|
|
381
|
+
request,
|
|
382
|
+
spaceId,
|
|
383
|
+
collectionId,
|
|
384
|
+
collectionDescription
|
|
385
|
+
})
|
|
386
|
+
if (dataBackend.findEqualityUniqueViolation) {
|
|
387
|
+
const violation = await dataBackend.findEqualityUniqueViolation({
|
|
388
|
+
spaceId,
|
|
389
|
+
collectionId,
|
|
390
|
+
indexes: newlyUnique
|
|
391
|
+
})
|
|
392
|
+
if (violation) {
|
|
393
|
+
throw new UniqueAttributeConflictError({ variant: 'equality' })
|
|
282
394
|
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
283
397
|
|
|
284
398
|
// `If-Match` (the `key-epochs` / conditional-Collection-write feature) makes
|
|
285
399
|
// a Collection Description update a compare-and-swap on its monotonic
|
|
@@ -580,6 +694,12 @@ export class CollectionRequest {
|
|
|
580
694
|
* the Collection's stored documents, answering `{documents, hasMore,
|
|
581
695
|
* cursor?}` (matching documents verbatim, opaque-cursor paginated) or
|
|
582
696
|
* `{count}`.
|
|
697
|
+
* - `equality` -- the plaintext equality query (the `equality-query` backend
|
|
698
|
+
* feature): `{equals | has, count, limit, cursor}` evaluated against the
|
|
699
|
+
* attributes the server extracts from the Collection's Resources per its
|
|
700
|
+
* declared `indexes`, answering `{documents, hasMore, cursor?}` (each
|
|
701
|
+
* document `{id, data?, custom?}`) or `{count}`. Only plaintext Collections
|
|
702
|
+
* serve it; an encrypted Collection answers `unsupported-operation` (501).
|
|
583
703
|
*
|
|
584
704
|
* The query parameters ride the signed JSON POST body (covered by the
|
|
585
705
|
* `Digest`), so no `allowTargetQuery` is needed. A body naming any other
|
|
@@ -669,6 +789,32 @@ export class CollectionRequest {
|
|
|
669
789
|
.type('application/json')
|
|
670
790
|
.send(JSON.stringify(result))
|
|
671
791
|
}
|
|
792
|
+
if (body?.profile === 'equality' && dataBackend.queryByEquality) {
|
|
793
|
+
// The `equality` profile applies only to plaintext Collections: an
|
|
794
|
+
// encrypted Collection's documents are opaque envelopes the server cannot
|
|
795
|
+
// extract attributes from, so it answers `unsupported-operation` (501).
|
|
796
|
+
if (collectionDescription.encryption !== undefined) {
|
|
797
|
+
throw new UnsupportedOperationError({ requestName })
|
|
798
|
+
}
|
|
799
|
+
// Resolve the declared indexes off the control-plane description: an
|
|
800
|
+
// undeclared/empty declaration means every named attribute fails the
|
|
801
|
+
// fail-closed declared-names check (400). Parse/validate the query body
|
|
802
|
+
// against it, then let the backend extract, match, and paginate.
|
|
803
|
+
const indexes = normalizeIndexes({
|
|
804
|
+
indexes: collectionDescription.indexes
|
|
805
|
+
})
|
|
806
|
+
const parsed = parseEqualityQueryBody({ body, indexes, requestName })
|
|
807
|
+
const result = await dataBackend.queryByEquality({
|
|
808
|
+
spaceId,
|
|
809
|
+
collectionId,
|
|
810
|
+
indexes,
|
|
811
|
+
...parsed
|
|
812
|
+
})
|
|
813
|
+
return reply
|
|
814
|
+
.status(200)
|
|
815
|
+
.type('application/json')
|
|
816
|
+
.send(JSON.stringify(result))
|
|
817
|
+
}
|
|
672
818
|
|
|
673
819
|
// Any other profile, or a backend without the profile's method.
|
|
674
820
|
throw new UnsupportedOperationError({ requestName })
|
|
@@ -816,7 +962,20 @@ export class CollectionRequest {
|
|
|
816
962
|
|
|
817
963
|
/**
|
|
818
964
|
* GET /space/:spaceId/:collectionId/ (with trailing slash):
|
|
819
|
-
* List Collection items
|
|
965
|
+
* List Collection items.
|
|
966
|
+
*
|
|
967
|
+
* With one or more `filter[<attr>]=<value>` query parameters this becomes the
|
|
968
|
+
* anonymous-cacheable entry point over the same equality machinery as the
|
|
969
|
+
* POST `equality` query profile: the filters map to a single-element `equals`
|
|
970
|
+
* conjunction (string-valued equality only) and the handler answers the same
|
|
971
|
+
* `{documents, hasMore, cursor?}` page. Authorization is the ordinary
|
|
972
|
+
* capability-or-policy GET path (a `PublicCanRead` Collection answers a filter
|
|
973
|
+
* query anonymously, so an HTTP cache can serve it); `allowTargetQuery`
|
|
974
|
+
* already tolerates the query string. Every filter attribute MUST be declared
|
|
975
|
+
* in the Collection's `indexes` (fail-closed 400, which also covers encrypted
|
|
976
|
+
* Collections -- they can never declare `indexes`); the data-plane backend
|
|
977
|
+
* MUST serve `queryByEquality` (else 501). With no `filter[...]` parameter the
|
|
978
|
+
* existing listing behavior is unchanged.
|
|
820
979
|
*
|
|
821
980
|
* @param request {import('fastify').FastifyRequest}
|
|
822
981
|
* @param reply {import('fastify').FastifyReply}
|
|
@@ -825,14 +984,19 @@ export class CollectionRequest {
|
|
|
825
984
|
static async list(
|
|
826
985
|
request: FastifyRequest<{
|
|
827
986
|
Params: { spaceId: string; collectionId: string }
|
|
828
|
-
Querystring:
|
|
987
|
+
Querystring: Record<string, string | string[] | undefined>
|
|
829
988
|
}>,
|
|
830
989
|
reply: FastifyReply
|
|
831
990
|
): Promise<FastifyReply> {
|
|
832
991
|
const {
|
|
833
|
-
params: { spaceId, collectionId }
|
|
834
|
-
query: { limit, cursor }
|
|
992
|
+
params: { spaceId, collectionId }
|
|
835
993
|
} = request
|
|
994
|
+
// `limit` / `cursor` are single-valued pagination params; a repeated value
|
|
995
|
+
// (an array) is ignored here and falls back to the backend default.
|
|
996
|
+
const rawLimit = request.query.limit
|
|
997
|
+
const rawCursor = request.query.cursor
|
|
998
|
+
const limit = typeof rawLimit === 'string' ? rawLimit : undefined
|
|
999
|
+
const cursor = typeof rawCursor === 'string' ? rawCursor : undefined
|
|
836
1000
|
const { storage } = request.server
|
|
837
1001
|
const requestName = 'List Collection'
|
|
838
1002
|
|
|
@@ -867,17 +1031,62 @@ export class CollectionRequest {
|
|
|
867
1031
|
requestName
|
|
868
1032
|
})
|
|
869
1033
|
|
|
870
|
-
//
|
|
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.
|
|
1034
|
+
// List (or filter-query) from the Collection's selected (data-plane) backend.
|
|
875
1035
|
const dataBackend = await resolveBackend({
|
|
876
1036
|
request,
|
|
877
1037
|
spaceId,
|
|
878
1038
|
collectionId,
|
|
879
1039
|
collectionDescription
|
|
880
1040
|
})
|
|
1041
|
+
|
|
1042
|
+
// GET equality filter: `filter[<attr>]=<value>` maps to the equality profile
|
|
1043
|
+
// over the same machinery. Present filters take this cacheable path; their
|
|
1044
|
+
// absence leaves the ordinary listing below untouched.
|
|
1045
|
+
const filters = parseListFilter({ query: request.query, requestName })
|
|
1046
|
+
if (filters !== undefined) {
|
|
1047
|
+
// Fail-closed declared-names check, the same rule as the POST profile:
|
|
1048
|
+
// every filter attribute MUST be declared in the Collection's `indexes`
|
|
1049
|
+
// (an encrypted Collection has none, so a filter there is always a 400).
|
|
1050
|
+
const indexes = normalizeIndexes({
|
|
1051
|
+
indexes: collectionDescription.indexes
|
|
1052
|
+
})
|
|
1053
|
+
const declared = new Set(indexes.map(declaration => declaration.name))
|
|
1054
|
+
for (const name of Object.keys(filters)) {
|
|
1055
|
+
if (!declared.has(name)) {
|
|
1056
|
+
throw new InvalidRequestBodyError({
|
|
1057
|
+
requestName,
|
|
1058
|
+
detail: `Filter attribute "${name}" is not declared in the Collection's indexes.`,
|
|
1059
|
+
pointer: `#/filter/${name}`
|
|
1060
|
+
})
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (!dataBackend.queryByEquality) {
|
|
1064
|
+
throw new UnsupportedOperationError({ requestName })
|
|
1065
|
+
}
|
|
1066
|
+
// The canonical GET semantics: a single-element `equals` conjunction over
|
|
1067
|
+
// string values. Reuse the already-parsed `limit` / `cursor` params and
|
|
1068
|
+
// answer the same page shape as the POST profile.
|
|
1069
|
+
const parsedFilterLimit = limit !== undefined ? Number(limit) : NaN
|
|
1070
|
+
const result = await dataBackend.queryByEquality({
|
|
1071
|
+
spaceId,
|
|
1072
|
+
collectionId,
|
|
1073
|
+
indexes,
|
|
1074
|
+
query: { equals: [{ ...filters }] },
|
|
1075
|
+
...(Number.isFinite(parsedFilterLimit) && parsedFilterLimit >= 1
|
|
1076
|
+
? { limit: parsedFilterLimit }
|
|
1077
|
+
: {}),
|
|
1078
|
+
...(cursor !== undefined && { cursor })
|
|
1079
|
+
})
|
|
1080
|
+
return reply
|
|
1081
|
+
.status(200)
|
|
1082
|
+
.type('application/json')
|
|
1083
|
+
.send(JSON.stringify(result))
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Coerce `limit` (a query string) to a positive integer; a non-numeric or
|
|
1087
|
+
// `< 1` value is ignored so the backend applies its own default. `cursor` is
|
|
1088
|
+
// opaque and passed through verbatim -- the backend validates it and rejects
|
|
1089
|
+
// a malformed one with `invalid-cursor` (400).
|
|
881
1090
|
const parsedLimit = limit !== undefined ? Number(limit) : NaN
|
|
882
1091
|
const collectionItems = await dataBackend.listCollectionItems({
|
|
883
1092
|
spaceId,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
assertEncryptedMetaConforms
|
|
14
14
|
} from '../lib/encryption.js'
|
|
15
15
|
import { assertValidIds } from '../lib/validateId.js'
|
|
16
|
+
import { normalizeIndexes } from '../lib/equalityIndex.js'
|
|
16
17
|
import { resourcePath, metaPath } from '../lib/paths.js'
|
|
17
18
|
import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
|
|
18
19
|
import { parseKeyEpochHeader, parseMetaEpoch } from '../lib/keyEpoch.js'
|
|
@@ -171,6 +172,14 @@ export class ResourceRequest {
|
|
|
171
172
|
headers: request.headers,
|
|
172
173
|
requestName
|
|
173
174
|
})
|
|
175
|
+
// When the Collection declares any `unique: true` index entries (the
|
|
176
|
+
// `equality-query` feature), pass the normalized unique entries so the
|
|
177
|
+
// backend enforces the plaintext uniqueness claim atomically with the write
|
|
178
|
+
// (409). Authorization has already run above, so the existence-revealing 409
|
|
179
|
+
// is observable only to a caller authorized to write here.
|
|
180
|
+
const uniqueIndexes = normalizeIndexes({
|
|
181
|
+
indexes: collectionDescription.indexes
|
|
182
|
+
}).filter(declaration => declaration.unique)
|
|
174
183
|
// Surface any `If-Match` / `If-None-Match` write precondition to the storage
|
|
175
184
|
// layer, which evaluates it atomically with the write (returning 412
|
|
176
185
|
// `precondition-failed` on a mismatch -- rethrown unchanged below).
|
|
@@ -183,6 +192,7 @@ export class ResourceRequest {
|
|
|
183
192
|
input,
|
|
184
193
|
createdBy: invokerDid(request),
|
|
185
194
|
epoch,
|
|
195
|
+
...(uniqueIndexes.length > 0 && { uniqueIndexes }),
|
|
186
196
|
...parseWritePreconditions(request.headers)
|
|
187
197
|
})
|
|
188
198
|
} catch (err) {
|
|
@@ -551,6 +561,15 @@ export class ResourceRequest {
|
|
|
551
561
|
collectionId,
|
|
552
562
|
collectionDescription
|
|
553
563
|
})
|
|
564
|
+
// When the Collection declares any `unique: true` index entries (the
|
|
565
|
+
// `equality-query` feature), pass the normalized unique entries so the
|
|
566
|
+
// backend enforces the plaintext uniqueness claim for custom-sourced
|
|
567
|
+
// attributes atomically with this metadata write (409). Authorization has
|
|
568
|
+
// already run, so the existence-revealing 409 is observable only to a caller
|
|
569
|
+
// authorized to write here.
|
|
570
|
+
const uniqueIndexes = normalizeIndexes({
|
|
571
|
+
indexes: collectionDescription.indexes
|
|
572
|
+
}).filter(declaration => declaration.unique)
|
|
554
573
|
let written
|
|
555
574
|
try {
|
|
556
575
|
written = await dataBackend.writeResourceMetadata({
|
|
@@ -559,6 +578,7 @@ export class ResourceRequest {
|
|
|
559
578
|
resourceId,
|
|
560
579
|
custom,
|
|
561
580
|
epoch,
|
|
581
|
+
...(uniqueIndexes.length > 0 && { uniqueIndexes }),
|
|
562
582
|
...parseWritePreconditions(request.headers)
|
|
563
583
|
})
|
|
564
584
|
} catch (err) {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
listRegisteredBackends
|
|
22
22
|
} from '../lib/backends.js'
|
|
23
23
|
import { assertSupportedEncryption } from '../lib/encryption.js'
|
|
24
|
+
import { assertSupportedIndexes } from '../lib/equalityIndex.js'
|
|
24
25
|
import { formatEtag } from '../lib/etag.js'
|
|
25
26
|
import {
|
|
26
27
|
spacePath,
|
|
@@ -302,6 +303,7 @@ export class SpaceRequest {
|
|
|
302
303
|
name?: string
|
|
303
304
|
backend?: unknown
|
|
304
305
|
encryption?: unknown
|
|
306
|
+
indexes?: unknown
|
|
305
307
|
}
|
|
306
308
|
}>,
|
|
307
309
|
reply: FastifyReply
|
|
@@ -324,6 +326,27 @@ export class SpaceRequest {
|
|
|
324
326
|
encryption: body?.encryption,
|
|
325
327
|
requestName
|
|
326
328
|
})
|
|
329
|
+
// Validate the optional `indexes` declaration (the `equality-query`
|
|
330
|
+
// feature) on the same terms as the PUT path, and enforce the
|
|
331
|
+
// mutual-exclusion rail here too: a Collection cannot be born both indexed
|
|
332
|
+
// and encrypted (the server cannot extract plaintext attributes from an
|
|
333
|
+
// opaque envelope).
|
|
334
|
+
const indexes = assertSupportedIndexes({
|
|
335
|
+
indexes: body?.indexes,
|
|
336
|
+
requestName
|
|
337
|
+
})
|
|
338
|
+
if (
|
|
339
|
+
indexes !== undefined &&
|
|
340
|
+
indexes.length > 0 &&
|
|
341
|
+
encryption !== undefined
|
|
342
|
+
) {
|
|
343
|
+
throw new InvalidRequestBodyError({
|
|
344
|
+
requestName,
|
|
345
|
+
detail:
|
|
346
|
+
'Collection "indexes" must not be combined with an "encryption" marker.',
|
|
347
|
+
pointer: '#/indexes'
|
|
348
|
+
})
|
|
349
|
+
}
|
|
327
350
|
|
|
328
351
|
// Verify (capability-only): creating a Collection requires a valid
|
|
329
352
|
// capability invocation; no access-control-policy fallback.
|
|
@@ -368,7 +391,8 @@ export class SpaceRequest {
|
|
|
368
391
|
type: ['Collection'],
|
|
369
392
|
name,
|
|
370
393
|
backend,
|
|
371
|
-
...(encryption !== undefined && { encryption })
|
|
394
|
+
...(encryption !== undefined && { encryption }),
|
|
395
|
+
...(indexes !== undefined && { indexes })
|
|
372
396
|
}
|
|
373
397
|
|
|
374
398
|
const createdBy = invokerDid(request)
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,13 @@ import type {
|
|
|
41
41
|
BlindedIndexQuery,
|
|
42
42
|
BlindedIndexQueryPage
|
|
43
43
|
} from './lib/blindedIndex.js'
|
|
44
|
+
import type {
|
|
45
|
+
EqualityQuery,
|
|
46
|
+
EqualityQueryPage,
|
|
47
|
+
EqualityCandidate,
|
|
48
|
+
EqualityValue,
|
|
49
|
+
NormalizedIndexDeclaration
|
|
50
|
+
} from './lib/equalityIndex.js'
|
|
44
51
|
|
|
45
52
|
// The shared WAS wire model now lives in `@interop/storage-core`. Import the
|
|
46
53
|
// shapes referenced by the `StorageBackend` contract below, and re-export the
|
|
@@ -64,6 +71,17 @@ import type {
|
|
|
64
71
|
// contract (`queryByBlindedIndex`) from this one module.
|
|
65
72
|
export type { BlindedIndexQuery, BlindedIndexQueryPage }
|
|
66
73
|
|
|
74
|
+
// Surface the equality-index query shapes referenced by the `StorageBackend`
|
|
75
|
+
// contract (`queryByEquality` / `findEqualityUniqueViolation`) from this one
|
|
76
|
+
// module, alongside the blinded-index ones.
|
|
77
|
+
export type {
|
|
78
|
+
EqualityQuery,
|
|
79
|
+
EqualityQueryPage,
|
|
80
|
+
EqualityCandidate,
|
|
81
|
+
EqualityValue,
|
|
82
|
+
NormalizedIndexDeclaration
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
// Surface the reused @interop/data-integrity-core types from this one module.
|
|
68
86
|
export type {
|
|
69
87
|
IDID,
|
|
@@ -91,6 +109,7 @@ export type {
|
|
|
91
109
|
ResourceSummary,
|
|
92
110
|
CollectionResourcesList,
|
|
93
111
|
CollectionDescription,
|
|
112
|
+
CollectionIndexDeclaration,
|
|
94
113
|
CollectionEncryption,
|
|
95
114
|
CollectionEncryptionEpoch,
|
|
96
115
|
CollectionEncryptionRecipient,
|
|
@@ -608,12 +627,30 @@ export interface StorageBackend {
|
|
|
608
627
|
* (see `lib/blindedIndex.ts`). Conflicts require `unique: true` on both
|
|
609
628
|
* sides, and a document keeping its own unique attribute across an update
|
|
610
629
|
* never self-conflicts.
|
|
630
|
+
*
|
|
631
|
+
* A backend carrying the `equality-query` feature enforces the analogous
|
|
632
|
+
* plaintext unique-attribute invariant when the request layer passes
|
|
633
|
+
* `uniqueIndexes` (the target Collection's normalized `unique: true`
|
|
634
|
+
* declarations, resolved from its control-plane description): a write whose
|
|
635
|
+
* extracted value for a `unique` content-sourced attribute is already claimed
|
|
636
|
+
* by a different live Resource in the same Collection rejects with
|
|
637
|
+
* `UniqueAttributeConflictError` (409), evaluated atomically with the write
|
|
638
|
+
* (see `lib/equalityIndex.ts`). Unlike the blinded invariant this is
|
|
639
|
+
* Collection-level, so a conflict does not require the other side to opt in.
|
|
611
640
|
*/
|
|
612
641
|
writeResource(options: {
|
|
613
642
|
spaceId: string
|
|
614
643
|
collectionId: string
|
|
615
644
|
resourceId: string
|
|
616
645
|
input: ResourceInput
|
|
646
|
+
/**
|
|
647
|
+
* The target Collection's normalized `unique: true` index declarations,
|
|
648
|
+
* passed by the request layer only when the Collection declares any. The
|
|
649
|
+
* backend enforces the plaintext unique-attribute claim atomically with the
|
|
650
|
+
* write (409 `UniqueAttributeConflictError`), for the content-sourced
|
|
651
|
+
* entries a content write can claim.
|
|
652
|
+
*/
|
|
653
|
+
uniqueIndexes?: NormalizedIndexDeclaration[]
|
|
617
654
|
/**
|
|
618
655
|
* DID of the party whose capability invocation authorized this write (the
|
|
619
656
|
* signing key's DID, fragment stripped). Recorded as the Resource's
|
|
@@ -685,6 +722,15 @@ export interface StorageBackend {
|
|
|
685
722
|
collectionId: string
|
|
686
723
|
resourceId: string
|
|
687
724
|
custom: ResourceMetadataCustom | Record<string, unknown>
|
|
725
|
+
/**
|
|
726
|
+
* The target Collection's normalized `unique: true` index declarations,
|
|
727
|
+
* passed by the request layer only when the Collection declares any. The
|
|
728
|
+
* backend enforces the plaintext unique-attribute claim atomically with the
|
|
729
|
+
* metadata write (409 `UniqueAttributeConflictError`), for the
|
|
730
|
+
* custom-sourced entries a metadata write can claim (see
|
|
731
|
+
* `lib/equalityIndex.ts`).
|
|
732
|
+
*/
|
|
733
|
+
uniqueIndexes?: NormalizedIndexDeclaration[]
|
|
688
734
|
/**
|
|
689
735
|
* The client-declared key epoch (the `key-epochs` feature), a sibling of
|
|
690
736
|
* `custom`. Unlike `custom` (full replacement), an OMITTED `epoch`
|
|
@@ -854,6 +900,57 @@ export interface StorageBackend {
|
|
|
854
900
|
cursor?: string
|
|
855
901
|
}): Promise<{ count: number } | BlindedIndexQueryPage>
|
|
856
902
|
|
|
903
|
+
/**
|
|
904
|
+
* OPTIONAL plaintext equality query (the `equality` query profile; the
|
|
905
|
+
* `equality-query` feature token). Evaluates an equality query -- `equals`
|
|
906
|
+
* (OR across elements of an AND within each element's `{name: value}` pairs)
|
|
907
|
+
* or `has` (every named attribute present with an indexable value) -- over
|
|
908
|
+
* the attributes the server extracts from the Collection's live Resources per
|
|
909
|
+
* the `indexes` declaration. `indexes` is the NORMALIZED declaration array:
|
|
910
|
+
* the request layer resolves it from the control-plane description (a
|
|
911
|
+
* data-plane backend does not hold the description) and passes it in. Matching
|
|
912
|
+
* is strict JSON equality (no coercion); a content-sourced attribute reads a
|
|
913
|
+
* JSON Resource's stored content, a custom-sourced one reads the `custom`
|
|
914
|
+
* metadata object (so blobs are queryable too). With `count`, resolves only
|
|
915
|
+
* the match total; otherwise a page of matching documents (`{ id, data?,
|
|
916
|
+
* custom? }`) in ascending `resourceId` order, paginated with the standard
|
|
917
|
+
* opaque cursor (`cursor` present iff `hasMore`; a malformed one rejects with
|
|
918
|
+
* `invalid-cursor` 400). Both first-party backends answer through
|
|
919
|
+
* `lib/equalityIndex.ts` so semantics cannot drift.
|
|
920
|
+
*
|
|
921
|
+
* OPTIONAL: a backend that omits this method does not serve the profile, and
|
|
922
|
+
* the request layer returns `unsupported-operation` (501). The Space and
|
|
923
|
+
* Collection are guaranteed to exist by the request layer.
|
|
924
|
+
*/
|
|
925
|
+
queryByEquality?(options: {
|
|
926
|
+
spaceId: string
|
|
927
|
+
collectionId: string
|
|
928
|
+
query: EqualityQuery
|
|
929
|
+
indexes: NormalizedIndexDeclaration[]
|
|
930
|
+
count?: boolean
|
|
931
|
+
limit?: number
|
|
932
|
+
cursor?: string
|
|
933
|
+
}): Promise<{ count: number } | EqualityQueryPage>
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* OPTIONAL declare-time uniqueness scan for the `equality` profile: given the
|
|
937
|
+
* Collection's normalized `unique: true` declarations, scans its already-
|
|
938
|
+
* stored live Resources for two DIFFERENT Resources that claim the same
|
|
939
|
+
* `(name, value)`, resolving the first such `{ name, value }` or `undefined`
|
|
940
|
+
* when none exists. The request layer runs it when a Collection update ADDS a
|
|
941
|
+
* unique claim, rejecting a found violation with `id-conflict` (409) so a
|
|
942
|
+
* unique claim is never acknowledged over already-conflicting data.
|
|
943
|
+
*
|
|
944
|
+
* OPTIONAL: paired with `queryByEquality` on backends carrying the
|
|
945
|
+
* `equality-query` feature. The Space and Collection are guaranteed to exist
|
|
946
|
+
* by the request layer.
|
|
947
|
+
*/
|
|
948
|
+
findEqualityUniqueViolation?(options: {
|
|
949
|
+
spaceId: string
|
|
950
|
+
collectionId: string
|
|
951
|
+
indexes: NormalizedIndexDeclaration[]
|
|
952
|
+
}): Promise<{ name: string; value: EqualityValue } | undefined>
|
|
953
|
+
|
|
857
954
|
/**
|
|
858
955
|
* Access-control policy documents. The level is selected by which ids are
|
|
859
956
|
* present: Space (`spaceId`), Collection (`+ collectionId`), or Resource
|