@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,543 @@
1
+ /**
2
+ * Owns the reusable AM graph and tenant-backed check/cleanup replay behind the
3
+ * harness's framework-free lane port. The Vitest composition root constructs
4
+ * this tenant-aware side of the seam.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { judge } from "../case/verdict.js";
8
+ import { tenantIdmHandle } from "./idm.js";
9
+ import { conformChain } from "./conform.js";
10
+ import { emitLeasedJourney } from "./emit-journey.js";
11
+ import { emitLeasedSessionJourney } from "./emit-session.js";
12
+ import { instrumentSubject } from "./emit-subject.js";
13
+ import { createLeaseIdentity } from "./lease-identity.js";
14
+ import { acquireLeaseLock, addJournalFixture, addJournalResources, leaseStatePaths, newLeaseJournal, readLeaseJournal, removeLeaseJournal, removeJournalFixture, writeLeaseJournal, } from "./lease-lock.js";
15
+ import { acquireManagedFixtureLock, deleteManagedFixture, fixtureIdentity, managedResource, seedManagedFixtures, } from "./managed.js";
16
+ import { confirmResourceSnapshot } from "./resource-snapshot.js";
17
+ import { deleteCreatedResources, driveJourney, fetchCookieName, invokeJourney, provisionJourney, validateAicRun, } from "./run.js";
18
+ import { AicLaneError, amConfigHeaders, amRequest, connectTenant, defaultAicIo, realmJsonPath, } from "./tenant.js";
19
+ import { beginSingleTrace, beginTrace, clearAicTrace } from "./trace.js";
20
+ import { projectRoot } from "../project.js";
21
+ export class AicFileLease {
22
+ #options;
23
+ #io;
24
+ #realm;
25
+ #project;
26
+ #identity;
27
+ #wrapper;
28
+ #session;
29
+ #created = [];
30
+ #state = "new";
31
+ #queue = Promise.resolve();
32
+ #lock;
33
+ #paths;
34
+ #sessionWrapper;
35
+ #sessionCreated = [];
36
+ #cookieName;
37
+ #seededManaged = [];
38
+ #releaseManagedLock;
39
+ constructor(options) {
40
+ this.#options = options;
41
+ this.#realm = options.realm ?? "alpha";
42
+ this.#project = options.project ?? projectRoot();
43
+ this.#io = options.io ?? defaultAicIo(this.#project);
44
+ }
45
+ get identity() {
46
+ if (this.#identity === undefined) {
47
+ throw new AicLaneError("AIC file lease has not been opened");
48
+ }
49
+ return this.#identity;
50
+ }
51
+ async open() {
52
+ if (this.#state !== "new") {
53
+ throw new AicLaneError(`AIC file lease cannot open from state ${this.#state}`);
54
+ }
55
+ this.#state = "opening";
56
+ this.#identity = createLeaseIdentity({
57
+ id: this.#options.id,
58
+ source: this.#options.source,
59
+ outcomes: this.#options.outcomes,
60
+ });
61
+ this.#wrapper = emitLeasedJourney({
62
+ identity: this.#identity,
63
+ realm: this.#realm,
64
+ suiteName: this.#options.suiteName,
65
+ });
66
+ try {
67
+ this.#session = await connectTenant(this.#io, {
68
+ ...(this.#options.tenant === undefined ? {} : { tenant: this.#options.tenant }),
69
+ ...(this.#options.provider === undefined ? {} : { provider: this.#options.provider }),
70
+ useConfigured: this.#options.io === undefined,
71
+ project: this.#project,
72
+ });
73
+ this.#paths = leaseStatePaths(this.#session.baseUrl, this.#identity, this.#options.stateDir);
74
+ this.#lock = await acquireLeaseLock(this.#paths, this.#identity, {
75
+ ...(this.#options.lockTimeoutMs === undefined
76
+ ? {}
77
+ : { timeoutMs: this.#options.lockTimeoutMs }),
78
+ });
79
+ await this.#handleOlderJournal();
80
+ await writeLeaseJournal(this.#paths.journalPath, newLeaseJournal(this.#paths, this.#identity, this.#realm, staticResources(this.#wrapper)));
81
+ await provisionJourney(this.#io, this.#session, this.#wrapper, this.#created);
82
+ this.#state = "open";
83
+ }
84
+ catch (error) {
85
+ await this.#cleanupAfterFailedOpen();
86
+ this.#state = "closed";
87
+ throw error;
88
+ }
89
+ }
90
+ run(request) {
91
+ const task = async () => {
92
+ this.#requireOpen();
93
+ this.#validate(request);
94
+ const report = await conformChain({
95
+ ...request,
96
+ aic: (args) => this.#runEffects(args.cases, args.source, args.replies, args.managedFixtures ?? [], request.hooks),
97
+ });
98
+ assertConformance(report);
99
+ return report;
100
+ };
101
+ const result = this.#queue.then(task, task);
102
+ this.#queue = result.then(() => undefined, () => undefined);
103
+ return result;
104
+ }
105
+ async endTest() {
106
+ await this.#queue;
107
+ const errors = await this.#cleanupManaged();
108
+ if (errors.length > 0) {
109
+ throw new AicLaneError(`AIC managed fixture cleanup failed: ${errors.join("; ")}`);
110
+ }
111
+ }
112
+ async close() {
113
+ if (this.#state === "closed") {
114
+ return;
115
+ }
116
+ await this.#queue;
117
+ const errors = [];
118
+ try {
119
+ const session = this.#session;
120
+ errors.push(...(await this.#cleanupManaged()));
121
+ if (session !== undefined) {
122
+ errors.push(...(await deleteCreatedResources(this.#io, session, this.#realm, this.#sessionCreated)));
123
+ errors.push(...(await deleteCreatedResources(this.#io, session, this.#realm, this.#created)));
124
+ }
125
+ if (errors.length === 0) {
126
+ this.#created = [];
127
+ this.#sessionCreated = [];
128
+ if (this.#paths !== undefined) {
129
+ await removeLeaseJournal(this.#paths.journalPath);
130
+ }
131
+ }
132
+ }
133
+ finally {
134
+ this.#state = "closed";
135
+ await this.#releaseManagedLock?.();
136
+ this.#releaseManagedLock = undefined;
137
+ await this.#lock?.release();
138
+ }
139
+ if (errors.length > 0) {
140
+ throw new AicLaneError(`AIC file lease cleanup failed: ${errors.join("; ")}`);
141
+ }
142
+ }
143
+ async #runEffects(cases, source, replies, managedFixtures, hooks) {
144
+ const session = this.#session;
145
+ // The bearer captured at open() is NOT refreshed here, deliberately.
146
+ // Measured 2026-09-14: the agent rotates on its own ~898s schedule and
147
+ // `whoami --token` can hand back a token already near the end of that
148
+ // cycle, so neither the file's age nor the token's tells you whether it is
149
+ // still good — a prophylactic refresh would cost a CLI call per case and
150
+ // still not guarantee one. `amRequest` instead refreshes and retries once
151
+ // on a non-anonymous 401, which closes the gap completely for the price of
152
+ // one wasted request on the rare occasion the bearer dies mid-file.
153
+ // `/authenticate` is anonymous and carries no bearer, so a journey
154
+ // invocation is unaffected either way.
155
+ if (managedFixtures.length > 0) {
156
+ this.#releaseManagedLock = await acquireManagedFixtureLock(session, cases[cases.length - 1]?.name ?? this.identity.id);
157
+ const identities = managedFixtures.map((fixture) => fixtureIdentity(fixture));
158
+ for (const [index, identity] of identities.entries()) {
159
+ const fixture = managedFixtures[index];
160
+ await addJournalFixture(this.#journalPath(), {
161
+ type: fixture.type,
162
+ id: identity.id,
163
+ });
164
+ }
165
+ }
166
+ let effects;
167
+ let failure;
168
+ let fixturesReady = false;
169
+ try {
170
+ await seedManagedFixtures(this.#io, session, managedFixtures, this.#seededManaged);
171
+ fixturesReady = true;
172
+ let sessionCookie;
173
+ const existingSession = cases[0]?.given.existingSession;
174
+ if (existingSession !== undefined) {
175
+ sessionCookie = await this.#mintSession(existingSession);
176
+ clearAicTrace();
177
+ }
178
+ effects = await this.#armAndDrive(cases, source, replies, hooks, sessionCookie);
179
+ }
180
+ catch (error) {
181
+ failure = error;
182
+ }
183
+ if (fixturesReady && hooks.cleanup !== undefined) {
184
+ try {
185
+ await hooks.cleanup(tenantIdmHandle(this.#io, session));
186
+ }
187
+ catch (error) {
188
+ const cleanupFailure = laneHookError(cases[cases.length - 1]?.name ?? this.identity.id, "cleanup()", error);
189
+ failure = combineHookFailure(failure, cleanupFailure);
190
+ }
191
+ }
192
+ const cleanupErrors = await this.#cleanupManaged();
193
+ for (const fixture of managedFixtures) {
194
+ const identity = fixtureIdentity(fixture);
195
+ if (!this.#seededManaged.some((seeded) => seeded.type === fixture.type && seeded.id === identity.id)) {
196
+ try {
197
+ await removeJournalFixture(this.#journalPath(), {
198
+ type: fixture.type,
199
+ id: identity.id,
200
+ });
201
+ }
202
+ catch (error) {
203
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
204
+ }
205
+ }
206
+ }
207
+ if (failure !== undefined) {
208
+ if (cleanupErrors.length > 0) {
209
+ throw new AggregateError([failure, ...cleanupErrors.map((message) => new AicLaneError(message))], "AIC run and managed fixture cleanup both failed");
210
+ }
211
+ throw failure;
212
+ }
213
+ if (cleanupErrors.length > 0) {
214
+ throw new AicLaneError(`AIC managed fixture cleanup failed: ${cleanupErrors.join("; ")}`);
215
+ }
216
+ return effects;
217
+ }
218
+ async #armAndDrive(cases, source, replies, hooks, sessionCookie) {
219
+ const session = this.#session;
220
+ const wrapper = this.#wrapper;
221
+ const nonce = randomUUID();
222
+ const instrumented = instrumentSubject(source, this.identity.idHash, cases[0]?.given, {
223
+ snapshotKey: this.identity.snapshotKey,
224
+ invocationNonce: nonce,
225
+ header: this.identity.marker,
226
+ });
227
+ const subjectDigest = instrumented.subjectDigest;
228
+ if (subjectDigest === undefined) {
229
+ throw new AicLaneError("leased subject instrumentation produced no digest");
230
+ }
231
+ // No size guard: there is no script-specific limit, and the 5 MiB
232
+ // request-body ceiling is orders of magnitude above any generated subject
233
+ // (measured 2026-09-14, `docs/api/04-scripts.md`). Over it AM answers a
234
+ // clean 400 and stores nothing, so the confirming read below is the only
235
+ // check this needs — it never truncates.
236
+ const body = {
237
+ _id: this.identity.ids.subjectScript,
238
+ name: `${this.identity.treeName}-subject`,
239
+ description: this.identity.marker,
240
+ script: Buffer.from(instrumented.source, "utf8").toString("base64"),
241
+ default: false,
242
+ language: "JAVASCRIPT",
243
+ context: "AUTHENTICATION_TREE_DECISION_NODE",
244
+ evaluatorVersion: "2.0",
245
+ };
246
+ const path = `${realmJsonPath(this.#realm)}/scripts/${this.identity.ids.subjectScript}`;
247
+ const update = await amRequest(this.#io, session, {
248
+ method: "PUT",
249
+ path,
250
+ headers: amConfigHeaders(),
251
+ body: JSON.stringify(body),
252
+ });
253
+ if (update.status !== 200) {
254
+ throw new AicLaneError(`subject update returned HTTP ${update.status}, expected 200; the owned slot may have disappeared`);
255
+ }
256
+ const confirmation = await amRequest(this.#io, session, {
257
+ method: "GET",
258
+ path,
259
+ headers: amConfigHeaders(),
260
+ });
261
+ if (confirmation.status !== 200) {
262
+ throw new AicLaneError(`subject confirming read returned HTTP ${confirmation.status}, expected 200`);
263
+ }
264
+ confirmResourceSnapshot("script", body, confirmation.body);
265
+ const first = cases[0];
266
+ wrapper.invoke = {
267
+ headers: copyArrayMap(first.given.requestHeaders),
268
+ parameters: copyArrayMap(first.given.requestParameters),
269
+ cookies: { ...(first.given.requestCookies ?? {}) },
270
+ };
271
+ if (sessionCookie !== undefined) {
272
+ wrapper.invoke.cookies[sessionCookie.name] = sessionCookie.value;
273
+ }
274
+ const observePass = async (index, effects) => {
275
+ const final = index === cases.length - 1;
276
+ if (!final && !judge(cases[index], effects).pass) {
277
+ return;
278
+ }
279
+ const stepCheck = hooks.stepChecks[index];
280
+ const checks = final
281
+ ? hooks.finalChecks
282
+ : stepCheck === undefined
283
+ ? []
284
+ : [stepCheck];
285
+ for (const [checkIndex, check] of checks.entries()) {
286
+ try {
287
+ await check(tenantIdmHandle(this.#io, session), effects);
288
+ }
289
+ catch (error) {
290
+ const label = final
291
+ ? `final check() ${checkIndex + 1}`
292
+ : `step ${index + 1} check()`;
293
+ throw laneHookError(cases[index]?.name ?? this.identity.id, label, error);
294
+ }
295
+ }
296
+ };
297
+ return driveJourney(this.#io, session, wrapper, cases, replies, beginTrace(session.tenantName), {
298
+ leaseDigest: this.identity.structuralDigest,
299
+ invocationNonce: nonce,
300
+ subjectDigest,
301
+ }, observePass);
302
+ }
303
+ async #mintSession(existingSession) {
304
+ const session = this.#session;
305
+ const emitted = emitLeasedSessionJourney(existingSession, {
306
+ identity: this.identity,
307
+ realm: this.#realm,
308
+ });
309
+ if (this.#sessionWrapper === undefined) {
310
+ await addJournalResources(this.#journalPath(), staticResources(emitted));
311
+ await provisionJourney(this.#io, session, emitted, this.#sessionCreated);
312
+ this.#sessionWrapper = emitted;
313
+ }
314
+ else {
315
+ // A replaced minter script IS what the next invocation executes
316
+ // (measured 2026-09-14): one file minted a session carrying "A", this
317
+ // branch replaced the same script id to carry "B", and the subject
318
+ // journey 1.1s later saw "B". AM does not serve a cached compile here,
319
+ // so the slot is safe to reuse.
320
+ const script = emitted.scripts[0];
321
+ const body = scriptBody(script, this.identity.marker);
322
+ const path = `${realmJsonPath(this.#realm)}/scripts/${script.id}`;
323
+ const update = await amRequest(this.#io, session, {
324
+ method: "PUT",
325
+ path,
326
+ headers: amConfigHeaders(),
327
+ body: JSON.stringify(body),
328
+ });
329
+ if (update.status !== 200) {
330
+ throw new AicLaneError(`session-minter update returned HTTP ${update.status}, expected 200`);
331
+ }
332
+ const confirmation = await amRequest(this.#io, session, {
333
+ method: "GET",
334
+ path,
335
+ headers: amConfigHeaders(),
336
+ });
337
+ if (confirmation.status !== 200) {
338
+ throw new AicLaneError(`session-minter confirming read returned HTTP ${confirmation.status}, expected 200`);
339
+ }
340
+ confirmResourceSnapshot("script", body, confirmation.body);
341
+ this.#sessionWrapper.scripts[0] = script;
342
+ }
343
+ const response = await invokeJourney(this.#io, session, this.#sessionWrapper, beginSingleTrace(session.tenantName));
344
+ const tokenId = response.body.tokenId;
345
+ if (typeof tokenId !== "string" || tokenId.length === 0) {
346
+ throw new AicLaneError("session-minter returned no tokenId");
347
+ }
348
+ this.#cookieName ??= await fetchCookieName(this.#io, session);
349
+ return { name: this.#cookieName, value: tokenId };
350
+ }
351
+ async #cleanupManaged() {
352
+ const errors = [];
353
+ for (const fixture of this.#seededManaged.slice().reverse()) {
354
+ try {
355
+ await deleteManagedFixture(this.#io, this.#session, fixture);
356
+ await removeJournalFixture(this.#journalPath(), fixture);
357
+ this.#seededManaged = this.#seededManaged.filter((item) => item.type !== fixture.type || item.id !== fixture.id);
358
+ }
359
+ catch (error) {
360
+ errors.push(error instanceof Error ? error.message : String(error));
361
+ }
362
+ }
363
+ if (this.#releaseManagedLock !== undefined && this.#seededManaged.length === 0) {
364
+ try {
365
+ await this.#releaseManagedLock();
366
+ this.#releaseManagedLock = undefined;
367
+ }
368
+ catch (error) {
369
+ errors.push(error instanceof Error ? error.message : String(error));
370
+ }
371
+ }
372
+ return errors;
373
+ }
374
+ #validate(request) {
375
+ const validation = validateAicRun(request.cases, request.replies, request.managedFixtures);
376
+ if (request.hooks.stepChecks.length !== request.replies.length) {
377
+ throw new AicLaneError(`AIC file lease received ${request.hooks.stepChecks.length} step hook slots for ${request.replies.length} steps`);
378
+ }
379
+ if (request.source !== this.#options.source) {
380
+ throw new AicLaneError("AIC file lease source differs from the suite source used at open");
381
+ }
382
+ for (const kase of request.cases) {
383
+ if (kase.script !== this.#options.source) {
384
+ throw new AicLaneError(`${kase.name}: case source differs from the leased suite source`);
385
+ }
386
+ const realm = kase.given.realm ?? "alpha";
387
+ if (realm !== this.#realm) {
388
+ throw new AicLaneError(`${kase.name}: realm ${JSON.stringify(realm)} differs from leased realm ${JSON.stringify(this.#realm)}`);
389
+ }
390
+ if (kase.expect.outcome !== null &&
391
+ !this.identity.outcomes.includes(kase.expect.outcome)) {
392
+ throw new AicLaneError(`${kase.name}: outcome ${JSON.stringify(kase.expect.outcome)} is outside the leased vocabulary`);
393
+ }
394
+ }
395
+ if (validation.unsupported.length > 0 &&
396
+ this.#options.unsupported !== "skip") {
397
+ throw new AicLaneError(`AIC lane unsupported: ${validation.unsupported.join("; ")}`);
398
+ }
399
+ }
400
+ #requireOpen() {
401
+ if (this.#state !== "open") {
402
+ throw new AicLaneError(`AIC file lease is not open (state ${this.#state})`);
403
+ }
404
+ }
405
+ async #cleanupAfterFailedOpen() {
406
+ if (this.#session !== undefined && this.#created.length > 0) {
407
+ const errors = await deleteCreatedResources(this.#io, this.#session, this.#realm, this.#created);
408
+ if (errors.length === 0) {
409
+ this.#created = [];
410
+ if (this.#paths !== undefined) {
411
+ await removeLeaseJournal(this.#paths.journalPath);
412
+ }
413
+ }
414
+ }
415
+ if (this.#created.length === 0 && this.#paths !== undefined) {
416
+ await removeLeaseJournal(this.#paths.journalPath);
417
+ }
418
+ await this.#lock?.release();
419
+ }
420
+ async #handleOlderJournal() {
421
+ const paths = this.#paths;
422
+ const journal = await readLeaseJournal(paths.journalPath);
423
+ if (journal === undefined) {
424
+ return;
425
+ }
426
+ const residue = [];
427
+ for (const resource of journal.resources) {
428
+ const path = resourcePath(journal.realm, resource);
429
+ const response = await amRequest(this.#io, this.#session, {
430
+ method: "GET",
431
+ path,
432
+ headers: amConfigHeaders(),
433
+ });
434
+ if (response.status === 404) {
435
+ continue;
436
+ }
437
+ if (response.status >= 200 && response.status < 300) {
438
+ residue.push(resourceLabel(resource));
439
+ continue;
440
+ }
441
+ throw new AicLaneError(`could not probe older AIC lease journal resource ${resourceLabel(resource)} (HTTP ${response.status})`);
442
+ }
443
+ for (const fixture of journal.managedFixtures) {
444
+ const response = await amRequest(this.#io, this.#session, {
445
+ method: "GET",
446
+ path: managedResource(fixture.type, fixture.id),
447
+ });
448
+ if (response.status === 404) {
449
+ continue;
450
+ }
451
+ if (response.status >= 200 && response.status < 300) {
452
+ residue.push(`${fixture.type}/${fixture.id}`);
453
+ continue;
454
+ }
455
+ throw new AicLaneError(`could not probe older managed fixture ${fixture.type}/${fixture.id} (HTTP ${response.status})`);
456
+ }
457
+ if (residue.length > 0) {
458
+ throw new AicLaneError(`AIC file lease ${JSON.stringify(journal.aicId)} has owned residue: ${residue.join(", ")}`);
459
+ }
460
+ await removeLeaseJournal(paths.journalPath);
461
+ }
462
+ #journalPath() {
463
+ if (this.#paths === undefined) {
464
+ throw new AicLaneError("AIC file lease journal path is unavailable");
465
+ }
466
+ return this.#paths.journalPath;
467
+ }
468
+ }
469
+ class AicLaneHookError extends AicLaneError {
470
+ }
471
+ function laneHookError(name, hook, error) {
472
+ // The kind goes in the message and the original goes on `cause`: the message
473
+ // must not carry arbitrary assertion text, but a remote check reporting only
474
+ // "threw Error" tells you nothing about what it actually found.
475
+ return new AicLaneHookError(`local and AIC lanes disagreed for ${JSON.stringify(name)}: local ${hook} passed; AIC ${hook} threw ${safeErrorKind(error)}`, { cause: error });
476
+ }
477
+ function combineHookFailure(failure, cleanupFailure) {
478
+ if (failure === undefined) {
479
+ return cleanupFailure;
480
+ }
481
+ const message = failure instanceof AicLaneHookError
482
+ ? `${failure.message}; ${cleanupFailure.message}`
483
+ : "AIC run and suite cleanup both failed";
484
+ return new AggregateError([failure, cleanupFailure], message);
485
+ }
486
+ function safeErrorKind(error) {
487
+ if (!(error instanceof Error)) {
488
+ return "a non-Error value";
489
+ }
490
+ return /^(?:Error|[A-Za-z][A-Za-z0-9]*Error)$/.test(error.name)
491
+ ? error.name
492
+ : "an Error";
493
+ }
494
+ function staticResources(wrapper) {
495
+ return [
496
+ ...wrapper.scripts.map((script) => ({ kind: "script", id: script.id })),
497
+ ...wrapper.nodes.map((node) => ({ kind: "node", id: node.id })),
498
+ { kind: "tree", name: wrapper.treeName },
499
+ ];
500
+ }
501
+ function resourcePath(realm, resource) {
502
+ const base = realmJsonPath(realm);
503
+ if (resource.kind === "script") {
504
+ return `${base}/scripts/${resource.id}`;
505
+ }
506
+ if (resource.kind === "node") {
507
+ return `${base}/realm-config/authentication/authenticationtrees/nodes/ScriptedDecisionNode/${resource.id}`;
508
+ }
509
+ return `${base}/realm-config/authentication/authenticationtrees/trees/${encodeURIComponent(resource.name)}`;
510
+ }
511
+ function resourceLabel(resource) {
512
+ return resource.kind === "tree"
513
+ ? `tree ${resource.name}`
514
+ : `${resource.kind} ${resource.id}`;
515
+ }
516
+ function scriptBody(script, description) {
517
+ return {
518
+ _id: script.id,
519
+ name: script.name,
520
+ description,
521
+ script: Buffer.from(script.source, "utf8").toString("base64"),
522
+ default: false,
523
+ language: "JAVASCRIPT",
524
+ context: "AUTHENTICATION_TREE_DECISION_NODE",
525
+ evaluatorVersion: "2.0",
526
+ };
527
+ }
528
+ function assertConformance(report) {
529
+ const error = report.passes.find((pass) => pass.aic.error !== undefined)?.aic.error;
530
+ if (error !== undefined) {
531
+ throw new AicLaneError(error);
532
+ }
533
+ const failed = report.passes.find((pass) => pass.aic.verdict?.pass === false);
534
+ if (failed !== undefined) {
535
+ throw new AicLaneError(`AIC verdict failed for ${failed.name}: ${failed.aic.verdict?.summary}`);
536
+ }
537
+ if (report.disagreements.length > 0) {
538
+ throw new AicLaneError(`local and AIC lanes disagreed: ${report.disagreements.map((item) => item.message).join("; ")}`);
539
+ }
540
+ }
541
+ function copyArrayMap(value) {
542
+ return Object.fromEntries(Object.entries(value ?? {}).map(([key, items]) => [key, items.slice()]));
543
+ }
@@ -0,0 +1,21 @@
1
+ export interface HttpResponse {
2
+ status: number;
3
+ headers: Array<[string, string]>;
4
+ body: string;
5
+ }
6
+ export interface HttpRequest {
7
+ url: string;
8
+ method: string;
9
+ /** Repeated names are sent as repeated header lines, not comma-joined. */
10
+ headerLines: Array<[string, string]>;
11
+ body?: string;
12
+ timeoutMs?: number;
13
+ }
14
+ /**
15
+ * HTTP/1.1 request that preserves duplicate header names. Node's
16
+ * `setHeader(name, [a,b])` comma-joins; AM `requestHeaders` stores one
17
+ * element per occurrence (`docs/api/12-script-bindings-matrix.md`), so the
18
+ * authenticate call has to write each line itself.
19
+ */
20
+ export declare function sendHttp(req: HttpRequest): Promise<HttpResponse>;
21
+ export declare function headerValues(headers: Array<[string, string]>, name: string): string[];
@@ -0,0 +1,127 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ /**
4
+ * HTTP/1.1 request that preserves duplicate header names. Node's
5
+ * `setHeader(name, [a,b])` comma-joins; AM `requestHeaders` stores one
6
+ * element per occurrence (`docs/api/12-script-bindings-matrix.md`), so the
7
+ * authenticate call has to write each line itself.
8
+ */
9
+ export function sendHttp(req) {
10
+ const url = new URL(req.url);
11
+ const isHttps = url.protocol === "https:";
12
+ const requestFn = isHttps ? httpsRequest : httpRequest;
13
+ const body = req.body ?? "";
14
+ const headerLines = withHostAndLength(url, req.headerLines, Buffer.byteLength(body));
15
+ const options = {
16
+ protocol: url.protocol,
17
+ hostname: url.hostname,
18
+ port: url.port === "" ? undefined : url.port,
19
+ path: `${url.pathname}${url.search}`,
20
+ method: req.method,
21
+ timeout: req.timeoutMs ?? 30_000,
22
+ // Leave headers empty; we write them on the wire below.
23
+ headers: {},
24
+ };
25
+ return new Promise((resolve, reject) => {
26
+ const request = requestFn(options, (response) => {
27
+ collect(response).then(resolve, reject);
28
+ });
29
+ request.on("timeout", () => {
30
+ request.destroy(new Error(`rhino-local: HTTP timeout after ${options.timeout}ms`));
31
+ });
32
+ request.on("error", reject);
33
+ writeHeaderLines(request, headerLines);
34
+ if (body.length > 0) {
35
+ request.write(body);
36
+ }
37
+ request.end();
38
+ });
39
+ }
40
+ function withHostAndLength(url, headerLines, length) {
41
+ const out = [];
42
+ let hasHost = false;
43
+ let hasLength = false;
44
+ let hasConnection = false;
45
+ for (const [name, value] of headerLines) {
46
+ const lower = name.toLowerCase();
47
+ if (lower === "host") {
48
+ hasHost = true;
49
+ }
50
+ if (lower === "content-length") {
51
+ hasLength = true;
52
+ }
53
+ if (lower === "connection") {
54
+ hasConnection = true;
55
+ }
56
+ out.push([name, value]);
57
+ }
58
+ if (!hasHost) {
59
+ const host = url.port === "" ? url.hostname : `${url.hostname}:${url.port}`;
60
+ out.unshift(["Host", host]);
61
+ }
62
+ if (!hasLength) {
63
+ out.push(["Content-Length", String(length)]);
64
+ }
65
+ if (!hasConnection) {
66
+ out.push(["Connection", "close"]);
67
+ }
68
+ return out;
69
+ }
70
+ function writeHeaderLines(request, headerLines) {
71
+ const grouped = new Map();
72
+ const order = [];
73
+ for (const [name, value] of headerLines) {
74
+ const existing = grouped.get(name);
75
+ if (existing === undefined) {
76
+ grouped.set(name, [value]);
77
+ order.push(name);
78
+ }
79
+ else {
80
+ existing.push(value);
81
+ }
82
+ }
83
+ for (const name of order) {
84
+ const values = grouped.get(name);
85
+ if (values === undefined) {
86
+ continue;
87
+ }
88
+ const single = values[0];
89
+ request.setHeader(name, values.length === 1 && single !== undefined ? single : values);
90
+ }
91
+ }
92
+ function collect(response) {
93
+ return new Promise((resolve, reject) => {
94
+ const chunks = [];
95
+ response.on("data", (chunk) => {
96
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
97
+ });
98
+ response.on("error", reject);
99
+ response.on("end", () => {
100
+ const headers = [];
101
+ const raw = response.rawHeaders;
102
+ for (let index = 0; index < raw.length; index += 2) {
103
+ const name = raw[index];
104
+ const value = raw[index + 1];
105
+ if (name === undefined || value === undefined) {
106
+ continue;
107
+ }
108
+ headers.push([name, value]);
109
+ }
110
+ resolve({
111
+ status: response.statusCode ?? 0,
112
+ headers,
113
+ body: Buffer.concat(chunks).toString("utf8"),
114
+ });
115
+ });
116
+ });
117
+ }
118
+ export function headerValues(headers, name) {
119
+ const lower = name.toLowerCase();
120
+ const values = [];
121
+ for (const [key, value] of headers) {
122
+ if (key.toLowerCase() === lower) {
123
+ values.push(value);
124
+ }
125
+ }
126
+ return values;
127
+ }