@agiledigital/pingone-aic-script-tester 0.1.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.
Files changed (169) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +274 -0
  4. package/dist/bin/fetch-jar.d.ts +2 -0
  5. package/dist/bin/fetch-jar.js +13 -0
  6. package/dist/bin/pull-profile.d.ts +2 -0
  7. package/dist/bin/pull-profile.js +40 -0
  8. package/dist/bin/show-log.d.ts +2 -0
  9. package/dist/bin/show-log.js +31 -0
  10. package/dist/classes/.complete +12 -0
  11. package/dist/classes/AmClassShutter.class +0 -0
  12. package/dist/classes/AmContextFactory$ObservedContext.class +0 -0
  13. package/dist/classes/AmContextFactory.class +0 -0
  14. package/dist/classes/AmScopes.class +0 -0
  15. package/dist/classes/HostOps.class +0 -0
  16. package/dist/classes/JsValues.class +0 -0
  17. package/dist/classes/Json$Parser.class +0 -0
  18. package/dist/classes/Json.class +0 -0
  19. package/dist/classes/LanguageVersions.class +0 -0
  20. package/dist/classes/Runner.class +0 -0
  21. package/dist/classes/ScriptContextScope.class +0 -0
  22. package/dist/src/aic/callbacks.d.ts +35 -0
  23. package/dist/src/aic/callbacks.js +130 -0
  24. package/dist/src/aic/conform.d.ts +90 -0
  25. package/dist/src/aic/conform.js +193 -0
  26. package/dist/src/aic/constants.d.ts +13 -0
  27. package/dist/src/aic/constants.js +13 -0
  28. package/dist/src/aic/diff.d.ts +25 -0
  29. package/dist/src/aic/diff.js +201 -0
  30. package/dist/src/aic/emit-journey.d.ts +64 -0
  31. package/dist/src/aic/emit-journey.js +254 -0
  32. package/dist/src/aic/emit-result.d.ts +10 -0
  33. package/dist/src/aic/emit-result.js +39 -0
  34. package/dist/src/aic/emit-session.d.ts +26 -0
  35. package/dist/src/aic/emit-session.js +132 -0
  36. package/dist/src/aic/emit-subject.d.ts +40 -0
  37. package/dist/src/aic/emit-subject.js +210 -0
  38. package/dist/src/aic/file-lease.d.ts +35 -0
  39. package/dist/src/aic/file-lease.js +543 -0
  40. package/dist/src/aic/http.d.ts +21 -0
  41. package/dist/src/aic/http.js +127 -0
  42. package/dist/src/aic/idm.d.ts +10 -0
  43. package/dist/src/aic/idm.js +112 -0
  44. package/dist/src/aic/index.d.ts +35 -0
  45. package/dist/src/aic/index.js +21 -0
  46. package/dist/src/aic/js.d.ts +8 -0
  47. package/dist/src/aic/js.js +11 -0
  48. package/dist/src/aic/lease-identity.d.ts +38 -0
  49. package/dist/src/aic/lease-identity.js +72 -0
  50. package/dist/src/aic/lease-lock.d.ts +45 -0
  51. package/dist/src/aic/lease-lock.js +173 -0
  52. package/dist/src/aic/managed.d.ts +28 -0
  53. package/dist/src/aic/managed.js +199 -0
  54. package/dist/src/aic/provider.d.ts +156 -0
  55. package/dist/src/aic/provider.js +416 -0
  56. package/dist/src/aic/record.d.ts +28 -0
  57. package/dist/src/aic/record.js +183 -0
  58. package/dist/src/aic/resource-snapshot.d.ts +12 -0
  59. package/dist/src/aic/resource-snapshot.js +116 -0
  60. package/dist/src/aic/run.d.ts +102 -0
  61. package/dist/src/aic/run.js +450 -0
  62. package/dist/src/aic/tenant.d.ts +107 -0
  63. package/dist/src/aic/tenant.js +307 -0
  64. package/dist/src/aic/trace.d.ts +25 -0
  65. package/dist/src/aic/trace.js +56 -0
  66. package/dist/src/aic/txid.d.ts +11 -0
  67. package/dist/src/aic/txid.js +25 -0
  68. package/dist/src/aic/unsupported.d.ts +15 -0
  69. package/dist/src/aic/unsupported.js +60 -0
  70. package/dist/src/bindings/allowlist.d.ts +42 -0
  71. package/dist/src/bindings/allowlist.js +62 -0
  72. package/dist/src/bindings/harvest.d.ts +7 -0
  73. package/dist/src/bindings/harvest.js +137 -0
  74. package/dist/src/bindings/index.d.ts +4 -0
  75. package/dist/src/bindings/index.js +3 -0
  76. package/dist/src/bindings/preamble.d.ts +23 -0
  77. package/dist/src/bindings/preamble.js +36 -0
  78. package/dist/src/bindings/run.d.ts +33 -0
  79. package/dist/src/bindings/run.js +55 -0
  80. package/dist/src/case/equal.d.ts +2 -0
  81. package/dist/src/case/equal.js +40 -0
  82. package/dist/src/case/index.d.ts +5 -0
  83. package/dist/src/case/index.js +4 -0
  84. package/dist/src/case/portable.d.ts +10 -0
  85. package/dist/src/case/portable.js +28 -0
  86. package/dist/src/case/session.d.ts +34 -0
  87. package/dist/src/case/session.js +66 -0
  88. package/dist/src/case/state.d.ts +4 -0
  89. package/dist/src/case/state.js +47 -0
  90. package/dist/src/case/types.d.ts +266 -0
  91. package/dist/src/case/types.js +106 -0
  92. package/dist/src/case/util.d.ts +7 -0
  93. package/dist/src/case/util.js +115 -0
  94. package/dist/src/case/validate.d.ts +8 -0
  95. package/dist/src/case/validate.js +463 -0
  96. package/dist/src/case/verdict.d.ts +6 -0
  97. package/dist/src/case/verdict.js +810 -0
  98. package/dist/src/diagnostics.d.ts +7 -0
  99. package/dist/src/diagnostics.js +5 -0
  100. package/dist/src/emit-dts.d.ts +2 -0
  101. package/dist/src/emit-dts.js +82 -0
  102. package/dist/src/emit-js.d.ts +3 -0
  103. package/dist/src/emit-js.js +149 -0
  104. package/dist/src/harness/failures.d.ts +26 -0
  105. package/dist/src/harness/failures.js +113 -0
  106. package/dist/src/harness/idm.d.ts +12 -0
  107. package/dist/src/harness/idm.js +44 -0
  108. package/dist/src/harness/index.d.ts +11 -0
  109. package/dist/src/harness/index.js +6 -0
  110. package/dist/src/harness/lease.d.ts +136 -0
  111. package/dist/src/harness/lease.js +286 -0
  112. package/dist/src/harness/residue.d.ts +23 -0
  113. package/dist/src/harness/residue.js +73 -0
  114. package/dist/src/harness/show-log.d.ts +43 -0
  115. package/dist/src/harness/show-log.js +253 -0
  116. package/dist/src/harness/spec.d.ts +67 -0
  117. package/dist/src/harness/spec.js +189 -0
  118. package/dist/src/harness/step.d.ts +80 -0
  119. package/dist/src/harness/step.js +97 -0
  120. package/dist/src/harness/types.d.ts +100 -0
  121. package/dist/src/harness/types.js +1 -0
  122. package/dist/src/harness/vitest.d.ts +52 -0
  123. package/dist/src/harness/vitest.js +155 -0
  124. package/dist/src/idents.d.ts +4 -0
  125. package/dist/src/idents.js +42 -0
  126. package/dist/src/jvm.d.ts +124 -0
  127. package/dist/src/jvm.js +432 -0
  128. package/dist/src/load.d.ts +2 -0
  129. package/dist/src/load.js +104 -0
  130. package/dist/src/paths.d.ts +36 -0
  131. package/dist/src/paths.js +50 -0
  132. package/dist/src/profile/index.d.ts +5 -0
  133. package/dist/src/profile/index.js +3 -0
  134. package/dist/src/profile/normalise.d.ts +16 -0
  135. package/dist/src/profile/normalise.js +106 -0
  136. package/dist/src/profile/pull.d.ts +22 -0
  137. package/dist/src/profile/pull.js +36 -0
  138. package/dist/src/profile/seed.d.ts +11 -0
  139. package/dist/src/profile/seed.js +28 -0
  140. package/dist/src/profile/store.d.ts +13 -0
  141. package/dist/src/profile/store.js +60 -0
  142. package/dist/src/profile/types.d.ts +45 -0
  143. package/dist/src/profile/types.js +1 -0
  144. package/dist/src/profile/validate.d.ts +38 -0
  145. package/dist/src/profile/validate.js +129 -0
  146. package/dist/src/project.d.ts +37 -0
  147. package/dist/src/project.js +93 -0
  148. package/dist/src/protocol.d.ts +62 -0
  149. package/dist/src/protocol.js +68 -0
  150. package/dist/src/provider-module.d.ts +11 -0
  151. package/dist/src/provider-module.js +46 -0
  152. package/dist/src/runner.d.ts +39 -0
  153. package/dist/src/runner.js +337 -0
  154. package/dist/src/schema.d.ts +48 -0
  155. package/dist/src/schema.js +93 -0
  156. package/generated/scripted-decision-mocks.cjs +722 -0
  157. package/generated/scripted-decision-mocks.d.ts +626 -0
  158. package/generated/scripted-decision-next.json +3640 -0
  159. package/java/AmClassShutter.java +98 -0
  160. package/java/AmContextFactory.java +69 -0
  161. package/java/AmScopes.java +49 -0
  162. package/java/HostOps.java +180 -0
  163. package/java/JsValues.java +39 -0
  164. package/java/Json.java +441 -0
  165. package/java/LanguageVersions.java +55 -0
  166. package/java/Runner.java +289 -0
  167. package/java/ScriptContextScope.java +147 -0
  168. package/package.json +88 -0
  169. package/src/bindings/rhino/runtime.cjs +3021 -0
