@kubun/graphql 0.11.0 → 0.13.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/lib/context.d.ts +8 -1
- package/lib/context.js +34 -22
- package/lib/scalars.js +1 -0
- package/lib/schema.js +132 -14
- package/package.json +27 -24
package/lib/context.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DocumentID } from '@kubun/id';
|
|
2
2
|
import type { AggregateHaving, AggregateSpec, AggregateValues, DocumentData, DocumentFilter, DocumentNode, DocumentOrderBy, GroupedAggregateOrderTerm, GroupedAggregateValue } from '@kubun/protocol';
|
|
3
|
-
import type { ViewerReadAccess } from '@kubun/store-graph';
|
|
3
|
+
import type { StoredAccessDefaultRow, ViewerReadAccess } from '@kubun/store-graph';
|
|
4
4
|
import type { Tracer } from '@sozai/otel';
|
|
5
5
|
import type { GraphQLResolveInfo } from 'graphql';
|
|
6
6
|
import type { Connection, Edge } from 'graphql-relay';
|
|
@@ -83,6 +83,7 @@ export type ReadContextDB = {
|
|
|
83
83
|
modelID: string;
|
|
84
84
|
rank: number;
|
|
85
85
|
}>>;
|
|
86
|
+
listUserModelAccessDefaults(ownerDID: string): Promise<Array<StoredAccessDefaultRow>>;
|
|
86
87
|
};
|
|
87
88
|
/**
|
|
88
89
|
* Query scope carried alongside a connection value so child resolvers (e.g.
|
|
@@ -148,6 +149,12 @@ export type QueryContext = {
|
|
|
148
149
|
modelID: string;
|
|
149
150
|
rank: number;
|
|
150
151
|
}>>;
|
|
152
|
+
/**
|
|
153
|
+
* The viewer's own stored model-access-default rows, across every model and
|
|
154
|
+
* permission type. Scoped to the current viewer DID - not for cross-member
|
|
155
|
+
* visibility.
|
|
156
|
+
*/
|
|
157
|
+
listModelAccessDefaults(): Promise<Array<StoredAccessDefaultRow>>;
|
|
151
158
|
};
|
|
152
159
|
/**
|
|
153
160
|
* Subscription functions requiring server implementation
|
package/lib/context.js
CHANGED
|
@@ -8,6 +8,7 @@ import { withSpan } from '@sozai/otel';
|
|
|
8
8
|
* generator. When a consumer falls behind, the oldest events are evicted so a
|
|
9
9
|
* single slow subscriber cannot grow memory without bound.
|
|
10
10
|
*/ const DEFAULT_SUBSCRIPTION_QUEUE_SIZE = 1000;
|
|
11
|
+
/** Access checks in flight at once — see `filterByAccess`. */ const ACCESS_CHECK_CONCURRENCY = 25;
|
|
11
12
|
/**
|
|
12
13
|
* Minimum spacing between repeated overflow warnings for a single generator, so
|
|
13
14
|
* a sustained overflow logs at most once per interval instead of per drop.
|
|
@@ -25,6 +26,34 @@ import { withSpan } from '@sozai/otel';
|
|
|
25
26
|
const logger = getKubunLogger('graphql');
|
|
26
27
|
export function createReadContext(params) {
|
|
27
28
|
const { store, viewerDID, accessChecker, viewerReadAccess } = params;
|
|
29
|
+
/**
|
|
30
|
+
* Keep the documents a viewer may read, in their original order.
|
|
31
|
+
*
|
|
32
|
+
* The checks are independent reads over one viewer, so they run concurrently;
|
|
33
|
+
* checking in sequence only ever bought the order the denial warnings were
|
|
34
|
+
* pushed in, and filtering after every verdict is in preserves that too.
|
|
35
|
+
*/ async function filterByAccess(documents, check) {
|
|
36
|
+
// Concurrent but bounded. Each check issues its own reads, and the page
|
|
37
|
+
// reaching here can be as large as `unboundedScanCap` (5000) — one
|
|
38
|
+
// `Promise.all` over that queues five thousand reads onto a single SQLite
|
|
39
|
+
// connection at once, which is slower than the sequential version it
|
|
40
|
+
// replaced and starves everything else on that connection.
|
|
41
|
+
const verdicts = [];
|
|
42
|
+
for(let index = 0; index < documents.length; index += ACCESS_CHECK_CONCURRENCY){
|
|
43
|
+
const batch = documents.slice(index, index + ACCESS_CHECK_CONCURRENCY);
|
|
44
|
+
verdicts.push(...await Promise.all(batch.map((doc)=>check(doc, 'read'))));
|
|
45
|
+
}
|
|
46
|
+
return documents.filter((doc, index)=>{
|
|
47
|
+
if (verdicts[index]) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
params.warnings?.push({
|
|
51
|
+
message: `Access denied for document ${doc.id}`,
|
|
52
|
+
code: 'KB07'
|
|
53
|
+
});
|
|
54
|
+
return false;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
28
57
|
function traced(name, attributes, fn) {
|
|
29
58
|
if (params.tracer != null) {
|
|
30
59
|
return withSpan(params.tracer, name, {
|
|
@@ -206,6 +235,9 @@ export function createReadContext(params) {
|
|
|
206
235
|
first
|
|
207
236
|
});
|
|
208
237
|
},
|
|
238
|
+
async listModelAccessDefaults () {
|
|
239
|
+
return await store.listUserModelAccessDefaults(viewerDID);
|
|
240
|
+
},
|
|
209
241
|
async resolveConnection (modelIDs, args) {
|
|
210
242
|
const resolverKey = `connection:${modelIDs.join(',')}`;
|
|
211
243
|
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
@@ -252,17 +284,7 @@ export function createReadContext(params) {
|
|
|
252
284
|
// Apply access control
|
|
253
285
|
let accessible = documents;
|
|
254
286
|
if (accessChecker) {
|
|
255
|
-
accessible =
|
|
256
|
-
for (const doc of documents){
|
|
257
|
-
if (await accessChecker(doc, 'read')) {
|
|
258
|
-
accessible.push(doc);
|
|
259
|
-
} else if (params.warnings != null) {
|
|
260
|
-
params.warnings.push({
|
|
261
|
-
message: `Access denied for document ${doc.id}`,
|
|
262
|
-
code: 'KB07'
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
287
|
+
accessible = await filterByAccess(documents, accessChecker);
|
|
266
288
|
}
|
|
267
289
|
// Order by search rank
|
|
268
290
|
const rankMap = new Map(searchResults.map((r)=>[
|
|
@@ -491,17 +513,7 @@ export function createReadContext(params) {
|
|
|
491
513
|
return documents;
|
|
492
514
|
}
|
|
493
515
|
// Filter based on access control
|
|
494
|
-
const accessible =
|
|
495
|
-
for (const doc of documents){
|
|
496
|
-
if (await accessChecker(doc, 'read')) {
|
|
497
|
-
accessible.push(doc);
|
|
498
|
-
} else if (params.warnings != null) {
|
|
499
|
-
params.warnings.push({
|
|
500
|
-
message: `Access denied for document ${doc.id}`,
|
|
501
|
-
code: 'KB07'
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
516
|
+
const accessible = await filterByAccess(documents, accessChecker);
|
|
505
517
|
if (params.complexity != null) {
|
|
506
518
|
params.complexity.resolverCount++;
|
|
507
519
|
for (const id of modelIDs){
|
package/lib/scalars.js
CHANGED
package/lib/schema.js
CHANGED
|
@@ -105,7 +105,7 @@ function createEnumValueFilterInput(type) {
|
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
-
function createScalarValueFilterInput(type) {
|
|
108
|
+
function createScalarValueFilterInput(type, options = {}) {
|
|
109
109
|
return new GraphQLInputObjectType({
|
|
110
110
|
name: `${type.name}ValueFilter`,
|
|
111
111
|
isOneOf: true,
|
|
@@ -136,6 +136,37 @@ function createScalarValueFilterInput(type) {
|
|
|
136
136
|
},
|
|
137
137
|
greaterThanOrEqualTo: {
|
|
138
138
|
type
|
|
139
|
+
},
|
|
140
|
+
// Substring match only makes sense against string-backed scalars.
|
|
141
|
+
...options.stringBacked ? {
|
|
142
|
+
contains: {
|
|
143
|
+
type: GraphQLString
|
|
144
|
+
}
|
|
145
|
+
} : {}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Opaque identifiers (DID, DocumentID, AttachmentID): equality only, no ordering
|
|
150
|
+
// or substring match — the underlying strings are content-addressed, not text.
|
|
151
|
+
function createExactValueFilterInput(type) {
|
|
152
|
+
return new GraphQLInputObjectType({
|
|
153
|
+
name: `${type.name}ValueFilter`,
|
|
154
|
+
isOneOf: true,
|
|
155
|
+
fields: {
|
|
156
|
+
isNull: {
|
|
157
|
+
type: GraphQLBoolean
|
|
158
|
+
},
|
|
159
|
+
equalTo: {
|
|
160
|
+
type
|
|
161
|
+
},
|
|
162
|
+
notEqualTo: {
|
|
163
|
+
type
|
|
164
|
+
},
|
|
165
|
+
in: {
|
|
166
|
+
type: toList(type)
|
|
167
|
+
},
|
|
168
|
+
notIn: {
|
|
169
|
+
type: toList(type)
|
|
139
170
|
}
|
|
140
171
|
}
|
|
141
172
|
});
|
|
@@ -159,6 +190,7 @@ const VALUE_FILTER_INPUTS = {
|
|
|
159
190
|
}
|
|
160
191
|
}
|
|
161
192
|
}),
|
|
193
|
+
AttachmentIDValueFilter: createExactValueFilterInput(GraphQLAttachmentID),
|
|
162
194
|
BooleanValueFilter: new GraphQLInputObjectType({
|
|
163
195
|
name: 'BooleanValueFilter',
|
|
164
196
|
isOneOf: true,
|
|
@@ -171,16 +203,26 @@ const VALUE_FILTER_INPUTS = {
|
|
|
171
203
|
}
|
|
172
204
|
}
|
|
173
205
|
}),
|
|
206
|
+
DIDValueFilter: createExactValueFilterInput(GraphQLDID),
|
|
207
|
+
DocumentIDValueFilter: createExactValueFilterInput(GraphQLDocID),
|
|
174
208
|
FloatValueFilter: createScalarValueFilterInput(GraphQLFloat),
|
|
175
209
|
IntValueFilter: createScalarValueFilterInput(GraphQLInt),
|
|
176
|
-
StringValueFilter: createScalarValueFilterInput(GraphQLString
|
|
210
|
+
StringValueFilter: createScalarValueFilterInput(GraphQLString, {
|
|
211
|
+
stringBacked: true
|
|
212
|
+
})
|
|
177
213
|
};
|
|
214
|
+
// Keyed by `scalar.name` (the GraphQL type name, e.g. "DID"), not the import
|
|
215
|
+
// binding — keying by binding name silently never matched here before, since
|
|
216
|
+
// scalars are looked up by `scalar.name`. `_owner` bypasses this map entirely
|
|
217
|
+
// (hardcoded to AccountValueFilter), so it is not listed here.
|
|
178
218
|
const VALUE_FILTER_INPUT_TYPES = {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
AttachmentID: 'AttachmentIDValueFilter',
|
|
220
|
+
Boolean: 'BooleanValueFilter',
|
|
221
|
+
DID: 'DIDValueFilter',
|
|
222
|
+
DocumentID: 'DocumentIDValueFilter',
|
|
223
|
+
Float: 'FloatValueFilter',
|
|
224
|
+
Int: 'IntValueFilter',
|
|
225
|
+
String: 'StringValueFilter'
|
|
184
226
|
};
|
|
185
227
|
const PatchSetOperation = new GraphQLInputObjectType({
|
|
186
228
|
name: 'PatchSetOperation',
|
|
@@ -247,6 +289,26 @@ const OrderByDirection = new GraphQLEnumType({
|
|
|
247
289
|
}
|
|
248
290
|
}
|
|
249
291
|
});
|
|
292
|
+
const ArrayPresence = new GraphQLEnumType({
|
|
293
|
+
name: 'ArrayPresence',
|
|
294
|
+
values: {
|
|
295
|
+
NULL: {
|
|
296
|
+
value: 'null'
|
|
297
|
+
},
|
|
298
|
+
NON_NULL: {
|
|
299
|
+
value: 'nonNull'
|
|
300
|
+
},
|
|
301
|
+
EMPTY: {
|
|
302
|
+
value: 'empty'
|
|
303
|
+
},
|
|
304
|
+
NON_EMPTY: {
|
|
305
|
+
value: 'nonEmpty'
|
|
306
|
+
},
|
|
307
|
+
NULL_OR_EMPTY: {
|
|
308
|
+
value: 'nullOrEmpty'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
250
312
|
const AccessRule = new GraphQLObjectType({
|
|
251
313
|
name: 'AccessRule',
|
|
252
314
|
fields: {
|
|
@@ -581,6 +643,42 @@ export class SchemaBuilder {
|
|
|
581
643
|
}
|
|
582
644
|
};
|
|
583
645
|
}
|
|
646
|
+
// Introspection: the viewer's own model-access-default rows. The stored
|
|
647
|
+
// rows are one-per-(model, permissionType); group them by modelID so each
|
|
648
|
+
// result groups read+write into `permissions`, matching `ModelAccessDefaults`.
|
|
649
|
+
queryFields.modelAccessDefaults = {
|
|
650
|
+
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(ModelAccessDefaults))),
|
|
651
|
+
resolve: async (_root, _args, ctx)=>{
|
|
652
|
+
const viewerDID = ctx.getViewer();
|
|
653
|
+
// The rows are the viewer's OWN defaults and `ownerDID` names the viewer.
|
|
654
|
+
// With no viewer there is no owner to name, and no own rows to return.
|
|
655
|
+
if (viewerDID == null) {
|
|
656
|
+
return [];
|
|
657
|
+
}
|
|
658
|
+
const rows = await ctx.listModelAccessDefaults();
|
|
659
|
+
const byModelID = new Map();
|
|
660
|
+
for (const row of rows){
|
|
661
|
+
let entry = byModelID.get(row.modelID);
|
|
662
|
+
if (entry == null) {
|
|
663
|
+
entry = {
|
|
664
|
+
ownerDID: viewerDID,
|
|
665
|
+
modelID: row.modelID,
|
|
666
|
+
permissions: {}
|
|
667
|
+
};
|
|
668
|
+
byModelID.set(row.modelID, entry);
|
|
669
|
+
}
|
|
670
|
+
entry.permissions[row.permissionType] = {
|
|
671
|
+
level: row.level,
|
|
672
|
+
allowedDIDs: row.allowedDIDs,
|
|
673
|
+
allowedCircles: row.allowedCircles,
|
|
674
|
+
allowedGroups: row.allowedGroups
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
return [
|
|
678
|
+
...byModelID.values()
|
|
679
|
+
];
|
|
680
|
+
}
|
|
681
|
+
};
|
|
584
682
|
// Add access control mutations if mutations are enabled
|
|
585
683
|
if (this._includeMutations) {
|
|
586
684
|
this._mutations.setModelAccessDefaults = {
|
|
@@ -2125,16 +2223,34 @@ export class SchemaBuilder {
|
|
|
2125
2223
|
if (schema.type === 'array') {
|
|
2126
2224
|
const name = this._aliases[id] ?? pascalCase(parentName + schema.title);
|
|
2127
2225
|
refParentName = name;
|
|
2128
|
-
|
|
2226
|
+
const itemID = getReferenceID(schema.items.$ref);
|
|
2227
|
+
const itemSchema = this._getReferenceSchema(itemID);
|
|
2228
|
+
// Membership ops need a scalar item type; array-of-object/array-of-array items
|
|
2229
|
+
// fall back to `isNull`-only filtering (no `_buildScalar` support for them).
|
|
2230
|
+
if (itemSchema.type === 'object' || itemSchema.type === 'array') {
|
|
2231
|
+
return new GraphQLInputObjectType({
|
|
2232
|
+
name: `${name}Filter`,
|
|
2233
|
+
isOneOf: true,
|
|
2234
|
+
fields: {
|
|
2235
|
+
isNull: {
|
|
2236
|
+
type: GraphQLBoolean
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
const itemScalar = this._buildScalar(itemID, itemSchema, name);
|
|
2129
2242
|
return new GraphQLInputObjectType({
|
|
2130
2243
|
name: `${name}Filter`,
|
|
2131
2244
|
isOneOf: true,
|
|
2132
2245
|
fields: {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
type:
|
|
2246
|
+
includesAll: {
|
|
2247
|
+
type: toList(itemScalar)
|
|
2248
|
+
},
|
|
2249
|
+
includesAny: {
|
|
2250
|
+
type: toList(itemScalar)
|
|
2251
|
+
},
|
|
2252
|
+
presence: {
|
|
2253
|
+
type: ArrayPresence
|
|
2138
2254
|
}
|
|
2139
2255
|
}
|
|
2140
2256
|
});
|
|
@@ -2144,7 +2260,9 @@ export class SchemaBuilder {
|
|
|
2144
2260
|
if (knownFilter != null) {
|
|
2145
2261
|
return this._inputObjects[knownFilter];
|
|
2146
2262
|
}
|
|
2147
|
-
const filter = isEnumType(scalar) ? createEnumValueFilterInput(scalar) : createScalarValueFilterInput(scalar
|
|
2263
|
+
const filter = isEnumType(scalar) ? createEnumValueFilterInput(scalar) : createScalarValueFilterInput(scalar, {
|
|
2264
|
+
stringBacked: true
|
|
2265
|
+
});
|
|
2148
2266
|
if (this._inputObjects[filter.name] == null) {
|
|
2149
2267
|
this._inputObjects[filter.name] = filter;
|
|
2150
2268
|
}
|
package/package.json
CHANGED
|
@@ -1,50 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/graphql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "see LICENSE.md",
|
|
3
|
+
"version": "0.13.0",
|
|
5
4
|
"keywords": [],
|
|
5
|
+
"license": "see LICENSE.md",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"type": "module",
|
|
7
|
-
"main": "lib/index.js",
|
|
8
|
-
"types": "lib/index.d.ts",
|
|
9
8
|
"exports": {
|
|
10
9
|
".": "./lib/index.js"
|
|
11
10
|
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/index.d.ts",
|
|
12
13
|
"files": [
|
|
13
14
|
"lib/*",
|
|
14
15
|
"LICENSE.md"
|
|
15
16
|
],
|
|
16
|
-
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@sozai/async": "^0.1
|
|
19
|
-
"@sozai/codec": "^0.
|
|
20
|
-
"@sozai/event": "^0.1.
|
|
21
|
-
"@sozai/otel": "^0.2
|
|
18
|
+
"@sozai/async": "^0.2.1",
|
|
19
|
+
"@sozai/codec": "^0.4.0",
|
|
20
|
+
"@sozai/event": "^0.1.3",
|
|
21
|
+
"@sozai/otel": "^0.3.2",
|
|
22
22
|
"change-case": "^5.4.4",
|
|
23
23
|
"graphql": "^16.14.2",
|
|
24
24
|
"graphql-relay": "^0.10.2",
|
|
25
|
-
"graphql-scalars": "^1.
|
|
26
|
-
"multiformats": "^14.0.
|
|
27
|
-
"@kubun/
|
|
28
|
-
"@kubun/
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/protocol": "^0.
|
|
25
|
+
"graphql-scalars": "^1.26.0",
|
|
26
|
+
"multiformats": "^14.0.5",
|
|
27
|
+
"@kubun/logger": "^0.12.0",
|
|
28
|
+
"@kubun/hlc": "^0.12.0",
|
|
29
|
+
"@kubun/id": "^0.12.0",
|
|
30
|
+
"@kubun/protocol": "^0.13.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@kokuin/capability": "^0.
|
|
34
|
-
"@kokuin/token": "^0.
|
|
35
|
-
"@kubun/
|
|
36
|
-
"@kubun/db": "^0.
|
|
37
|
-
"@kubun/
|
|
38
|
-
"@kubun/test-utils": "^0.
|
|
33
|
+
"@kokuin/capability": "^0.2.2",
|
|
34
|
+
"@kokuin/token": "^0.4.0",
|
|
35
|
+
"@kubun/store-graph": "^0.13.0",
|
|
36
|
+
"@kubun/db-better-sqlite": "^0.12.1",
|
|
37
|
+
"@kubun/db": "^0.12.1",
|
|
38
|
+
"@kubun/test-utils": "^0.12.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
39
42
|
},
|
|
40
43
|
"scripts": {
|
|
44
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
41
45
|
"build:clean": "del lib",
|
|
42
46
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
43
47
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
44
48
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
45
|
-
"
|
|
49
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
46
50
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
47
|
-
"test:unit": "vitest run"
|
|
48
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
51
|
+
"test:unit": "vitest run"
|
|
49
52
|
}
|
|
50
53
|
}
|