@kubun/service-graph-api 0.17.0 → 0.17.2
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/handlers.js +210 -9
- package/package.json +9 -9
package/lib/handlers.js
CHANGED
|
@@ -2,7 +2,151 @@ import { stringifyToken } from '@kokuin/token';
|
|
|
2
2
|
import { DocumentID } from '@kubun/id';
|
|
3
3
|
import { convertPatchInput, createMutationOperations, WriteAccessDeniedError } from '@kubun/mutation';
|
|
4
4
|
import { consume } from '@sozai/generator';
|
|
5
|
-
import { GraphQLError } from 'graphql';
|
|
5
|
+
import { GraphQLError, Kind, parse } from 'graphql';
|
|
6
|
+
// The field-name convention the connector plugin's schema extension mints
|
|
7
|
+
// per-model action mutations under (see `createConnectorSchemaExtension` in
|
|
8
|
+
// `@kubun/plugin-connector`). Not a live registry lookup — service-graph-api
|
|
9
|
+
// has no dependency on plugin-connector — the prefix alone identifies a
|
|
10
|
+
// connector spend syntactically, which is all a pre-signed or explicit-tx
|
|
11
|
+
// submission carries before any resolver runs.
|
|
12
|
+
const CONNECTOR_ACTION_FIELD_PREFIXES = [
|
|
13
|
+
'connectorCreate',
|
|
14
|
+
'connectorUpdate'
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Walks a selection set counting field selections into `stats`, resolving
|
|
18
|
+
* `FRAGMENT_SPREAD` and `INLINE_FRAGMENT` selections rather than skipping
|
|
19
|
+
* them — graphql-js's own execution-time `collectFields` flattens fragments
|
|
20
|
+
* into their parent selection set, so a field hidden only behind a fragment
|
|
21
|
+
* still runs alongside whatever else is in the mutation. A walk that treated
|
|
22
|
+
* "top-level" as "only literal FIELD selections" would let a smuggled field
|
|
23
|
+
* (or even a second connector-action field) evade both the single-spend
|
|
24
|
+
* boundary and `containsConnectorActionField`'s detection.
|
|
25
|
+
*
|
|
26
|
+
* `visitedFragments` guards against a cyclic/self-referential fragment spread
|
|
27
|
+
* (A spreads B spreads A) hanging the handler: a fragment name already on the
|
|
28
|
+
* current recursion path is skipped rather than re-entered. It is removed
|
|
29
|
+
* again after that branch returns, so the same fragment spread twice in
|
|
30
|
+
* sibling (non-cyclic) positions is still counted both times. A spread whose
|
|
31
|
+
* definition is missing from the document contributes nothing — never throws;
|
|
32
|
+
* that document is already headed for the normal GraphQL-validation error
|
|
33
|
+
* path, not this gate's concern.
|
|
34
|
+
*/ function collectMutationFields(selectionSet, document, visitedFragments, stats) {
|
|
35
|
+
for (const selection of selectionSet.selections){
|
|
36
|
+
if (selection.kind === Kind.FIELD) {
|
|
37
|
+
stats.totalFieldCount += 1;
|
|
38
|
+
const name = selection.name.value;
|
|
39
|
+
if (CONNECTOR_ACTION_FIELD_PREFIXES.some((prefix)=>name.startsWith(prefix))) {
|
|
40
|
+
stats.connectorFieldCount += 1;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
45
|
+
collectMutationFields(selection.selectionSet, document, visitedFragments, stats);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
49
|
+
const fragmentName = selection.name.value;
|
|
50
|
+
if (visitedFragments.has(fragmentName)) continue;
|
|
51
|
+
const fragmentDef = document.definitions.find((def)=>def.kind === Kind.FRAGMENT_DEFINITION && def.name.value === fragmentName);
|
|
52
|
+
if (fragmentDef == null) continue;
|
|
53
|
+
visitedFragments.add(fragmentName);
|
|
54
|
+
collectMutationFields(fragmentDef.selectionSet, document, visitedFragments, stats);
|
|
55
|
+
visitedFragments.delete(fragmentName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Counts a mutation document's top-level field selections (fragment-expanded,
|
|
61
|
+
* see `collectMutationFields`), and how many of them are connector-action
|
|
62
|
+
* fields. Connector actions authorize themselves through the
|
|
63
|
+
* `connector/execute` gate inside `executeAction`, which needs the
|
|
64
|
+
* `ExecutionContext` (groupID, invocationCaps, authenticated) the engine's own
|
|
65
|
+
* delegated-mode context factory builds — the pre-signed and explicit-tx
|
|
66
|
+
* modes below never build one, so a connector field reaching either would run
|
|
67
|
+
* with no gate at all. Detected on the query text, before any mutation is
|
|
68
|
+
* applied or buffered.
|
|
69
|
+
*
|
|
70
|
+
* Shared by `containsConnectorActionField` (boolean presence, used by the
|
|
71
|
+
* pre-signed/explicit-transaction gates below) and the delegated-mode
|
|
72
|
+
* single-spend boundary (which additionally needs the total count to detect
|
|
73
|
+
* a connector field mixed with other mutations).
|
|
74
|
+
*/ function analyzeMutationFields(text) {
|
|
75
|
+
let document;
|
|
76
|
+
try {
|
|
77
|
+
document = parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
// A malformed document is a syntax error for the normal execution path to
|
|
80
|
+
// surface — not this gate's concern.
|
|
81
|
+
return {
|
|
82
|
+
totalFieldCount: 0,
|
|
83
|
+
connectorFieldCount: 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const stats = {
|
|
87
|
+
totalFieldCount: 0,
|
|
88
|
+
connectorFieldCount: 0
|
|
89
|
+
};
|
|
90
|
+
const visitedFragments = new Set();
|
|
91
|
+
for (const definition of document.definitions){
|
|
92
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.operation !== 'mutation') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
collectMutationFields(definition.selectionSet, document, visitedFragments, stats);
|
|
96
|
+
}
|
|
97
|
+
return stats;
|
|
98
|
+
}
|
|
99
|
+
function containsConnectorActionField(text) {
|
|
100
|
+
return analyzeMutationFields(text).connectorFieldCount > 0;
|
|
101
|
+
}
|
|
102
|
+
function connectorActionRequiresDelegatedModeResult() {
|
|
103
|
+
return toGraphResult({
|
|
104
|
+
data: null,
|
|
105
|
+
errors: [
|
|
106
|
+
new GraphQLError('Connector actions are delegated-mode only', {
|
|
107
|
+
extensions: {
|
|
108
|
+
code: 'CONNECTOR_ACTION_REQUIRES_DELEGATED_MODE'
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A connector-action spend must be the sole mutation in the request — not
|
|
116
|
+
* paired with another connector-action field, nor with any ordinary
|
|
117
|
+
* mutation field. This is the single-spend boundary: a purely syntactic
|
|
118
|
+
* count of top-level fields, checked before the request reaches the action
|
|
119
|
+
* layer (`executeAction` in `@kubun/plugin-connector`).
|
|
120
|
+
*/ function connectorActionSingleSpendOnlyResult() {
|
|
121
|
+
return toGraphResult({
|
|
122
|
+
data: null,
|
|
123
|
+
errors: [
|
|
124
|
+
new GraphQLError('A connector-action spend must be the only field in the mutation', {
|
|
125
|
+
extensions: {
|
|
126
|
+
code: 'CONNECTOR_ACTION_SINGLE_SPEND_ONLY'
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A credential-spend connector-action mutation must carry both envelope ids.
|
|
134
|
+
* Presence only — their content/format, envelope-reuse (replay/conflict)
|
|
135
|
+
* detection, and the `primary`-target rejection are all deeper semantic
|
|
136
|
+
* checks that stay in the action layer; this gate never inspects their
|
|
137
|
+
* values.
|
|
138
|
+
*/ function connectorActionEnvelopeMissingResult() {
|
|
139
|
+
return toGraphResult({
|
|
140
|
+
data: null,
|
|
141
|
+
errors: [
|
|
142
|
+
new GraphQLError('A connector-action spend requires operationID and deterministicEventID', {
|
|
143
|
+
extensions: {
|
|
144
|
+
code: 'CONNECTOR_ACTION_ENVELOPE_MISSING'
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
]
|
|
148
|
+
});
|
|
149
|
+
}
|
|
6
150
|
function toGraphResult(result) {
|
|
7
151
|
const graphResult = {
|
|
8
152
|
data: result.data
|
|
@@ -24,27 +168,42 @@ function toGraphResult(result) {
|
|
|
24
168
|
* The verified `iss` is the only trustworthy viewer: it is the DID that signed
|
|
25
169
|
* the request. A `sub` that differs from `iss` is a forged-subject attempt
|
|
26
170
|
* (running the graph as someone else), so it is rejected outright. The
|
|
27
|
-
* `defaultDID` fallback applies
|
|
28
|
-
*
|
|
171
|
+
* `defaultDID` fallback applies when no payload is present (the explicit
|
|
172
|
+
* `accessRules: false` local mode where authentication is disabled) or when a
|
|
173
|
+
* payload carries no `iss`.
|
|
174
|
+
*
|
|
175
|
+
* `authenticated` is `true` only on the verified-`iss` path — never on the
|
|
176
|
+
* `defaultDID` substitution — so a caller can forward it onto
|
|
177
|
+
* `ExecutionContext.authenticated` as trusted provenance.
|
|
29
178
|
*/ function resolveViewerDID(payload, defaultDID) {
|
|
30
|
-
if (payload == null) return
|
|
179
|
+
if (payload == null) return {
|
|
180
|
+
viewerDID: defaultDID,
|
|
181
|
+
authenticated: false
|
|
182
|
+
};
|
|
31
183
|
const iss = payload.iss;
|
|
32
184
|
const sub = payload.sub;
|
|
33
185
|
if (sub != null && sub !== iss) {
|
|
34
186
|
throw new Error('Access denied: signed payload subject does not match issuer');
|
|
35
187
|
}
|
|
36
|
-
return iss
|
|
188
|
+
return iss != null ? {
|
|
189
|
+
viewerDID: iss,
|
|
190
|
+
authenticated: true
|
|
191
|
+
} : {
|
|
192
|
+
viewerDID: defaultDID,
|
|
193
|
+
authenticated: false
|
|
194
|
+
};
|
|
37
195
|
}
|
|
38
196
|
/**
|
|
39
197
|
* Extract EngineGraphParams from a handler context, resolving the viewerDID
|
|
40
198
|
* from the verified issuer of the signed message payload (see resolveViewerDID).
|
|
41
199
|
*/ function extractGraphParams(ctx, defaultDID) {
|
|
42
200
|
const payload = ctx.message?.payload;
|
|
201
|
+
const { viewerDID } = resolveViewerDID(payload, defaultDID);
|
|
43
202
|
return {
|
|
44
203
|
id: ctx.param.id,
|
|
45
204
|
text: ctx.param.text,
|
|
46
205
|
variables: ctx.param.variables ?? {},
|
|
47
|
-
viewerDID
|
|
206
|
+
viewerDID
|
|
48
207
|
};
|
|
49
208
|
}
|
|
50
209
|
export function createGraphHandlers(params) {
|
|
@@ -68,8 +227,14 @@ export function createGraphHandlers(params) {
|
|
|
68
227
|
});
|
|
69
228
|
},
|
|
70
229
|
'graph/mutate': async (ctx)=>{
|
|
71
|
-
const
|
|
230
|
+
const payload = ctx.message?.payload;
|
|
231
|
+
const { viewerDID, authenticated } = resolveViewerDID(payload, defaultDID);
|
|
72
232
|
if (ctx.param.mutations != null) {
|
|
233
|
+
// Connector actions are delegated-mode only — refused before any
|
|
234
|
+
// mutation is applied (see `containsConnectorActionField`).
|
|
235
|
+
if (containsConnectorActionField(ctx.param.text)) {
|
|
236
|
+
return connectorActionRequiresDelegatedModeResult();
|
|
237
|
+
}
|
|
73
238
|
// Pre-signed mode: client submitted JWT tokens keyed by GraphQL field alias
|
|
74
239
|
const mutations = ctx.param.mutations;
|
|
75
240
|
const entries = Object.entries(mutations);
|
|
@@ -155,17 +320,53 @@ export function createGraphHandlers(params) {
|
|
|
155
320
|
const owner = ctx.param.owner ?? viewerDID;
|
|
156
321
|
// Non-transaction delegated: engine.mutateGraph() handles signing internally
|
|
157
322
|
if (transactionID == null) {
|
|
323
|
+
// The single spend boundary + envelope presence gate applies only on
|
|
324
|
+
// this rail — it's the only one a connector-action field can reach
|
|
325
|
+
// (pre-signed and explicit-transaction both refuse it outright,
|
|
326
|
+
// above and below). Purely syntactic: field-count and id-presence
|
|
327
|
+
// only, never the fields' content — see the two result helpers'
|
|
328
|
+
// docs for what stays in the action layer instead.
|
|
329
|
+
const fieldStats = analyzeMutationFields(ctx.param.text);
|
|
330
|
+
if (fieldStats.connectorFieldCount > 0) {
|
|
331
|
+
if (fieldStats.totalFieldCount > 1) {
|
|
332
|
+
return connectorActionSingleSpendOnlyResult();
|
|
333
|
+
}
|
|
334
|
+
if (ctx.param.operationID == null || ctx.param.deterministicEventID == null) {
|
|
335
|
+
return connectorActionEnvelopeMissingResult();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// `groupID` is server-derived from the tunnel binding (see
|
|
339
|
+
// ServiceHandlerContext.groupID), never a wire field — RequestHandlerContext
|
|
340
|
+
// has no static `groupID`, so it is read via the same narrowed-cast
|
|
341
|
+
// pattern as `payload` above.
|
|
342
|
+
const groupID = ctx.groupID;
|
|
158
343
|
const result = await engine.mutateGraph({
|
|
159
344
|
id: ctx.param.id,
|
|
160
345
|
text: ctx.param.text,
|
|
161
346
|
variables: ctx.param.variables ?? {},
|
|
162
347
|
viewerDID,
|
|
163
|
-
owner
|
|
348
|
+
owner,
|
|
349
|
+
authenticated,
|
|
350
|
+
groupID,
|
|
351
|
+
invocationCaps: ctx.param.invocationCaps,
|
|
352
|
+
// The connector operation envelope rides the wire and reaches the
|
|
353
|
+
// action layer only through `ExecutionContext` — forward it, or a
|
|
354
|
+
// credential spend fails its envelope check downstream.
|
|
355
|
+
operationID: ctx.param.operationID,
|
|
356
|
+
deterministicEventID: ctx.param.deterministicEventID
|
|
164
357
|
});
|
|
165
358
|
return toGraphResult(result);
|
|
166
359
|
}
|
|
167
360
|
// Transaction mode: buffer signed mutations without applying them.
|
|
168
361
|
// Requires custom signing context since mutateGraph() applies immediately.
|
|
362
|
+
// Connector actions are delegated-mode only — refused before any
|
|
363
|
+
// mutation is buffered (see `containsConnectorActionField`); the
|
|
364
|
+
// non-transaction delegated branch above already returned, so reaching
|
|
365
|
+
// here means an explicit transaction that never builds the engine's
|
|
366
|
+
// own ExecutionContext (groupID, invocationCaps, authenticated).
|
|
367
|
+
if (containsConnectorActionField(ctx.param.text)) {
|
|
368
|
+
return connectorActionRequiresDelegatedModeResult();
|
|
369
|
+
}
|
|
169
370
|
const signAndBuffer = async (mutation)=>{
|
|
170
371
|
const signed = await signingIdentity.signToken(mutation);
|
|
171
372
|
const jwt = stringifyToken(signed);
|
|
@@ -282,7 +483,7 @@ export function createGraphHandlers(params) {
|
|
|
282
483
|
},
|
|
283
484
|
'graph/beginTransaction': async (ctx)=>{
|
|
284
485
|
const payload = ctx.message?.payload;
|
|
285
|
-
const callerDID = resolveViewerDID(payload, defaultDID);
|
|
486
|
+
const { viewerDID: callerDID } = resolveViewerDID(payload, defaultDID);
|
|
286
487
|
// Validate that the graph exists before starting a transaction
|
|
287
488
|
await engine.loadGraph({
|
|
288
489
|
id: ctx.param.id
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/service-graph-api",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -17,27 +17,27 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@enkaku/server": "^0.21.2",
|
|
19
19
|
"@kokuin/token": "^0.5.0",
|
|
20
|
-
"@kubun/engine": "^0.17.
|
|
20
|
+
"@kubun/engine": "^0.17.2",
|
|
21
21
|
"@kubun/hlc": "^0.17.0",
|
|
22
22
|
"@kubun/id": "^0.17.0",
|
|
23
|
-
"@kubun/mutation": "^0.17.
|
|
24
|
-
"@kubun/plugin-service-api": "^0.17.
|
|
25
|
-
"@kubun/protocol": "^0.17.
|
|
23
|
+
"@kubun/mutation": "^0.17.1",
|
|
24
|
+
"@kubun/plugin-service-api": "^0.17.1",
|
|
25
|
+
"@kubun/protocol": "^0.17.1",
|
|
26
26
|
"@sozai/generator": "^0.2.0",
|
|
27
27
|
"@sozai/runtime": "^0.1.0",
|
|
28
|
-
"graphql": "^
|
|
28
|
+
"graphql": "^17.0.2"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@enkaku/client": "^0.21.2",
|
|
32
32
|
"@enkaku/transport": "^0.21.0",
|
|
33
33
|
"@kokuin/capability": "^0.3.0",
|
|
34
34
|
"@kokuin/controller": "^0.1.0",
|
|
35
|
-
"@kubun/client": "^0.17.
|
|
35
|
+
"@kubun/client": "^0.17.2",
|
|
36
36
|
"@kubun/db": "^0.17.0",
|
|
37
|
-
"@kubun/plugin-service-server": "^0.17.
|
|
37
|
+
"@kubun/plugin-service-server": "^0.17.1",
|
|
38
38
|
"@kubun/store-controller": "^0.17.0",
|
|
39
39
|
"@kubun/store-delegation": "^0.17.0",
|
|
40
|
-
"@kubun/store-graph": "^0.17.
|
|
40
|
+
"@kubun/store-graph": "^0.17.1",
|
|
41
41
|
"@kubun/store-p2p": "^0.17.0",
|
|
42
42
|
"@kubun/test-utils": "^0.0.0"
|
|
43
43
|
},
|