@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.
Files changed (2) hide show
  1. package/lib/handlers.js +210 -9
  2. 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 ONLY when no payload is present, which is the
28
- * explicit `accessRules: false` local mode where authentication is disabled.
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 defaultDID;
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 ?? defaultDID;
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: resolveViewerDID(payload, defaultDID)
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 { viewerDID } = extractGraphParams(ctx, defaultDID);
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.0",
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.0",
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.0",
24
- "@kubun/plugin-service-api": "^0.17.0",
25
- "@kubun/protocol": "^0.17.0",
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": "^16.14.2"
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.0",
35
+ "@kubun/client": "^0.17.2",
36
36
  "@kubun/db": "^0.17.0",
37
- "@kubun/plugin-service-server": "^0.17.0",
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.0",
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
  },