@@ -0,0 +1,116 @@
1
+ /** Request projections and confirmed-read snapshots for leased AM resources. */
2
+ import { deepEqual } from "../case/equal.js";
3
+ import { isPlainObject } from "../case/util.js";
4
+ import { AicLaneError } from "./tenant.js";
5
+ const SCRIPT_SERVER_FIELDS = new Set([
6
+ "createdBy",
7
+ "creationDate",
8
+ "lastModifiedBy",
9
+ "lastModifiedDate",
10
+ "_rev",
11
+ ]);
12
+ const NODE_SERVER_FIELDS = new Set(["_id", "_rev", "_type", "_outcomes"]);
13
+ const TREE_SERVER_FIELDS = new Set([
14
+ "_id",
15
+ "_rev",
16
+ "innerTreeOnly",
17
+ "noSession",
18
+ "mustRun",
19
+ "transactionalOnly",
20
+ ]);
21
+ /** Strip server-owned node metadata even though AM accepts some of it. */
22
+ export function nodeRequestProjection(value) {
23
+ const { _id: _ignoredId, _type: _ignoredType, _outcomes: _ignoredOutcomes, _rev: _ignoredRev, ...body } = value;
24
+ return body;
25
+ }
26
+ export function resourceRequestProjection(kind, value) {
27
+ return kind === "node" ? nodeRequestProjection(value) : { ...value };
28
+ }
29
+ /**
30
+ * Return the snapshot from the confirming GET, never from submitted bytes.
31
+ *
32
+ * Script and tree `description` round-trips byte-exact (measured 2026-09-14,
33
+ * `docs/api/04-scripts.md`), so the marker stays in the comparison rather than
34
+ * being normalized away — a rewritten marker is a real failure, not noise.
35
+ */
36
+ export function confirmResourceSnapshot(kind, submitted, confirmed) {
37
+ const expected = normalize(kind, resourceRequestProjection(kind, submitted), submitted);
38
+ const actual = normalize(kind, confirmed, submitted);
39
+ if (!deepEqual(expected, actual)) {
40
+ throw new AicLaneError(`${kind} confirming read did not match the submitted functional content`);
41
+ }
42
+ return actual;
43
+ }
44
+ function normalize(kind, value, submitted) {
45
+ if (!isPlainObject(value)) {
46
+ throw new AicLaneError(`${kind} confirming read was not a JSON object`);
47
+ }
48
+ if (kind === "script") {
49
+ return normalizeScript(value);
50
+ }
51
+ if (kind === "node") {
52
+ return normalizeNode(value);
53
+ }
54
+ return normalizeTree(value, submitted);
55
+ }
56
+ function normalizeScript(value) {
57
+ const allowed = new Set([
58
+ "_id",
59
+ "name",
60
+ "description",
61
+ "script",
62
+ "default",
63
+ "language",
64
+ "context",
65
+ "evaluatorVersion",
66
+ ]);
67
+ rejectUnknown("script", value, allowed, SCRIPT_SERVER_FIELDS);
68
+ if (typeof value.script !== "string") {
69
+ throw new AicLaneError("script confirming read has no base64 source");
70
+ }
71
+ const normalized = {};
72
+ for (const key of allowed) {
73
+ if (key === "script") {
74
+ normalized.source = Buffer.from(value.script, "base64").toString("utf8");
75
+ }
76
+ else if (Object.prototype.hasOwnProperty.call(value, key)) {
77
+ normalized[key] = value[key];
78
+ }
79
+ }
80
+ return normalized;
81
+ }
82
+ function normalizeNode(value) {
83
+ const allowed = new Set(["script", "outcomes", "inputs", "outputs"]);
84
+ rejectUnknown("node", value, allowed, NODE_SERVER_FIELDS);
85
+ return copyAllowed(value, allowed);
86
+ }
87
+ function normalizeTree(value, submitted) {
88
+ const allowed = new Set(Object.keys(submitted));
89
+ rejectUnknown("tree", value, allowed, TREE_SERVER_FIELDS);
90
+ const normalized = copyAllowed(value, allowed);
91
+ if (isPlainObject(normalized.nodes) && isPlainObject(submitted.nodes)) {
92
+ const nodes = {};
93
+ for (const [id, node] of Object.entries(normalized.nodes)) {
94
+ if (!isPlainObject(node)) {
95
+ throw new AicLaneError("tree confirming read contains a non-object node entry");
96
+ }
97
+ const submittedNode = submitted.nodes[id];
98
+ const copy = { ...node };
99
+ if (isPlainObject(submittedNode) && !("version" in submittedNode)) {
100
+ delete copy.version;
101
+ }
102
+ nodes[id] = copy;
103
+ }
104
+ normalized.nodes = nodes;
105
+ }
106
+ return normalized;
107
+ }
108
+ function rejectUnknown(kind, value, functional, serverOwned) {
109
+ const unknown = Object.keys(value).filter((key) => !functional.has(key) && !serverOwned.has(key));
110
+ if (unknown.length > 0) {
111
+ throw new AicLaneError(`${kind} confirming read contained unexpected fields: ${unknown.join(", ")}`);
112
+ }
113
+ }
114
+ function copyAllowed(value, allowed) {
115
+ return Object.fromEntries(Object.entries(value).filter(([key]) => allowed.has(key)));
116
+ }
@@ -0,0 +1,102 @@
1
+ import type { TenantProvider } from "./provider.ts";
2
+ import type { Case, JsonValue, RecordedEffects } from "../case/types.ts";
3
+ import { type WrapperJourney } from "./emit-journey.ts";
4
+ import { type ManagedFixture } from "./managed.ts";
5
+ import { type AicIo, type AmResponse, type TenantSession } from "./tenant.ts";
6
+ /** One submitted callback value, matched to an emitted callback by type. */
7
+ export interface AicReply {
8
+ type: string;
9
+ value: JsonValue;
10
+ }
11
+ export interface RunAicOptions {
12
+ io?: AicIo;
13
+ tenant?: string;
14
+ realm?: string;
15
+ runId?: string;
16
+ project?: string;
17
+ /** Where the tenant and its bearer come from; see `resolveTenantProvider`. */
18
+ provider?: TenantProvider;
19
+ /** Harness-owned records from the local lease's fixture ledger. */
20
+ managedFixtures?: readonly ManagedFixture[];
21
+ /**
22
+ * One entry per suspended pass, in order: what the client submits to advance
23
+ * the journey. Omit for a single-pass case. The journey is provisioned once
24
+ * and every pass re-enters the same node, which is what makes this the
25
+ * counterpart of the local lane's `.step()` chain rather than N runs.
26
+ */
27
+ replies?: readonly (readonly AicReply[])[];
28
+ }
29
+ export interface AicRunValidation {
30
+ harnessOwnsManaged: boolean;
31
+ unsupported: string[];
32
+ }
33
+ export type AicPassObserver = (index: number, effects: RecordedEffects) => void | Promise<void>;
34
+ export type CreatedResource = {
35
+ kind: "script";
36
+ id: string;
37
+ } | {
38
+ kind: "node";
39
+ id: string;
40
+ } | {
41
+ kind: "tree";
42
+ name: string;
43
+ };
44
+ /**
45
+ * Create a namespaced wrapper journey, invoke it once, record effects, and
46
+ * delete everything created. Refuses to overwrite an existing script, node,
47
+ * or tree. Every `aic` invocation goes through `--no-prompt`.
48
+ */
49
+ export declare function runAicLane(kase: Case, source: string, options?: RunAicOptions): Promise<RecordedEffects>;
50
+ /**
51
+ * One-shot compatibility facade: run a step chain on a tenant, provisioning
52
+ * and deleting one throwaway journey around this call. `useLease()` does not
53
+ * call this function; it hands the recorded chain to its pre-opened
54
+ * `AicFileLease` so stable resources are reused across tests.
55
+ *
56
+ * `cases` is one `Case` per pass — `cases[0].given` seeds the journey and every
57
+ * later `given` is what the local lane computed for that pass. Handing the
58
+ * carried seeds over rather than recomputing them here is the whole point of
59
+ * the two lanes: the tenant's own `before` snapshot is checked against them
60
+ * (`verifySeedsVisible`), so a carry rule that is wrong — carrying transient
61
+ * state across a suspend, say — fails loudly here instead of agreeing with
62
+ * itself locally.
63
+ *
64
+ * Managed state is accepted only with a fixture ledger that exactly matches
65
+ * the first pass. Those records are create-only seeded, read back, and removed
66
+ * around the whole journey while a per-tenant lock excludes parallel fixture
67
+ * runs. Later passes may carry managed changes made by the subject itself.
68
+ *
69
+ * `options.replies` must hold one entry per suspended pass, so
70
+ * `replies.length === cases.length - 1`.
71
+ */
72
+ export declare function runAicChain(cases: readonly Case[], source: string, options?: RunAicOptions): Promise<RecordedEffects[]>;
73
+ /**
74
+ * Shared fail-closed validation for the throwaway and reusable runners.
75
+ * Lease-specific source, realm, and outcome constraints remain with the
76
+ * lease because the one-shot facade has no pre-opened graph to compare.
77
+ */
78
+ export declare function validateAicRun(cases: readonly Case[], replies: readonly (readonly AicReply[])[], managedFixtures?: readonly ManagedFixture[]): AicRunValidation;
79
+ /**
80
+ * The session cookie's name is per-tenant, so it has to be read rather than
81
+ * assumed (`given.cookieName` is refused on this lane for the same reason).
82
+ */
83
+ export declare function fetchCookieName(io: AicIo, session: TenantSession): Promise<string>;
84
+ export declare function provisionJourney(io: AicIo, session: TenantSession, wrapper: WrapperJourney, created: CreatedResource[]): Promise<void>;
85
+ /**
86
+ * Invoke the journey, then answer each declared pass in turn.
87
+ *
88
+ * Every pass re-enters the same node, so the tenant carries state between them
89
+ * on its own terms — which is the point: the local lane has to model that, and
90
+ * this is what it is modelled against. A pass that reaches the result node
91
+ * while replies are still pending is an error, because the chain then asserts
92
+ * against a journey shorter than it described.
93
+ */
94
+ export declare function driveJourney(io: AicIo, session: TenantSession, wrapper: WrapperJourney, cases: readonly Case[], replies: readonly (readonly AicReply[])[], tx: {
95
+ next: () => string;
96
+ }, proof?: {
97
+ leaseDigest: string;
98
+ invocationNonce: string;
99
+ subjectDigest: string;
100
+ }, observePass?: AicPassObserver): Promise<RecordedEffects[]>;
101
+ export declare function invokeJourney(io: AicIo, session: TenantSession, wrapper: WrapperJourney, transactionId: string, body?: string): Promise<AmResponse>;
102
+ export declare function deleteCreatedResources(io: AicIo, session: TenantSession, realm: string, created: readonly CreatedResource[]): Promise<string[]>;
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { projectRoot } from "../project.js";
3
+ import { fillCallbackInputs, parseAuthenticateCallbacks } from "./callbacks.js";
4
+ import { emitWrapperJourney } from "./emit-journey.js";
5
+ import { emitSessionJourney } from "./emit-session.js";
6
+ import { acquireManagedFixtureLock, deleteManagedFixture, managedSeedMatches, seedManagedFixtures, } from "./managed.js";
7
+ import { assembleEffects, parseSubjectDump } from "./record.js";
8
+ import { confirmResourceSnapshot, resourceRequestProjection, } from "./resource-snapshot.js";
9
+ import { AicLaneError, amConfigHeaders, amRequest, connectTenant, defaultAicIo, realmJsonPath, } from "./tenant.js";
10
+ import { beginSingleTrace, beginTrace, clearAicTrace } from "./trace.js";
11
+ import { TX_HEADER } from "./txid.js";
12
+ import { aicUnsupportedReason } from "./unsupported.js";
13
+ /**
14
+ * Create a namespaced wrapper journey, invoke it once, record effects, and
15
+ * delete everything created. Refuses to overwrite an existing script, node,
16
+ * or tree. Every `aic` invocation goes through `--no-prompt`.
17
+ */
18
+ export async function runAicLane(kase, source, options = {}) {
19
+ const passes = await runAicChain([kase], source, options);
20
+ return passes[passes.length - 1];
21
+ }
22
+ /**
23
+ * One-shot compatibility facade: run a step chain on a tenant, provisioning
24
+ * and deleting one throwaway journey around this call. `useLease()` does not
25
+ * call this function; it hands the recorded chain to its pre-opened
26
+ * `AicFileLease` so stable resources are reused across tests.
27
+ *
28
+ * `cases` is one `Case` per pass — `cases[0].given` seeds the journey and every
29
+ * later `given` is what the local lane computed for that pass. Handing the
30
+ * carried seeds over rather than recomputing them here is the whole point of
31
+ * the two lanes: the tenant's own `before` snapshot is checked against them
32
+ * (`verifySeedsVisible`), so a carry rule that is wrong — carrying transient
33
+ * state across a suspend, say — fails loudly here instead of agreeing with
34
+ * itself locally.
35
+ *
36
+ * Managed state is accepted only with a fixture ledger that exactly matches
37
+ * the first pass. Those records are create-only seeded, read back, and removed
38
+ * around the whole journey while a per-tenant lock excludes parallel fixture
39
+ * runs. Later passes may carry managed changes made by the subject itself.
40
+ *
41
+ * `options.replies` must hold one entry per suspended pass, so
42
+ * `replies.length === cases.length - 1`.
43
+ */
44
+ export async function runAicChain(cases, source, options = {}) {
45
+ const replies = options.replies ?? [];
46
+ const validation = validateAicRun(cases, replies, options.managedFixtures);
47
+ const last = cases[cases.length - 1];
48
+ const managedFixtures = options.managedFixtures ?? [];
49
+ if (validation.unsupported.length > 0) {
50
+ throw new AicLaneError(`AIC lane skipped: ${validation.unsupported.join("; ")}`);
51
+ }
52
+ const kase = emitCase(cases);
53
+ const project = options.project ?? projectRoot();
54
+ const io = options.io ?? defaultAicIo(project);
55
+ const session = await connectTenant(io, {
56
+ ...(options.tenant !== undefined ? { tenant: options.tenant } : {}),
57
+ ...(options.provider !== undefined ? { provider: options.provider } : {}),
58
+ useConfigured: options.io === undefined,
59
+ project,
60
+ });
61
+ const runId = options.runId ?? randomUUID().replace(/-/g, "").slice(0, 12);
62
+ const wrapper = emitWrapperJourney(kase, source, {
63
+ runId,
64
+ ...(options.realm !== undefined ? { realm: options.realm } : {}),
65
+ });
66
+ const created = [];
67
+ const seededManaged = [];
68
+ const releaseManagedLock = managedFixtures.length === 0
69
+ ? async () => Promise.resolve()
70
+ : await acquireManagedFixtureLock(session, last.name);
71
+ try {
72
+ await seedManagedFixtures(io, session, managedFixtures, seededManaged);
73
+ if (kase.given.existingSession !== undefined) {
74
+ await mintSession(io, session, kase.given.existingSession, wrapper, runId, created);
75
+ // Mint succeeded: its one-off id is harness plumbing, not a subject pass.
76
+ // A mint *failure* leaves that trace in place so show-log can fetch it.
77
+ clearAicTrace();
78
+ }
79
+ await provisionJourney(io, session, wrapper, created);
80
+ return await driveJourney(io, session, wrapper, cases, replies, beginTrace(session.tenantName));
81
+ }
82
+ finally {
83
+ try {
84
+ await cleanup(io, session, wrapper.realm, created, seededManaged);
85
+ }
86
+ finally {
87
+ await releaseManagedLock();
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * Shared fail-closed validation for the throwaway and reusable runners.
93
+ * Lease-specific source, realm, and outcome constraints remain with the
94
+ * lease because the one-shot facade has no pre-opened graph to compare.
95
+ */
96
+ export function validateAicRun(cases, replies, managedFixtures) {
97
+ const first = cases[0];
98
+ if (first === undefined) {
99
+ throw new AicLaneError("runAicChain needs at least one case");
100
+ }
101
+ if (replies.length !== cases.length - 1) {
102
+ throw new AicLaneError(`runAicChain: ${cases.length} passes need ${cases.length - 1} reply sets, got ${replies.length}`);
103
+ }
104
+ const harnessOwnsManaged = managedFixtures !== undefined &&
105
+ managedSeedMatches(first.given.managed, managedFixtures);
106
+ if (managedFixtures !== undefined && !harnessOwnsManaged) {
107
+ throw new AicLaneError("managed fixture provenance does not match the first pass's given.managed seed");
108
+ }
109
+ const unsupported = cases.flatMap((kase) => {
110
+ const reason = aicUnsupportedReason(kase, { harnessOwnsManaged });
111
+ return reason === undefined ? [] : [`${kase.name}: ${reason}`];
112
+ });
113
+ return { harnessOwnsManaged, unsupported };
114
+ }
115
+ /**
116
+ * Run a throwaway journey to completion and put its session cookie on the
117
+ * subject's invoke. This is the only way to seed `existingSession`: a session
118
+ * exists only after a journey completes (`docs/api/09-journeys.md`).
119
+ *
120
+ * The mini journey is provisioned into the same `created` list as the subject,
121
+ * so one cleanup removes both even if the subject invoke throws.
122
+ *
123
+ * Its authenticate call is *not* a numbered pass of the subject stem: pass 1
124
+ * of the script under test must stay `-01`. Minting gets its own uuid so a
125
+ * mint failure is still look-up-able; a successful mint is cleared before the
126
+ * subject runs.
127
+ */
128
+ async function mintSession(io, session, existingSession, wrapper, runId, created) {
129
+ const minter = emitSessionJourney(existingSession, {
130
+ runId,
131
+ realm: wrapper.realm,
132
+ });
133
+ await provisionJourney(io, session, minter, created);
134
+ const response = await invokeJourney(io, session, minter, beginSingleTrace(session.tenantName));
135
+ const body = response.body;
136
+ if (typeof body.tokenId !== "string" || body.tokenId.length === 0) {
137
+ // A journey that returns callbacks has not completed, so there is no
138
+ // session yet; seeding nothing and running anyway would grade the subject
139
+ // as though the harness had meant the binding to be absent.
140
+ throw new AicLaneError(`session-minting journey ${minter.treeName} returned no tokenId${txid(response)}: ${snippet(response)}`);
141
+ }
142
+ const cookieName = await fetchCookieName(io, session);
143
+ wrapper.invoke.cookies[cookieName] = body.tokenId;
144
+ }
145
+ /**
146
+ * The session cookie's name is per-tenant, so it has to be read rather than
147
+ * assumed (`given.cookieName` is refused on this lane for the same reason).
148
+ */
149
+ export async function fetchCookieName(io, session) {
150
+ const response = await amRequest(io, session, {
151
+ method: "GET",
152
+ path: "/am/json/serverinfo/*",
153
+ anonymous: true,
154
+ });
155
+ const name = response.body.cookieName;
156
+ if (typeof name !== "string" || name.length === 0) {
157
+ throw new AicLaneError(`GET /am/json/serverinfo/* returned no cookieName (HTTP ${response.status})`);
158
+ }
159
+ return name;
160
+ }
161
+ export async function provisionJourney(io, session, wrapper, created) {
162
+ const base = realmJsonPath(wrapper.realm);
163
+ const resources = [];
164
+ for (const script of wrapper.scripts) {
165
+ const description = typeof wrapper.treeBody.description === "string"
166
+ ? wrapper.treeBody.description
167
+ : "rhino-local AIC lane throwaway. Safe to delete.";
168
+ resources.push({
169
+ kind: "script",
170
+ path: `${base}/scripts/${script.id}`,
171
+ label: `script ${script.name}`,
172
+ created: { kind: "script", id: script.id },
173
+ body: {
174
+ _id: script.id,
175
+ name: script.name,
176
+ description,
177
+ script: Buffer.from(script.source, "utf8").toString("base64"),
178
+ default: false,
179
+ language: "JAVASCRIPT",
180
+ context: "AUTHENTICATION_TREE_DECISION_NODE",
181
+ evaluatorVersion: "2.0",
182
+ },
183
+ });
184
+ }
185
+ for (const node of wrapper.nodes) {
186
+ const nodeBody = wrapper.nodeBodies[node.id];
187
+ if (nodeBody === undefined) {
188
+ throw new AicLaneError(`missing node body for ${node.id}`);
189
+ }
190
+ resources.push({
191
+ kind: "node",
192
+ path: `${base}/realm-config/authentication/authenticationtrees/nodes/ScriptedDecisionNode/${node.id}`,
193
+ label: `node ${node.displayName}`,
194
+ created: { kind: "node", id: node.id },
195
+ body: nodeBody,
196
+ });
197
+ }
198
+ resources.push({
199
+ kind: "tree",
200
+ path: `${base}/realm-config/authentication/authenticationtrees/trees/${encodeURIComponent(wrapper.treeName)}`,
201
+ label: `tree ${wrapper.treeName}`,
202
+ created: { kind: "tree", name: wrapper.treeName },
203
+ body: wrapper.treeBody,
204
+ });
205
+ for (const resource of resources) {
206
+ await refuseIfExists(io, session, resource.path, resource.label);
207
+ }
208
+ for (const resource of resources) {
209
+ const submitted = resourceRequestProjection(resource.kind, resource.body);
210
+ const response = await amRequest(io, session, {
211
+ method: "PUT",
212
+ path: resource.path,
213
+ headers: amConfigHeaders(),
214
+ body: JSON.stringify(submitted),
215
+ });
216
+ if (response.status !== 201) {
217
+ expectStatus(response, `PUT ${resource.label}`, 201);
218
+ }
219
+ created.push(resource.created);
220
+ const confirmation = await amRequest(io, session, {
221
+ method: "GET",
222
+ path: resource.path,
223
+ headers: amConfigHeaders(),
224
+ });
225
+ expectStatus(confirmation, `confirm ${resource.label}`, 200);
226
+ confirmResourceSnapshot(resource.kind, submitted, confirmation.body);
227
+ }
228
+ }
229
+ /**
230
+ * Invoke the journey, then answer each declared pass in turn.
231
+ *
232
+ * Every pass re-enters the same node, so the tenant carries state between them
233
+ * on its own terms — which is the point: the local lane has to model that, and
234
+ * this is what it is modelled against. A pass that reaches the result node
235
+ * while replies are still pending is an error, because the chain then asserts
236
+ * against a journey shorter than it described.
237
+ */
238
+ export async function driveJourney(io, session, wrapper, cases, replies, tx, proof, observePass) {
239
+ const passes = [];
240
+ let response = await invokeJourney(io, session, wrapper, tx.next());
241
+ for (const [index, reply] of replies.entries()) {
242
+ const kase = cases[index];
243
+ const label = kase.name;
244
+ const parsed = parseAuthenticateCallbacks(response.body);
245
+ if (parsed.dumpRaw !== undefined) {
246
+ throw new AicLaneError(`${label}: the journey finished before this step ran — the script decided an outcome instead of sending callbacks`);
247
+ }
248
+ const effects = assembleEffects({
249
+ given: kase.given,
250
+ callbacks: parsed.callbacks,
251
+ });
252
+ passes.push(effects);
253
+ await observePass?.(index, effects);
254
+ const body = fillCallbackInputs(response.body, reply, label);
255
+ response = await invokeJourney(io, session, wrapper, tx.next(), body);
256
+ }
257
+ const finalIndex = cases.length - 1;
258
+ const final = recordFromAuthenticate(cases[finalIndex], response, proof);
259
+ passes.push(final);
260
+ await observePass?.(finalIndex, final);
261
+ return passes;
262
+ }
263
+ /**
264
+ * The case the wrapper journey is emitted from: the first pass's seed, with
265
+ * every outcome any pass may reach declared on the subject node. A tenant
266
+ * answers an undeclared outcome with a bare 401, so a chain whose last pass
267
+ * decides something the first never mentions has to say so up front.
268
+ */
269
+ function emitCase(cases) {
270
+ const first = cases[0];
271
+ const outcomes = new Set(first.outcomes ?? []);
272
+ for (const kase of cases) {
273
+ if (kase.expect.outcome !== null) {
274
+ outcomes.add(kase.expect.outcome);
275
+ }
276
+ }
277
+ return outcomes.size === 0 ? first : { ...first, outcomes: [...outcomes] };
278
+ }
279
+ export async function invokeJourney(io, session, wrapper, transactionId, body = "{}") {
280
+ const url = new URL(`${session.baseUrl}${realmJsonPath(wrapper.realm)}/authenticate`);
281
+ url.searchParams.append("authIndexType", "service");
282
+ url.searchParams.append("authIndexValue", wrapper.treeName);
283
+ for (const [key, values] of Object.entries(wrapper.invoke.parameters)) {
284
+ for (const value of values) {
285
+ url.searchParams.append(key, value);
286
+ }
287
+ }
288
+ const headers = [
289
+ ["Accept-API-Version", "protocol=1.0,resource=2.1"],
290
+ [TX_HEADER, transactionId],
291
+ ];
292
+ for (const [key, values] of Object.entries(wrapper.invoke.headers)) {
293
+ if (isReservedInvokeHeader(key)) {
294
+ continue;
295
+ }
296
+ for (const value of values) {
297
+ headers.push([key, value]);
298
+ }
299
+ }
300
+ const cookie = cookieHeader(wrapper.invoke.cookies);
301
+ if (cookie !== undefined) {
302
+ headers.push(["Cookie", cookie]);
303
+ }
304
+ const pathAndQuery = `${url.pathname}${url.search}`;
305
+ const response = await amRequest(io, session, {
306
+ method: "POST",
307
+ path: pathAndQuery,
308
+ headers,
309
+ body,
310
+ anonymous: true,
311
+ });
312
+ if (response.status < 200 || response.status >= 300) {
313
+ throw new AicLaneError(`authenticate HTTP ${response.status}${txid(response)}: ${snippet(response)}`, {
314
+ ...(response.status !== undefined ? { status: response.status } : {}),
315
+ ...(response.transactionId !== undefined
316
+ ? { transactionId: response.transactionId }
317
+ : {}),
318
+ });
319
+ }
320
+ return response;
321
+ }
322
+ function recordFromAuthenticate(kase, response, proof) {
323
+ const parsed = parseAuthenticateCallbacks(response.body);
324
+ if (parsed.dumpRaw !== undefined) {
325
+ const dump = parseSubjectDump(parsed.dumpRaw);
326
+ if (proof !== undefined &&
327
+ (dump.leaseDigest !== proof.leaseDigest ||
328
+ dump.invocationNonce !== proof.invocationNonce ||
329
+ dump.subjectDigest !== proof.subjectDigest)) {
330
+ throw new AicLaneError("AIC runtime lease manifest did not match the armed subject");
331
+ }
332
+ return assembleEffects({
333
+ given: kase.given,
334
+ dump,
335
+ callbacks: parsed.callbacks,
336
+ });
337
+ }
338
+ if (parsed.callbacks.length > 0) {
339
+ return assembleEffects({
340
+ given: kase.given,
341
+ callbacks: parsed.callbacks,
342
+ });
343
+ }
344
+ throw new AicLaneError(`authenticate returned HTTP ${response.status} with no harness dump and no callbacks${txid(response)}: ${snippet(response)}`);
345
+ }
346
+ async function cleanup(io, session, realm, created, seededManaged) {
347
+ const errors = await deleteCreatedResources(io, session, realm, created);
348
+ for (const fixture of seededManaged.slice().reverse()) {
349
+ try {
350
+ await deleteManagedFixture(io, session, fixture);
351
+ }
352
+ catch (error) {
353
+ errors.push(error instanceof Error ? error.message : String(error));
354
+ }
355
+ }
356
+ if (errors.length > 0) {
357
+ // Cleanup is best-effort so one failed delete does not prevent the rest.
358
+ // Surface every leak without hiding the subject failure (this is finally).
359
+ process.emitWarning(`rhino-local AIC cleanup: ${errors.join("; ")}`);
360
+ }
361
+ }
362
+ export async function deleteCreatedResources(io, session, realm, created) {
363
+ const base = realmJsonPath(realm);
364
+ const errors = [];
365
+ for (const item of created.slice().reverse()) {
366
+ try {
367
+ let response;
368
+ if (item.kind === "tree") {
369
+ response = await amRequest(io, session, {
370
+ method: "DELETE",
371
+ path: `${base}/realm-config/authentication/authenticationtrees/trees/${encodeURIComponent(item.name)}`,
372
+ headers: amConfigHeaders(),
373
+ });
374
+ }
375
+ else if (item.kind === "node") {
376
+ response = await amRequest(io, session, {
377
+ method: "DELETE",
378
+ path: `${base}/realm-config/authentication/authenticationtrees/nodes/ScriptedDecisionNode/${item.id}`,
379
+ headers: amConfigHeaders(),
380
+ });
381
+ }
382
+ else {
383
+ response = await amRequest(io, session, {
384
+ method: "DELETE",
385
+ path: `${base}/scripts/${item.id}`,
386
+ headers: amConfigHeaders(),
387
+ });
388
+ }
389
+ expectDeleted(response, `AIC ${item.kind}`);
390
+ }
391
+ catch (error) {
392
+ errors.push(error instanceof Error ? error.message : String(error));
393
+ }
394
+ }
395
+ return errors;
396
+ }
397
+ function expectDeleted(response, label) {
398
+ if (response.status === 404 ||
399
+ (response.status >= 200 && response.status < 300)) {
400
+ return;
401
+ }
402
+ throw new AicLaneError(`delete ${label} returned HTTP ${response.status}: ${snippet(response)}`);
403
+ }
404
+ async function refuseIfExists(io, session, path, label) {
405
+ const response = await amRequest(io, session, {
406
+ method: "GET",
407
+ path,
408
+ headers: amConfigHeaders(),
409
+ });
410
+ if (response.status === 404) {
411
+ return;
412
+ }
413
+ if (response.status >= 200 && response.status < 300) {
414
+ throw new AicLaneError(`refusing to overwrite existing ${label} (GET ${path} returned ${response.status})`);
415
+ }
416
+ throw new AicLaneError(`GET ${label} HTTP ${response.status}: ${snippet(response)}`, { status: response.status });
417
+ }
418
+ function expectStatus(response, label, status) {
419
+ if (response.status === status) {
420
+ return;
421
+ }
422
+ throw new AicLaneError(`${label} returned HTTP ${response.status}, expected ${status}${txid(response)}: ${snippet(response)}`, {
423
+ status: response.status,
424
+ ...(response.transactionId !== undefined
425
+ ? { transactionId: response.transactionId }
426
+ : {}),
427
+ });
428
+ }
429
+ function isReservedInvokeHeader(name) {
430
+ const lower = name.toLowerCase();
431
+ return (lower === "host" ||
432
+ lower === "content-length" ||
433
+ lower === "transfer-encoding" ||
434
+ lower === "connection" ||
435
+ lower === TX_HEADER);
436
+ }
437
+ function cookieHeader(cookies) {
438
+ const parts = [];
439
+ for (const [name, value] of Object.entries(cookies)) {
440
+ parts.push(`${name}=${value}`);
441
+ }
442
+ return parts.length === 0 ? undefined : parts.join("; ");
443
+ }
444
+ function txid(response) {
445
+ return response.transactionId === undefined ? "" : ` tx=${response.transactionId}`;
446
+ }
447
+ function snippet(response) {
448
+ const trimmed = response.bodyText.replace(/\s+/g, " ").trim();
449
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;
450
+ }