@nanobpm/urban 0.85.0 → 0.86.1
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/dist/openapi/spec.d.ts +72 -0
- package/dist/openapi/spec.js +130 -0
- package/dist/runtime/core/host.d.ts +67 -9
- package/dist/runtime/core/host.js +48 -0
- package/dist/runtime/core/modules/mcp.d.ts +23 -5
- package/dist/runtime/core/modules/mcp.js +346 -61
- package/dist/runtime/engine/nanosdk.js +9 -5
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +4 -0
- package/package.json +3 -1
- package/src/openapi/mcp-parity.fixture.yaml +49 -0
- package/src/openapi/mcp-tools.snapshot.json +20 -0
- package/src/openapi/spec.test.ts +92 -0
- package/src/openapi/spec.ts +161 -0
- package/src/runtime/core/host.ts +83 -9
- package/src/runtime/core/modules/mcp.test.ts +526 -14
- package/src/runtime/core/modules/mcp.ts +391 -61
- package/src/runtime/engine/nanosdk.test.ts +69 -11
- package/src/runtime/engine/nanosdk.ts +9 -5
- package/src/runtime/index.ts +8 -0
package/dist/runtime/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { createLogger } from "./core/logger.ts";
|
|
|
15
15
|
export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
|
|
16
16
|
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
17
17
|
export { ENGINE_CLIENT_METHODS } from "./core/host.ts";
|
|
18
|
+
export { assertDeployedWaitStateType, DEPLOYED_WAIT_STATE_TYPES, UnsupportedWaitStateTypeError, } from "./core/host.ts";
|
|
18
19
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
19
20
|
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
|
20
21
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -22,6 +22,10 @@ export { BpmnError, isBpmnError } from "./core/host.js";
|
|
|
22
22
|
// double / adapter iterates it to prove at runtime that it implements the full interface,
|
|
23
23
|
// catching the "fake lags the SDK" seam-lag class categorically.
|
|
24
24
|
export { ENGINE_CLIENT_METHODS } from "./core/host.js";
|
|
25
|
+
// Deployed wait-state floor (issue nanobpm/nano-ide#497): the `waitStateType` values the
|
|
26
|
+
// deployed gateway actually serves, plus the shared guard both EngineClient adapters use so
|
|
27
|
+
// a filter outside the floor fails identically in emulation and against a live engine.
|
|
28
|
+
export { assertDeployedWaitStateType, DEPLOYED_WAIT_STATE_TYPES, UnsupportedWaitStateTypeError, } from "./core/host.js";
|
|
25
29
|
// Connector packs (ADR 0050, in-process port)
|
|
26
30
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
|
|
27
31
|
// Data layer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.1",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -93,6 +93,8 @@
|
|
|
93
93
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
|
|
94
94
|
"gen:runtime": "node scripts/gen-runtime.mjs",
|
|
95
95
|
"check:runtime": "node scripts/gen-runtime.mjs --check",
|
|
96
|
+
"gen:mcp-tools": "node --experimental-strip-types scripts/gen-mcp-tools.ts",
|
|
97
|
+
"check:mcp": "node --experimental-strip-types scripts/gen-mcp-tools.ts --check",
|
|
96
98
|
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
|
|
97
99
|
"test:conformance": "node --test --experimental-strip-types \"src/context/conformance/**/*.conformance.ts\"",
|
|
98
100
|
"check:pii": "node --experimental-strip-types scripts/check-pii.ts",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Parity-guard fixture (ADR 0067 Slice 3) — NOT an app spec.
|
|
2
|
+
#
|
|
3
|
+
# This is the input to the spec↔tool parity guard (`scripts/gen-mcp-tools.ts`).
|
|
4
|
+
# It exercises every branch of the MCP tool projection with a stable, minimal
|
|
5
|
+
# document so `check:mcp` fails loudly the moment the walker's projection changes
|
|
6
|
+
# without a matching, reviewed regeneration of `mcp-tools.snapshot.json`:
|
|
7
|
+
#
|
|
8
|
+
# - a READ operation (GET) → projected, non-mutating
|
|
9
|
+
# - a MUTATING operation (POST) → projected, mutating (guarded at runtime)
|
|
10
|
+
# - an `x-mcp`-EXCLUDED operation → present in the doc but withheld from the
|
|
11
|
+
# agent-facing tool surface (the security-
|
|
12
|
+
# relevant opt-out, `x-mcp: false`)
|
|
13
|
+
#
|
|
14
|
+
# Edit this fixture ONLY together with a reviewed `npm run gen:mcp-tools`.
|
|
15
|
+
openapi: 3.1.0
|
|
16
|
+
info:
|
|
17
|
+
title: mcp-parity-fixture
|
|
18
|
+
version: 0.0.0
|
|
19
|
+
components:
|
|
20
|
+
securitySchemes:
|
|
21
|
+
appSecret:
|
|
22
|
+
type: apiKey
|
|
23
|
+
in: header
|
|
24
|
+
name: X-Nano-Key
|
|
25
|
+
x-nano-secret-env: NANO_WEBHOOK_KEY
|
|
26
|
+
paths:
|
|
27
|
+
/widgets:
|
|
28
|
+
get:
|
|
29
|
+
operationId: listWidgets
|
|
30
|
+
summary: List widgets (read — projected, unguarded on loopback).
|
|
31
|
+
responses:
|
|
32
|
+
"200":
|
|
33
|
+
description: ok
|
|
34
|
+
post:
|
|
35
|
+
operationId: createWidget
|
|
36
|
+
summary: Create a widget (mutating — projected, guarded).
|
|
37
|
+
security:
|
|
38
|
+
- appSecret: []
|
|
39
|
+
responses:
|
|
40
|
+
"201":
|
|
41
|
+
description: created
|
|
42
|
+
/internal/reindex:
|
|
43
|
+
post:
|
|
44
|
+
operationId: reindexWidgets
|
|
45
|
+
summary: Operator-only reindex (withheld from the MCP tool surface).
|
|
46
|
+
x-mcp: false
|
|
47
|
+
responses:
|
|
48
|
+
"202":
|
|
49
|
+
description: accepted
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"operationId": "reindexWidgets",
|
|
4
|
+
"method": "post",
|
|
5
|
+
"mutating": true,
|
|
6
|
+
"excluded": true
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"operationId": "listWidgets",
|
|
10
|
+
"method": "get",
|
|
11
|
+
"mutating": false,
|
|
12
|
+
"excluded": false
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"operationId": "createWidget",
|
|
16
|
+
"method": "post",
|
|
17
|
+
"mutating": true,
|
|
18
|
+
"excluded": false
|
|
19
|
+
}
|
|
20
|
+
]
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
evaluateSecurity,
|
|
6
6
|
isObjectSchema,
|
|
7
7
|
isSafeOperationId,
|
|
8
|
+
isMutatingMethod,
|
|
8
9
|
type OpenApiDoc,
|
|
9
10
|
type OpenApiSchema,
|
|
10
11
|
operationsWithoutId,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
resolveSchema,
|
|
14
15
|
responseSchemaForStatus,
|
|
15
16
|
sharedRequestBodySchemas,
|
|
17
|
+
sharedSecretSchemeName,
|
|
18
|
+
sharedSecretSchemeNames,
|
|
16
19
|
toRouteMatcher,
|
|
17
20
|
undeclaredPathParams,
|
|
18
21
|
undeclaredSecuritySchemes,
|
|
@@ -436,6 +439,18 @@ test("isSafeOperationId rejects path separators and traversal; collectOperations
|
|
|
436
439
|
assert.deepEqual(operationsWithUnsafeId(evil), ["GET /x (../../etc/passwd)"]);
|
|
437
440
|
});
|
|
438
441
|
|
|
442
|
+
test("isMutatingMethod: the HTTP safe verbs (get/head/options) are read-only, everything else mutates", () => {
|
|
443
|
+
// OPTIONS is a CORS/preflight metadata probe — a safe, non-mutating verb (RFC 9110 §9.2.1). It must
|
|
444
|
+
// NOT be flagged mutating, or an `options` operation is wrongly marked destructive in tools/list and
|
|
445
|
+
// captured as a "mutating" fact by the spec↔tool parity snapshot.
|
|
446
|
+
for (const safe of ["get", "head", "options"] as const) {
|
|
447
|
+
assert.equal(isMutatingMethod(safe), false, `${safe} must be treated as read-only`);
|
|
448
|
+
}
|
|
449
|
+
for (const mutating of ["put", "post", "delete", "patch"] as const) {
|
|
450
|
+
assert.equal(isMutatingMethod(mutating), true, `${mutating} must be treated as mutating`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
439
454
|
test("object validation uses own-property checks (prototype keys don't satisfy required or bypass additionalProperties)", () => {
|
|
440
455
|
// A prototype key must NOT satisfy `required` (own-property semantics).
|
|
441
456
|
const req = { type: "object", properties: { toString: { type: "string" } }, required: ["toString"] };
|
|
@@ -633,6 +648,83 @@ test("evaluateSecurity: alternative requirements are OR (any one satisfied autho
|
|
|
633
648
|
assert.equal(evaluateSecurity(doc, op, noHeader, noQuery, env).status, 401);
|
|
634
649
|
});
|
|
635
650
|
|
|
651
|
+
test("sharedSecretSchemeName: exactly one apiKey header scheme with x-nano-secret-env is the guard", () => {
|
|
652
|
+
assert.equal(sharedSecretSchemeName(secured), "webhookKey");
|
|
653
|
+
assert.deepEqual(sharedSecretSchemeNames(secured), ["webhookKey"]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("sharedSecretSchemeName: no candidate scheme → undefined (mutations stay closed)", () => {
|
|
657
|
+
const doc: OpenApiDoc = {
|
|
658
|
+
openapi: "3.0.0",
|
|
659
|
+
components: { securitySchemes: { oauth: { type: "oauth2" } } },
|
|
660
|
+
paths: {},
|
|
661
|
+
};
|
|
662
|
+
assert.equal(sharedSecretSchemeName(doc), undefined);
|
|
663
|
+
assert.deepEqual(sharedSecretSchemeNames(doc), []);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("sharedSecretSchemeName: MULTIPLE candidate schemes are an explicit misconfiguration, not a silent first-wins", () => {
|
|
667
|
+
// Two apiKey header schemes both carry x-nano-secret-env: which one guards mutations would
|
|
668
|
+
// otherwise depend on object iteration (authoring) order — surface it loudly instead.
|
|
669
|
+
const doc: OpenApiDoc = {
|
|
670
|
+
openapi: "3.0.0",
|
|
671
|
+
components: {
|
|
672
|
+
securitySchemes: {
|
|
673
|
+
primary: { type: "apiKey", in: "header", name: "X-A", "x-nano-secret-env": "A_KEY" },
|
|
674
|
+
secondary: { type: "apiKey", in: "header", name: "X-B", "x-nano-secret-env": "B_KEY" },
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
paths: {},
|
|
678
|
+
};
|
|
679
|
+
// The full candidate list is exposed (source of truth) …
|
|
680
|
+
assert.deepEqual(sharedSecretSchemeNames(doc), ["primary", "secondary"]);
|
|
681
|
+
// … and the selector refuses to pick one silently.
|
|
682
|
+
assert.throws(
|
|
683
|
+
() => sharedSecretSchemeName(doc),
|
|
684
|
+
/multiple shared-secret schemes.*primary, secondary/s,
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("sharedSecretSchemeNames: a declared-but-EMPTY x-nano-secret-env is still a candidate (→ 500 downstream, not a silent 401)", () => {
|
|
689
|
+
// An empty `x-nano-secret-env` is a misconfiguration, not "no scheme": a truthiness test would
|
|
690
|
+
// drop it so the guard reports a misleading "no shared-secret scheme" (401). A structural presence
|
|
691
|
+
// check keeps it selected so `evaluateSecurity` surfaces it as a 500, mirroring how it treats an
|
|
692
|
+
// empty env-var pointer.
|
|
693
|
+
const doc: OpenApiDoc = {
|
|
694
|
+
openapi: "3.0.0",
|
|
695
|
+
components: {
|
|
696
|
+
securitySchemes: {
|
|
697
|
+
webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "" },
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
paths: {},
|
|
701
|
+
};
|
|
702
|
+
assert.deepEqual(sharedSecretSchemeNames(doc), ["webhookKey"]);
|
|
703
|
+
assert.equal(sharedSecretSchemeName(doc), "webhookKey");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("sharedSecretSchemeNames: a malformed (null) scheme entry is skipped, not a bare TypeError", () => {
|
|
707
|
+
// A mistakenly empty scheme (`webhookKey:` with no body) parses as `null` in YAML. Dereferencing
|
|
708
|
+
// it (`scheme.type`) would throw a bare TypeError; a non-object scheme is not a valid apiKey
|
|
709
|
+
// shared-secret candidate, so it is skipped and the real candidate is still selected. Built via
|
|
710
|
+
// JSON.parse so the runtime-invalid `null` scheme reaches the function (types forbid it).
|
|
711
|
+
const doc: OpenApiDoc = parseSpec(
|
|
712
|
+
JSON.stringify({
|
|
713
|
+
openapi: "3.0.0",
|
|
714
|
+
components: {
|
|
715
|
+
securitySchemes: {
|
|
716
|
+
brokenEmpty: null,
|
|
717
|
+
webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "KEY" },
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
paths: {},
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
assert.deepEqual(sharedSecretSchemeNames(doc), ["webhookKey"]);
|
|
724
|
+
assert.equal(sharedSecretSchemeName(doc), "webhookKey");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
|
|
636
728
|
test("responseSchemaForStatus: a documented-but-bodyless status suppresses the default fallback", () => {
|
|
637
729
|
const ops = collectOperations({
|
|
638
730
|
openapi: "3.1.0",
|
package/src/openapi/spec.ts
CHANGED
|
@@ -187,6 +187,11 @@ export interface OperationInfo {
|
|
|
187
187
|
* else the document-level default, else `[]`). An empty list means the operation is open. */
|
|
188
188
|
security: OpenApiSecurityRequirement[];
|
|
189
189
|
eject: boolean;
|
|
190
|
+
/** Whether the operation opts OUT of MCP tool projection via the `x-mcp` extension (ADR 0067).
|
|
191
|
+
* Defaults to `false` — an operation IS exposed as a tool unless it explicitly excludes itself.
|
|
192
|
+
* Security-relevant: this is the single authoring switch that keeps an operator-only door (e.g.
|
|
193
|
+
* a human-gated dispatch) out of the agent-facing tool surface. See {@link isMcpExcluded}. */
|
|
194
|
+
mcpExcluded: boolean;
|
|
190
195
|
summary?: string;
|
|
191
196
|
}
|
|
192
197
|
|
|
@@ -307,6 +312,7 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
307
312
|
responseSchemas: collectResponseSchemas(responses),
|
|
308
313
|
security,
|
|
309
314
|
eject: opRaw["x-urban-eject"] === true,
|
|
315
|
+
mcpExcluded: isMcpExcluded(opRaw["x-mcp"]),
|
|
310
316
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
311
317
|
});
|
|
312
318
|
}
|
|
@@ -314,6 +320,161 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
314
320
|
return out;
|
|
315
321
|
}
|
|
316
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Read the `x-mcp` operation extension (ADR 0067 — MCP tool projection). An operation is EXPOSED as
|
|
325
|
+
* an MCP tool by default; it is excluded only when it says so explicitly, so a spec author opts a
|
|
326
|
+
* door OUT rather than every operation having to opt in. Recognised exclusion forms:
|
|
327
|
+
* - `x-mcp: false` — a terse boolean opt-out;
|
|
328
|
+
* - `x-mcp: { exclude: true }` — the object form (room for future keys, e.g. per-tool metadata).
|
|
329
|
+
* Any other value (including `x-mcp: true` / `{ exclude: false }` / absent) leaves the operation
|
|
330
|
+
* exposed — fail-open to the documented default. This is a SECURITY-RELEVANT authoring rule: it is
|
|
331
|
+
* the one switch that keeps an operator-only operation out of the agent-facing tool surface, so it
|
|
332
|
+
* is enforced from the same single walker the runtime and the parity guard both read (no second
|
|
333
|
+
* source of truth to drift, ADR 0053).
|
|
334
|
+
*/
|
|
335
|
+
export function isMcpExcluded(ext: unknown): boolean {
|
|
336
|
+
if (ext === false) return true;
|
|
337
|
+
return isRecord(ext) && ext.exclude === true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The HTTP methods MCP treats as read-only (safe, non-mutating). These are exactly the HTTP "safe"
|
|
341
|
+
* verbs (RFC 9110 §9.2.1) that {@link HTTP_METHODS} can express: GET/HEAD, plus OPTIONS (a
|
|
342
|
+
* CORS/preflight metadata probe that never mutates). Every other method an operation can declare is
|
|
343
|
+
* a mutation whose tool is guarded (ADR 0067 Slice 3). */
|
|
344
|
+
const READ_ONLY_METHODS: readonly HttpMethodLower[] = ["get", "head", "options"];
|
|
345
|
+
|
|
346
|
+
/** Whether an operation's HTTP method mutates state — anything other than a safe read verb. Drives
|
|
347
|
+
* the MCP read/mutate split: read tools are unguarded on loopback, mutating tools are gated. */
|
|
348
|
+
export function isMutatingMethod(method: HttpMethodLower): boolean {
|
|
349
|
+
return !READ_ONLY_METHODS.includes(method);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, and whether
|
|
353
|
+
* `x-mcp` excludes it from projection. This is the shared unit the runtime MCP surface AND the CI
|
|
354
|
+
* spec↔tool parity guard both read — a single derivation of "which operations become tools", never
|
|
355
|
+
* a second walker (ADR 0053). */
|
|
356
|
+
export interface McpToolProjection {
|
|
357
|
+
operationId: string;
|
|
358
|
+
method: HttpMethodLower;
|
|
359
|
+
mutating: boolean;
|
|
360
|
+
excluded: boolean;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Project every operationId-bearing operation to its MCP-tool facts, in the stable
|
|
365
|
+
* `collectOperations` (path, method) order. Excluded operations are RETAINED here (flagged
|
|
366
|
+
* `excluded: true`) rather than dropped, so the parity guard can positively assert that an
|
|
367
|
+
* `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
|
|
368
|
+
* tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
|
|
369
|
+
* `mountApi` routes.
|
|
370
|
+
*/
|
|
371
|
+
export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
|
|
372
|
+
return collectOperations(doc).map((op) => ({
|
|
373
|
+
operationId: op.operationId,
|
|
374
|
+
method: op.method,
|
|
375
|
+
mutating: isMutatingMethod(op.method),
|
|
376
|
+
excluded: op.mcpExcluded,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Compare a previously-captured MCP-tool projection (the committed snapshot) against the projection
|
|
382
|
+
* freshly computed from the spec, returning a human-readable line for every skew — an operation the
|
|
383
|
+
* spec now projects that the snapshot lacks, one the snapshot still lists that the spec no longer
|
|
384
|
+
* projects, or one whose method / mutating / excluded facts changed. An empty array means the spec
|
|
385
|
+
* and the projected tool list are in parity. This is the pure core of the `check:mcp` CI drift gate
|
|
386
|
+
* (ADR 0067) — the same "generated derived artifact + fail-on-drift" pattern the repo already uses
|
|
387
|
+
* for `check:runtime` — and is unit-tested by feeding it an injected skew.
|
|
388
|
+
*/
|
|
389
|
+
export function diffMcpToolProjection(
|
|
390
|
+
snapshot: McpToolProjection[],
|
|
391
|
+
current: McpToolProjection[],
|
|
392
|
+
): string[] {
|
|
393
|
+
const drift: string[] = [];
|
|
394
|
+
const byId = (list: McpToolProjection[]): Map<string, McpToolProjection> =>
|
|
395
|
+
new Map(list.map((p) => [p.operationId, p]));
|
|
396
|
+
const snapshotById = byId(snapshot);
|
|
397
|
+
const currentById = byId(current);
|
|
398
|
+
for (const [id, cur] of currentById) {
|
|
399
|
+
const snap = snapshotById.get(id);
|
|
400
|
+
if (!snap) {
|
|
401
|
+
drift.push(`+ ${id}: spec projects a tool the snapshot is missing (regenerate the snapshot)`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (snap.method !== cur.method || snap.mutating !== cur.mutating || snap.excluded !== cur.excluded) {
|
|
405
|
+
drift.push(
|
|
406
|
+
`~ ${id}: projection changed — snapshot {method:${snap.method}, mutating:${snap.mutating}, ` +
|
|
407
|
+
`excluded:${snap.excluded}} vs spec {method:${cur.method}, mutating:${cur.mutating}, ` +
|
|
408
|
+
`excluded:${cur.excluded}}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const id of snapshotById.keys()) {
|
|
413
|
+
if (!currentById.has(id)) {
|
|
414
|
+
drift.push(`- ${id}: snapshot lists a tool the spec no longer projects (regenerate the snapshot)`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return drift;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Every app shared-secret security scheme (ADR 0067/0059): the `apiKey` schemes in
|
|
422
|
+
* `components.securitySchemes` presented in a request HEADER and pointing at an env var via
|
|
423
|
+
* `x-nano-secret-env` (declared, even if empty — an empty pointer is a misconfiguration surfaced as
|
|
424
|
+
* 500 downstream, not "no scheme"), in document (authoring) order. Single source of truth for both the guard
|
|
425
|
+
* selector (`sharedSecretSchemeName`) and its ambiguity check — a mutating MCP tool REUSES the
|
|
426
|
+
* app's existing shared secret (the same one a `webhook` trigger or a guarded operation already
|
|
427
|
+
* uses) instead of minting an MCP-specific synonym.
|
|
428
|
+
*/
|
|
429
|
+
export function sharedSecretSchemeNames(doc: OpenApiDoc): string[] {
|
|
430
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
431
|
+
const names: string[] = [];
|
|
432
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
433
|
+
// A malformed scheme value (e.g. `webhookKey:` with no body parses as `null` in YAML, or a
|
|
434
|
+
// primitive) is not a valid apiKey shared-secret candidate — skip it rather than dereference it
|
|
435
|
+
// and throw a bare TypeError. This mirrors `evaluateSecurity`, which never treats a non-object
|
|
436
|
+
// scheme as a valid credential (a referenced null/primitive scheme surfaces there as a 500), so
|
|
437
|
+
// there is no silent security hole: mutations fail closed and a genuine misconfig is caught at
|
|
438
|
+
// request time by the enforcement path.
|
|
439
|
+
if (!isRecord(scheme)) continue;
|
|
440
|
+
// Structural presence check (`typeof … === "string"`), NOT truthiness: a declared-but-EMPTY
|
|
441
|
+
// `x-nano-secret-env` is a misconfiguration, not "no scheme". Selecting it here lets
|
|
442
|
+
// `evaluateSecurity` surface it as a 500 (missing env-var pointer) — the same way it treats an
|
|
443
|
+
// empty pointer — instead of silently dropping the scheme so the guard reports a misleading 401.
|
|
444
|
+
if (
|
|
445
|
+
scheme.type === "apiKey" &&
|
|
446
|
+
(scheme.in ?? "header") === "header" &&
|
|
447
|
+
typeof scheme["x-nano-secret-env"] === "string"
|
|
448
|
+
) {
|
|
449
|
+
names.push(name);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return names;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The name of the app's single shared-secret security scheme (ADR 0067/0059) — the credential the
|
|
457
|
+
* framework MUTATING MCP tools require. `undefined` when the app declares no such scheme, in which
|
|
458
|
+
* case the mutating tools stay closed unless an explicit runtime opt-in is set.
|
|
459
|
+
*
|
|
460
|
+
* THROWS when the spec declares MORE THAN ONE candidate: which scheme becomes "the" MCP mutation
|
|
461
|
+
* guard would otherwise depend on `securitySchemes` object iteration (authoring) order, silently
|
|
462
|
+
* guarding with an unintended secret (or rejecting a different, valid shared-secret header). That is
|
|
463
|
+
* an incoherent spec, surfaced loudly — the caller maps it to a 500 misconfiguration — rather than
|
|
464
|
+
* resolved by an invisible "first wins" rule. Derived from `sharedSecretSchemeNames` (no drift).
|
|
465
|
+
*/
|
|
466
|
+
export function sharedSecretSchemeName(doc: OpenApiDoc): string | undefined {
|
|
467
|
+
const names = sharedSecretSchemeNames(doc);
|
|
468
|
+
if (names.length > 1) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`OpenAPI spec declares multiple shared-secret schemes (apiKey header with x-nano-secret-env): ` +
|
|
471
|
+
`${names.join(", ")}. Exactly one may serve as the MCP mutation guard — remove the extras so ` +
|
|
472
|
+
"the guard scheme is unambiguous.",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return names[0];
|
|
476
|
+
}
|
|
477
|
+
|
|
317
478
|
/**
|
|
318
479
|
* Throw if the document declares the same `operationId` on more than one operation. An operationId
|
|
319
480
|
* must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -296,10 +296,19 @@ export interface ElementInstanceSummary {
|
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
298
|
* The kind of wait an element instance is parked in, as a
|
|
299
|
-
* `/v2/element-instances/wait-states/search` result reports it.
|
|
300
|
-
*
|
|
301
|
-
* (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well
|
|
302
|
-
*
|
|
299
|
+
* `/v2/element-instances/wait-states/search` result reports it. This is the *canonical*
|
|
300
|
+
* six-type contract (Zeebe's, as of 2026): a `JOB` (a service task awaiting a worker), a
|
|
301
|
+
* `MESSAGE` (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well
|
|
302
|
+
* as a `USER_TASK`.
|
|
303
|
+
*
|
|
304
|
+
* **Not every deployed engine implements the whole union.** It is forward-looking on
|
|
305
|
+
* purpose (the response mapper and {@link normalizeWaitStateType} tolerate all six so a
|
|
306
|
+
* newer engine's rows map cleanly), but the *currently deployed* nanobpmn gateway's
|
|
307
|
+
* read-model floor is narrower — see {@link DEPLOYED_WAIT_STATE_TYPES}. A
|
|
308
|
+
* {@link ElementInstanceWaitStateFilter} whose `waitStateType` falls outside that floor is
|
|
309
|
+
* rejected (the gateway answers HTTP 422); {@link assertDeployedWaitStateType} enforces the
|
|
310
|
+
* same floor client-side so the divergence surfaces as a clear error offline, in tests, and
|
|
311
|
+
* against a live engine alike. Ref: Magikcraft/nano-bpm#1042.
|
|
303
312
|
*/
|
|
304
313
|
export type WaitStateType =
|
|
305
314
|
| "JOB"
|
|
@@ -309,6 +318,59 @@ export type WaitStateType =
|
|
|
309
318
|
| "SIGNAL"
|
|
310
319
|
| "CONDITION";
|
|
311
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The `waitStateType` values the *currently deployed* nanobpmn gateway's wait-state read
|
|
323
|
+
* model actually implements — the **deployed floor**, a strict subset of the canonical
|
|
324
|
+
* {@link WaitStateType} union. The gateway's read model is a snapshot of the Zeebe contract
|
|
325
|
+
* taken 2026-06-09 (the same day Zeebe extended it with `USER_TASK`, then `TIMER`/`SIGNAL`/
|
|
326
|
+
* `CONDITION`), so it recognizes only `JOB | MESSAGE` and rejects any other filter value
|
|
327
|
+
* with HTTP 422.
|
|
328
|
+
*
|
|
329
|
+
* This constant is the **single source of truth** for that floor: the SDK-backed and
|
|
330
|
+
* WASM-backed {@link EngineClient} adapters both gate through {@link assertDeployedWaitStateType},
|
|
331
|
+
* and the WASM emulation synthesizes only these park kinds — so a test cannot pass against
|
|
332
|
+
* the emulation while a real gateway would 422 (No Drift Surfaces). Widen it (adding a leg
|
|
333
|
+
* backed by the real gateway) only once the engine actually serves the richer type — the
|
|
334
|
+
* tracking gap is Magikcraft/nano-bpm#1042.
|
|
335
|
+
*/
|
|
336
|
+
export const DEPLOYED_WAIT_STATE_TYPES: readonly WaitStateType[] = ["JOB", "MESSAGE"];
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Thrown when a wait-state search is asked to filter on a {@link WaitStateType} the deployed
|
|
340
|
+
* gateway's read model does not implement (outside {@link DEPLOYED_WAIT_STATE_TYPES}). It
|
|
341
|
+
* fails fast client-side with a clear, offline-detectable error rather than letting the call
|
|
342
|
+
* reach the gateway and surface as an opaque HTTP 422 "did not match any variant of untagged
|
|
343
|
+
* enum WaitStateTypeFilterProperty". Ref: Magikcraft/nano-bpm#1042.
|
|
344
|
+
*/
|
|
345
|
+
export class UnsupportedWaitStateTypeError extends Error {
|
|
346
|
+
readonly waitStateType: string;
|
|
347
|
+
readonly supported: readonly WaitStateType[];
|
|
348
|
+
constructor(waitStateType: string) {
|
|
349
|
+
super(
|
|
350
|
+
`unsupported waitStateType "${waitStateType}": the deployed nanobpmn gateway's ` +
|
|
351
|
+
`wait-state read model implements only ${DEPLOYED_WAIT_STATE_TYPES.join(" | ")} and ` +
|
|
352
|
+
`rejects any other value with HTTP 422. Ref: Magikcraft/nano-bpm#1042.`,
|
|
353
|
+
);
|
|
354
|
+
this.name = "UnsupportedWaitStateTypeError";
|
|
355
|
+
this.waitStateType = waitStateType;
|
|
356
|
+
this.supported = DEPLOYED_WAIT_STATE_TYPES;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Assert a wait-state filter's `waitStateType` is within the deployed floor
|
|
362
|
+
* ({@link DEPLOYED_WAIT_STATE_TYPES}), throwing {@link UnsupportedWaitStateTypeError}
|
|
363
|
+
* otherwise. An absent selector is always allowed (the search is unfiltered by type). Both
|
|
364
|
+
* {@link EngineClient} adapters call this before issuing a wait-state search, so the floor is
|
|
365
|
+
* enforced identically whether the query runs against a live gateway or the in-process
|
|
366
|
+
* emulation. No-ops for an in-floor type.
|
|
367
|
+
*/
|
|
368
|
+
export function assertDeployedWaitStateType(waitStateType?: WaitStateType): void {
|
|
369
|
+
if (waitStateType !== undefined && !DEPLOYED_WAIT_STATE_TYPES.includes(waitStateType)) {
|
|
370
|
+
throw new UnsupportedWaitStateTypeError(waitStateType);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
312
374
|
/**
|
|
313
375
|
* A single parked (waiting) element instance as
|
|
314
376
|
* {@link EngineClient.searchElementInstanceWaitStates} reports it. Every variant carries the
|
|
@@ -352,6 +414,10 @@ export interface ElementInstanceFilter {
|
|
|
352
414
|
* Selectors for an element-instance *wait-state* search: which process instance, element,
|
|
353
415
|
* and/or kind of wait to match. Used by {@link EngineClient.searchElementInstanceWaitStates}.
|
|
354
416
|
* There is deliberately no lifecycle-`state` selector — a wait state is always an active park.
|
|
417
|
+
*
|
|
418
|
+
* A `waitStateType` selector must fall within the deployed floor
|
|
419
|
+
* ({@link DEPLOYED_WAIT_STATE_TYPES}); a value outside it is rejected with
|
|
420
|
+
* {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422).
|
|
355
421
|
*/
|
|
356
422
|
export interface ElementInstanceWaitStateFilter {
|
|
357
423
|
processInstanceKey?: string;
|
|
@@ -512,11 +578,19 @@ export interface EngineClient {
|
|
|
512
578
|
): Promise<ElementInstanceSummary[]>;
|
|
513
579
|
/**
|
|
514
580
|
* Search the *wait states* of element instances
|
|
515
|
-
* (`POST /v2/element-instances/wait-states/search`) —
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
581
|
+
* (`POST /v2/element-instances/wait-states/search`) — the parks the deployed engine's
|
|
582
|
+
* read model serves. A zero-wait read; each result is discriminated by `waitStateType`
|
|
583
|
+
* (see {@link ElementInstanceWaitState}), so a consumer can read the job/message parks a
|
|
584
|
+
* {@link searchUserTasks} cannot surface.
|
|
585
|
+
*
|
|
586
|
+
* **Deployed floor.** The currently deployed nanobpmn gateway implements only
|
|
587
|
+
* `JOB | MESSAGE` ({@link DEPLOYED_WAIT_STATE_TYPES}) — a `USER_TASK` park is read via
|
|
588
|
+
* {@link searchUserTasks}, not here. A `waitStateType` filter outside the floor is rejected
|
|
589
|
+
* with {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422); an
|
|
590
|
+
* unfiltered search returns only in-floor parks. The `waitStateType` union is
|
|
591
|
+
* forward-looking (richer engines may serve more), but this adapter contract pins the
|
|
592
|
+
* floor so a query cannot pass in emulation while a real engine rejects it. Ref:
|
|
593
|
+
* Magikcraft/nano-bpm#1042.
|
|
520
594
|
*/
|
|
521
595
|
searchElementInstanceWaitStates(
|
|
522
596
|
filter?: ElementInstanceWaitStateFilter,
|