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