@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.
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +274 -0
- package/dist/bin/fetch-jar.d.ts +2 -0
- package/dist/bin/fetch-jar.js +13 -0
- package/dist/bin/pull-profile.d.ts +2 -0
- package/dist/bin/pull-profile.js +40 -0
- package/dist/bin/show-log.d.ts +2 -0
- package/dist/bin/show-log.js +31 -0
- package/dist/classes/.complete +12 -0
- package/dist/classes/AmClassShutter.class +0 -0
- package/dist/classes/AmContextFactory$ObservedContext.class +0 -0
- package/dist/classes/AmContextFactory.class +0 -0
- package/dist/classes/AmScopes.class +0 -0
- package/dist/classes/HostOps.class +0 -0
- package/dist/classes/JsValues.class +0 -0
- package/dist/classes/Json$Parser.class +0 -0
- package/dist/classes/Json.class +0 -0
- package/dist/classes/LanguageVersions.class +0 -0
- package/dist/classes/Runner.class +0 -0
- package/dist/classes/ScriptContextScope.class +0 -0
- package/dist/src/aic/callbacks.d.ts +35 -0
- package/dist/src/aic/callbacks.js +130 -0
- package/dist/src/aic/conform.d.ts +90 -0
- package/dist/src/aic/conform.js +193 -0
- package/dist/src/aic/constants.d.ts +13 -0
- package/dist/src/aic/constants.js +13 -0
- package/dist/src/aic/diff.d.ts +25 -0
- package/dist/src/aic/diff.js +201 -0
- package/dist/src/aic/emit-journey.d.ts +64 -0
- package/dist/src/aic/emit-journey.js +254 -0
- package/dist/src/aic/emit-result.d.ts +10 -0
- package/dist/src/aic/emit-result.js +39 -0
- package/dist/src/aic/emit-session.d.ts +26 -0
- package/dist/src/aic/emit-session.js +132 -0
- package/dist/src/aic/emit-subject.d.ts +40 -0
- package/dist/src/aic/emit-subject.js +210 -0
- package/dist/src/aic/file-lease.d.ts +35 -0
- package/dist/src/aic/file-lease.js +543 -0
- package/dist/src/aic/http.d.ts +21 -0
- package/dist/src/aic/http.js +127 -0
- package/dist/src/aic/idm.d.ts +10 -0
- package/dist/src/aic/idm.js +112 -0
- package/dist/src/aic/index.d.ts +35 -0
- package/dist/src/aic/index.js +21 -0
- package/dist/src/aic/js.d.ts +8 -0
- package/dist/src/aic/js.js +11 -0
- package/dist/src/aic/lease-identity.d.ts +38 -0
- package/dist/src/aic/lease-identity.js +72 -0
- package/dist/src/aic/lease-lock.d.ts +45 -0
- package/dist/src/aic/lease-lock.js +173 -0
- package/dist/src/aic/managed.d.ts +28 -0
- package/dist/src/aic/managed.js +199 -0
- package/dist/src/aic/provider.d.ts +156 -0
- package/dist/src/aic/provider.js +416 -0
- package/dist/src/aic/record.d.ts +28 -0
- package/dist/src/aic/record.js +183 -0
- package/dist/src/aic/resource-snapshot.d.ts +12 -0
- package/dist/src/aic/resource-snapshot.js +116 -0
- package/dist/src/aic/run.d.ts +102 -0
- package/dist/src/aic/run.js +450 -0
- package/dist/src/aic/tenant.d.ts +107 -0
- package/dist/src/aic/tenant.js +307 -0
- package/dist/src/aic/trace.d.ts +25 -0
- package/dist/src/aic/trace.js +56 -0
- package/dist/src/aic/txid.d.ts +11 -0
- package/dist/src/aic/txid.js +25 -0
- package/dist/src/aic/unsupported.d.ts +15 -0
- package/dist/src/aic/unsupported.js +60 -0
- package/dist/src/bindings/allowlist.d.ts +42 -0
- package/dist/src/bindings/allowlist.js +62 -0
- package/dist/src/bindings/harvest.d.ts +7 -0
- package/dist/src/bindings/harvest.js +137 -0
- package/dist/src/bindings/index.d.ts +4 -0
- package/dist/src/bindings/index.js +3 -0
- package/dist/src/bindings/preamble.d.ts +23 -0
- package/dist/src/bindings/preamble.js +36 -0
- package/dist/src/bindings/run.d.ts +33 -0
- package/dist/src/bindings/run.js +55 -0
- package/dist/src/case/equal.d.ts +2 -0
- package/dist/src/case/equal.js +40 -0
- package/dist/src/case/index.d.ts +5 -0
- package/dist/src/case/index.js +4 -0
- package/dist/src/case/portable.d.ts +10 -0
- package/dist/src/case/portable.js +28 -0
- package/dist/src/case/session.d.ts +34 -0
- package/dist/src/case/session.js +66 -0
- package/dist/src/case/state.d.ts +4 -0
- package/dist/src/case/state.js +47 -0
- package/dist/src/case/types.d.ts +266 -0
- package/dist/src/case/types.js +106 -0
- package/dist/src/case/util.d.ts +7 -0
- package/dist/src/case/util.js +115 -0
- package/dist/src/case/validate.d.ts +8 -0
- package/dist/src/case/validate.js +463 -0
- package/dist/src/case/verdict.d.ts +6 -0
- package/dist/src/case/verdict.js +810 -0
- package/dist/src/diagnostics.d.ts +7 -0
- package/dist/src/diagnostics.js +5 -0
- package/dist/src/emit-dts.d.ts +2 -0
- package/dist/src/emit-dts.js +82 -0
- package/dist/src/emit-js.d.ts +3 -0
- package/dist/src/emit-js.js +149 -0
- package/dist/src/harness/failures.d.ts +26 -0
- package/dist/src/harness/failures.js +113 -0
- package/dist/src/harness/idm.d.ts +12 -0
- package/dist/src/harness/idm.js +44 -0
- package/dist/src/harness/index.d.ts +11 -0
- package/dist/src/harness/index.js +6 -0
- package/dist/src/harness/lease.d.ts +136 -0
- package/dist/src/harness/lease.js +286 -0
- package/dist/src/harness/residue.d.ts +23 -0
- package/dist/src/harness/residue.js +73 -0
- package/dist/src/harness/show-log.d.ts +43 -0
- package/dist/src/harness/show-log.js +253 -0
- package/dist/src/harness/spec.d.ts +67 -0
- package/dist/src/harness/spec.js +189 -0
- package/dist/src/harness/step.d.ts +80 -0
- package/dist/src/harness/step.js +97 -0
- package/dist/src/harness/types.d.ts +100 -0
- package/dist/src/harness/types.js +1 -0
- package/dist/src/harness/vitest.d.ts +52 -0
- package/dist/src/harness/vitest.js +155 -0
- package/dist/src/idents.d.ts +4 -0
- package/dist/src/idents.js +42 -0
- package/dist/src/jvm.d.ts +124 -0
- package/dist/src/jvm.js +432 -0
- package/dist/src/load.d.ts +2 -0
- package/dist/src/load.js +104 -0
- package/dist/src/paths.d.ts +36 -0
- package/dist/src/paths.js +50 -0
- package/dist/src/profile/index.d.ts +5 -0
- package/dist/src/profile/index.js +3 -0
- package/dist/src/profile/normalise.d.ts +16 -0
- package/dist/src/profile/normalise.js +106 -0
- package/dist/src/profile/pull.d.ts +22 -0
- package/dist/src/profile/pull.js +36 -0
- package/dist/src/profile/seed.d.ts +11 -0
- package/dist/src/profile/seed.js +28 -0
- package/dist/src/profile/store.d.ts +13 -0
- package/dist/src/profile/store.js +60 -0
- package/dist/src/profile/types.d.ts +45 -0
- package/dist/src/profile/types.js +1 -0
- package/dist/src/profile/validate.d.ts +38 -0
- package/dist/src/profile/validate.js +129 -0
- package/dist/src/project.d.ts +37 -0
- package/dist/src/project.js +93 -0
- package/dist/src/protocol.d.ts +62 -0
- package/dist/src/protocol.js +68 -0
- package/dist/src/provider-module.d.ts +11 -0
- package/dist/src/provider-module.js +46 -0
- package/dist/src/runner.d.ts +39 -0
- package/dist/src/runner.js +337 -0
- package/dist/src/schema.d.ts +48 -0
- package/dist/src/schema.js +93 -0
- package/generated/scripted-decision-mocks.cjs +722 -0
- package/generated/scripted-decision-mocks.d.ts +626 -0
- package/generated/scripted-decision-next.json +3640 -0
- package/java/AmClassShutter.java +98 -0
- package/java/AmContextFactory.java +69 -0
- package/java/AmScopes.java +49 -0
- package/java/HostOps.java +180 -0
- package/java/JsValues.java +39 -0
- package/java/Json.java +441 -0
- package/java/LanguageVersions.java +55 -0
- package/java/Runner.java +289 -0
- package/java/ScriptContextScope.java +147 -0
- package/package.json +88 -0
- package/src/bindings/rhino/runtime.cjs +3021 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { createPrivateKey, randomUUID, sign } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { headerValues, sendHttp } from "./http.js";
|
|
4
|
+
export class TenantProviderError extends Error {
|
|
5
|
+
constructor(message, options = {}) {
|
|
6
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : {});
|
|
7
|
+
this.name = "TenantProviderError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The caller supplies the bearer. The harness calls `getToken` when a
|
|
12
|
+
* connection opens and again after a 401, and does no caching of its own.
|
|
13
|
+
*/
|
|
14
|
+
export function tokenCallbackProvider(options) {
|
|
15
|
+
const description = describeUrl(options.baseUrl, options.name);
|
|
16
|
+
const logs = options.logKeys === undefined
|
|
17
|
+
? undefined
|
|
18
|
+
: logKeysReader(description.baseUrl, options.logKeys, options.http ?? sendHttp);
|
|
19
|
+
return {
|
|
20
|
+
describe: () => Promise.resolve(description),
|
|
21
|
+
async getToken(request) {
|
|
22
|
+
const token = (await options.getToken(request)).trim();
|
|
23
|
+
if (token.length === 0) {
|
|
24
|
+
throw new TenantProviderError("the token callback returned an empty token");
|
|
25
|
+
}
|
|
26
|
+
return token;
|
|
27
|
+
},
|
|
28
|
+
...(logs === undefined ? {} : { logs }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// --- service account --------------------------------------------------------
|
|
32
|
+
/** `docs/api/00-auth.md`; a returned scope may be a re-ordered subset. */
|
|
33
|
+
export const DEFAULT_SERVICE_ACCOUNT_SCOPE = "fr:am:* fr:idm:* fr:idc:esv:*";
|
|
34
|
+
/** Assertions are rejected if `exp` is much past 180s (`docs/api/00-auth.md`). */
|
|
35
|
+
const ASSERTION_LIFETIME_S = 180;
|
|
36
|
+
/** Refresh this long before the tenant's `expires_in` runs out. */
|
|
37
|
+
const REFRESH_MARGIN_S = 60;
|
|
38
|
+
/**
|
|
39
|
+
* Mint bearers with the JWT-bearer grant, as the `aic` agent does: an RS256
|
|
40
|
+
* assertion (`iss` = `sub` = the account id, `aud` = the token endpoint, a
|
|
41
|
+
* short `exp`, a fresh `jti`) posted to the root realm's
|
|
42
|
+
* `/am/oauth2/access_token` as `client_id=service-account`.
|
|
43
|
+
*
|
|
44
|
+
* The token is cached until {@link REFRESH_MARGIN_S} before its
|
|
45
|
+
* `expires_in`; a `"rejected"` request always mints a new one.
|
|
46
|
+
*/
|
|
47
|
+
export function serviceAccountProvider(options) {
|
|
48
|
+
const description = describeUrl(options.baseUrl, options.name);
|
|
49
|
+
const key = privateKeyFrom(options.jwk);
|
|
50
|
+
const http = options.http ?? sendHttp;
|
|
51
|
+
const now = options.now ?? (() => Date.now() / 1000);
|
|
52
|
+
const tokenUrl = `${description.baseUrl}/am/oauth2/access_token`;
|
|
53
|
+
let cached;
|
|
54
|
+
async function mint() {
|
|
55
|
+
const issuedAt = Math.floor(now());
|
|
56
|
+
const assertion = signAssertion(key, {
|
|
57
|
+
iss: options.serviceAccountId,
|
|
58
|
+
sub: options.serviceAccountId,
|
|
59
|
+
aud: tokenUrl,
|
|
60
|
+
exp: issuedAt + ASSERTION_LIFETIME_S,
|
|
61
|
+
jti: randomUUID(),
|
|
62
|
+
});
|
|
63
|
+
const body = new URLSearchParams({
|
|
64
|
+
client_id: "service-account",
|
|
65
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
66
|
+
assertion,
|
|
67
|
+
scope: options.scope ?? DEFAULT_SERVICE_ACCOUNT_SCOPE,
|
|
68
|
+
}).toString();
|
|
69
|
+
const response = await http({
|
|
70
|
+
url: tokenUrl,
|
|
71
|
+
method: "POST",
|
|
72
|
+
headerLines: [
|
|
73
|
+
["Content-Type", "application/x-www-form-urlencoded"],
|
|
74
|
+
["Accept", "application/json"],
|
|
75
|
+
],
|
|
76
|
+
body,
|
|
77
|
+
});
|
|
78
|
+
if (response.status !== 200) {
|
|
79
|
+
throw new TenantProviderError(`service-account token request (POST /am/oauth2/access_token) returned ${response.status}` +
|
|
80
|
+
tokenErrorDetail(response.body, description.baseUrl));
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(response.body);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new TenantProviderError("service-account token response is not JSON", {
|
|
88
|
+
cause: error,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const record = parsed;
|
|
92
|
+
if (typeof record.access_token !== "string" || record.access_token.length === 0) {
|
|
93
|
+
throw new TenantProviderError("service-account token response has no access_token");
|
|
94
|
+
}
|
|
95
|
+
const lifetime = typeof record.expires_in === "number" ? record.expires_in : 0;
|
|
96
|
+
cached = {
|
|
97
|
+
token: record.access_token,
|
|
98
|
+
refreshAt: issuedAt + Math.max(0, lifetime - REFRESH_MARGIN_S),
|
|
99
|
+
};
|
|
100
|
+
return record.access_token;
|
|
101
|
+
}
|
|
102
|
+
const logs = options.logKeys === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: logKeysReader(description.baseUrl, options.logKeys, http);
|
|
105
|
+
return {
|
|
106
|
+
describe: () => Promise.resolve(description),
|
|
107
|
+
async getToken(request) {
|
|
108
|
+
if (request.reason === "initial" && cached !== undefined && now() < cached.refreshAt) {
|
|
109
|
+
return cached.token;
|
|
110
|
+
}
|
|
111
|
+
return mint();
|
|
112
|
+
},
|
|
113
|
+
...(logs === undefined ? {} : { logs }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function privateKeyFrom(jwk) {
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = typeof jwk === "string" ? JSON.parse(jwk) : jwk;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new TenantProviderError("the service-account JWK is not JSON", { cause: error });
|
|
123
|
+
}
|
|
124
|
+
if (parsed.kty !== "RSA" || typeof parsed.d !== "string") {
|
|
125
|
+
throw new TenantProviderError("the service-account JWK must be an RSA private key (kty RSA, with d)");
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return createPrivateKey({ key: parsed, format: "jwk" });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new TenantProviderError("the service-account JWK is not a usable RSA private key", {
|
|
132
|
+
cause: error,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Compact JWS, RS256. Exported for tests that verify it with the public key. */
|
|
137
|
+
export function signAssertion(key, claims) {
|
|
138
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
139
|
+
const payload = base64url(JSON.stringify(claims));
|
|
140
|
+
const signature = sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), key);
|
|
141
|
+
return `${header}.${payload}.${signature.toString("base64url")}`;
|
|
142
|
+
}
|
|
143
|
+
function base64url(text) {
|
|
144
|
+
return Buffer.from(text, "utf8").toString("base64url");
|
|
145
|
+
}
|
|
146
|
+
// --- logs via API key -------------------------------------------------------
|
|
147
|
+
/** What `aic logs tx` reads by default (`src/logs/cli.rs`). */
|
|
148
|
+
export const DEFAULT_LOG_SOURCES = ["am-everything", "idm-everything"];
|
|
149
|
+
const ACCESS_OUTCOME = "AM-ACCESS-OUTCOME";
|
|
150
|
+
const MAX_LOG_PAGES = 50;
|
|
151
|
+
/** `aic`'s spacing (`src/logs/api.rs`): the API allows 60 requests/min. */
|
|
152
|
+
const REQUEST_INTERVAL_MS = 1_050;
|
|
153
|
+
const MAX_429_RETRIES = 6;
|
|
154
|
+
/**
|
|
155
|
+
* `GET /monitoring/logs?source=…&transactionId=…` with the `x-api-key` /
|
|
156
|
+
* `x-api-secret` pair, following `pagedResultsCookie` — the query
|
|
157
|
+
* `aic logs tx` makes, with its request spacing and its `Retry-After`-honouring
|
|
158
|
+
* 429 backoff. The log API rejects bearers (`docs/api/08-logs.md`), which is
|
|
159
|
+
* why this needs its own keys.
|
|
160
|
+
*
|
|
161
|
+
* `transactionId` is a **prefix** match (`docs/api/08-logs.md`), as it is for
|
|
162
|
+
* `aic logs tx`: asking for a chain's stem returns every pass under it, which
|
|
163
|
+
* is what `show-log`'s "whole chain" choice relies on.
|
|
164
|
+
*/
|
|
165
|
+
export function logKeysReader(baseUrl, keys, http, options = {}) {
|
|
166
|
+
// Exported on its own, so it checks its origin itself: the keys go there.
|
|
167
|
+
const origin = describeUrl(baseUrl, undefined).baseUrl;
|
|
168
|
+
const pause = options.sleep ?? sleep;
|
|
169
|
+
const now = options.now ?? Date.now;
|
|
170
|
+
// The rate limit is per environment, not per reader: every default reader
|
|
171
|
+
// in the process shares one spacer, as `aic`'s process-global throttle does.
|
|
172
|
+
// A test that injects a clock gets its own, so tests cannot delay each other.
|
|
173
|
+
const spacer = options.sleep === undefined && options.now === undefined
|
|
174
|
+
? sharedSpacer
|
|
175
|
+
: new RequestSpacer(pause, now);
|
|
176
|
+
async function get(url) {
|
|
177
|
+
for (let retry = 0;; retry += 1) {
|
|
178
|
+
await spacer.slot();
|
|
179
|
+
const response = await http({
|
|
180
|
+
url,
|
|
181
|
+
method: "GET",
|
|
182
|
+
headerLines: [
|
|
183
|
+
["x-api-key", keys.id],
|
|
184
|
+
["x-api-secret", keys.secret],
|
|
185
|
+
["Accept", "application/json"],
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
if (response.status !== 429 || retry >= MAX_429_RETRIES) {
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
191
|
+
const retryAfter = Number(headerValues(response.headers, "retry-after")[0]);
|
|
192
|
+
const backoff = 1_000 * 2 ** Math.min(retry, 6);
|
|
193
|
+
await pause(Math.max(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 0, backoff));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function fetchAll(id) {
|
|
197
|
+
const events = [];
|
|
198
|
+
let cookie;
|
|
199
|
+
for (let page = 0; page < MAX_LOG_PAGES; page += 1) {
|
|
200
|
+
const params = new URLSearchParams({
|
|
201
|
+
source: DEFAULT_LOG_SOURCES.join(","),
|
|
202
|
+
transactionId: id,
|
|
203
|
+
});
|
|
204
|
+
if (cookie !== undefined) {
|
|
205
|
+
params.set("_pagedResultsCookie", cookie);
|
|
206
|
+
}
|
|
207
|
+
const response = await get(`${origin}/monitoring/logs?${params.toString()}`);
|
|
208
|
+
if (response.status !== 200) {
|
|
209
|
+
throw new TenantProviderError(`GET /monitoring/logs returned ${response.status}: ${scrubTenant(response.body, origin).slice(0, 300)}`);
|
|
210
|
+
}
|
|
211
|
+
let body;
|
|
212
|
+
try {
|
|
213
|
+
body = JSON.parse(response.body);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new TenantProviderError("GET /monitoring/logs returned a body that is not JSON", {
|
|
217
|
+
cause: error,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(body.result)) {
|
|
221
|
+
throw new TenantProviderError("GET /monitoring/logs returned no result array");
|
|
222
|
+
}
|
|
223
|
+
events.push(...body.result);
|
|
224
|
+
cookie = typeof body.pagedResultsCookie === "string" ? body.pagedResultsCookie : undefined;
|
|
225
|
+
if (cookie === undefined || cookie.length === 0) {
|
|
226
|
+
return events;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw new TenantProviderError(`transaction ${id} spans more than ${MAX_LOG_PAGES} log pages; narrow it`);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
async transaction(id, readOptions = {}) {
|
|
233
|
+
const waitMs = readOptions.waitMs ?? 60_000;
|
|
234
|
+
const started = now();
|
|
235
|
+
let events = await fetchAll(id);
|
|
236
|
+
// Back-to-back like `aic logs tx --wait`; the request spacing above is
|
|
237
|
+
// what keeps the loop inside the rate limit.
|
|
238
|
+
while (!hasAccessOutcome(events) && now() - started < waitMs) {
|
|
239
|
+
events = await fetchAll(id);
|
|
240
|
+
}
|
|
241
|
+
return events;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Hands out request slots at least {@link REQUEST_INTERVAL_MS} apart, in call
|
|
247
|
+
* order. Concurrent callers queue on one chain, so two cannot read the same
|
|
248
|
+
* "last request" time and wake together.
|
|
249
|
+
*/
|
|
250
|
+
class RequestSpacer {
|
|
251
|
+
#chain = Promise.resolve();
|
|
252
|
+
#last;
|
|
253
|
+
#pause;
|
|
254
|
+
#now;
|
|
255
|
+
constructor(pause, now) {
|
|
256
|
+
this.#pause = pause;
|
|
257
|
+
this.#now = now;
|
|
258
|
+
}
|
|
259
|
+
slot() {
|
|
260
|
+
const next = this.#chain.then(async () => {
|
|
261
|
+
if (this.#last !== undefined) {
|
|
262
|
+
const wait = this.#last + REQUEST_INTERVAL_MS - this.#now();
|
|
263
|
+
if (wait > 0) {
|
|
264
|
+
await this.#pause(wait);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.#last = this.#now();
|
|
268
|
+
});
|
|
269
|
+
this.#chain = next.catch(() => undefined);
|
|
270
|
+
return next;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const sharedSpacer = new RequestSpacer(sleep, Date.now);
|
|
274
|
+
function hasAccessOutcome(events) {
|
|
275
|
+
return events.some((event) => event?.payload?.eventName ===
|
|
276
|
+
ACCESS_OUTCOME);
|
|
277
|
+
}
|
|
278
|
+
// --- environment ------------------------------------------------------------
|
|
279
|
+
/** The variables {@link providerFromEnv} reads. */
|
|
280
|
+
export const TENANT_ENV = {
|
|
281
|
+
url: "AIC_SCRIPT_TESTER_TENANT_URL",
|
|
282
|
+
name: "AIC_SCRIPT_TESTER_TENANT_NAME",
|
|
283
|
+
serviceAccountId: "AIC_SCRIPT_TESTER_SA_ID",
|
|
284
|
+
jwk: "AIC_SCRIPT_TESTER_SA_JWK",
|
|
285
|
+
jwkFile: "AIC_SCRIPT_TESTER_SA_JWK_FILE",
|
|
286
|
+
scope: "AIC_SCRIPT_TESTER_SA_SCOPE",
|
|
287
|
+
logKeyId: "AIC_SCRIPT_TESTER_LOG_KEY_ID",
|
|
288
|
+
logKeySecret: "AIC_SCRIPT_TESTER_LOG_KEY_SECRET",
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* A service-account provider from the environment, or `undefined` when
|
|
292
|
+
* `AIC_SCRIPT_TESTER_TENANT_URL` is unset. A partial configuration throws rather
|
|
293
|
+
* than falling back, so a typo cannot quietly send the run to another tenant.
|
|
294
|
+
*/
|
|
295
|
+
export function providerFromEnv(env = process.env) {
|
|
296
|
+
// Presence, not truthiness, decides whether a configuration was attempted:
|
|
297
|
+
// `AIC_SCRIPT_TESTER_SA_ID=` must not read as "unset" and fall through to `aic`.
|
|
298
|
+
const empty = Object.values(TENANT_ENV).filter((name) => env[name] === "");
|
|
299
|
+
if (empty.length > 0) {
|
|
300
|
+
throw new TenantProviderError(`${empty.join(", ")} ${empty.length === 1 ? "is" : "are"} set but empty`);
|
|
301
|
+
}
|
|
302
|
+
const url = env[TENANT_ENV.url];
|
|
303
|
+
if (url === undefined) {
|
|
304
|
+
const stray = Object.values(TENANT_ENV).filter((name) => env[name] !== undefined);
|
|
305
|
+
if (stray.length > 0) {
|
|
306
|
+
// Falling back to `aic` here would run against whatever context it has
|
|
307
|
+
// current — the very tenant these variables were set to avoid.
|
|
308
|
+
throw new TenantProviderError(`${stray.join(", ")} ${stray.length === 1 ? "is" : "are"} set but ${TENANT_ENV.url} is not`);
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
const id = env[TENANT_ENV.serviceAccountId];
|
|
313
|
+
const inline = env[TENANT_ENV.jwk];
|
|
314
|
+
const file = env[TENANT_ENV.jwkFile];
|
|
315
|
+
if (!id) {
|
|
316
|
+
throw new TenantProviderError(`${TENANT_ENV.url} is set but ${TENANT_ENV.serviceAccountId} is not`);
|
|
317
|
+
}
|
|
318
|
+
if (!inline === !file) {
|
|
319
|
+
throw new TenantProviderError(`${TENANT_ENV.url} is set: give exactly one of ${TENANT_ENV.jwk} or ${TENANT_ENV.jwkFile}`);
|
|
320
|
+
}
|
|
321
|
+
const jwk = inline ?? readFileSync(file, "utf8");
|
|
322
|
+
const keyId = env[TENANT_ENV.logKeyId];
|
|
323
|
+
const keySecret = env[TENANT_ENV.logKeySecret];
|
|
324
|
+
if (!keyId !== !keySecret) {
|
|
325
|
+
throw new TenantProviderError(`give both ${TENANT_ENV.logKeyId} and ${TENANT_ENV.logKeySecret}, or neither`);
|
|
326
|
+
}
|
|
327
|
+
const scope = env[TENANT_ENV.scope];
|
|
328
|
+
const name = env[TENANT_ENV.name];
|
|
329
|
+
return serviceAccountProvider({
|
|
330
|
+
baseUrl: url,
|
|
331
|
+
serviceAccountId: id,
|
|
332
|
+
jwk,
|
|
333
|
+
...(scope ? { scope } : {}),
|
|
334
|
+
...(name ? { name } : {}),
|
|
335
|
+
...(keyId && keySecret ? { logKeys: { id: keyId, secret: keySecret } } : {}),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// --- registration -----------------------------------------------------------
|
|
339
|
+
let registered;
|
|
340
|
+
/**
|
|
341
|
+
* Set the provider every tenant-lane connection in this process uses, e.g.
|
|
342
|
+
* from a Vitest `setupFiles` module. Pass `undefined` to clear it.
|
|
343
|
+
*/
|
|
344
|
+
export function setTenantProvider(provider) {
|
|
345
|
+
registered = provider;
|
|
346
|
+
}
|
|
347
|
+
/** The registered provider, else one from the environment, else `undefined`. */
|
|
348
|
+
export function configuredTenantProvider(env = process.env) {
|
|
349
|
+
return registered ?? providerFromEnv(env);
|
|
350
|
+
}
|
|
351
|
+
// --- helpers ----------------------------------------------------------------
|
|
352
|
+
/**
|
|
353
|
+
* Re-check what a provider says about its tenant. Every built-in provider
|
|
354
|
+
* validates its URL when it is made, but a caller's own `TenantProvider` is
|
|
355
|
+
* only a promise, and `connectTenant` sends a bearer to whatever this returns.
|
|
356
|
+
*/
|
|
357
|
+
export function checkTenantDescription(description) {
|
|
358
|
+
if (typeof description.name !== "string" || description.name.trim() === "") {
|
|
359
|
+
throw new TenantProviderError("the tenant provider described a tenant with no name");
|
|
360
|
+
}
|
|
361
|
+
return describeUrl(description.baseUrl, description.name);
|
|
362
|
+
}
|
|
363
|
+
function describeUrl(baseUrl, name) {
|
|
364
|
+
let url;
|
|
365
|
+
try {
|
|
366
|
+
url = new URL(baseUrl);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
throw new TenantProviderError(`tenant base URL ${JSON.stringify(baseUrl)} is not a URL`, {
|
|
370
|
+
cause: error,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (url.protocol !== "https:") {
|
|
374
|
+
// The assertion, every bearer and the log keys travel to this origin.
|
|
375
|
+
throw new TenantProviderError(`tenant base URL must be https (got ${url.protocol.replace(/:$/, "")})`);
|
|
376
|
+
}
|
|
377
|
+
if (url.pathname !== "/" && url.pathname !== "") {
|
|
378
|
+
throw new TenantProviderError(`tenant base URL must have no path (got ${url.pathname}); the harness adds /am and /openidm`);
|
|
379
|
+
}
|
|
380
|
+
return { name: name ?? url.hostname, baseUrl: url.origin };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The OAuth `error` / `error_description` of a refused token request, with the
|
|
384
|
+
* tenant's origin and hostname replaced. The description is what names the
|
|
385
|
+
* problem (a bad `aud` quotes the URL it expected), but the message ends up in
|
|
386
|
+
* test output and CI logs, where the tenant's hostname does not belong.
|
|
387
|
+
*/
|
|
388
|
+
export function tokenErrorDetail(body, baseUrl) {
|
|
389
|
+
let parsed;
|
|
390
|
+
try {
|
|
391
|
+
parsed = JSON.parse(body);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return "";
|
|
395
|
+
}
|
|
396
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
const { error, error_description: description } = parsed;
|
|
400
|
+
const scrub = (text) => scrubTenant(text, baseUrl).slice(0, 200);
|
|
401
|
+
const parts = [
|
|
402
|
+
typeof error === "string" ? scrub(error) : undefined,
|
|
403
|
+
typeof description === "string" ? scrub(description) : undefined,
|
|
404
|
+
].filter((part) => part !== undefined && part !== "");
|
|
405
|
+
return parts.length === 0 ? "" : `: ${parts.join(" — ")}`;
|
|
406
|
+
}
|
|
407
|
+
/** `text` with the tenant's origin and hostname replaced by `<tenant>`. */
|
|
408
|
+
export function scrubTenant(text, baseUrl) {
|
|
409
|
+
const host = new URL(baseUrl).hostname;
|
|
410
|
+
return text.split(baseUrl).join("<tenant>").split(host).join("<tenant>");
|
|
411
|
+
}
|
|
412
|
+
function sleep(ms) {
|
|
413
|
+
return new Promise((resolve) => {
|
|
414
|
+
setTimeout(resolve, ms);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CallbackEffect, Given, JsonObject, RecordedEffects } from "../case/types.ts";
|
|
2
|
+
export interface SubjectDump {
|
|
3
|
+
outcome: string;
|
|
4
|
+
before: JsonObject;
|
|
5
|
+
final: JsonObject;
|
|
6
|
+
leaseDigest?: string;
|
|
7
|
+
invocationNonce?: string;
|
|
8
|
+
subjectDigest?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parse the result-script payload. The script records `outcome` and the
|
|
12
|
+
* unified `nodeState` map; this module classifies keys into shared/transient/
|
|
13
|
+
* secure using `given`, so the dump does not reimplement the verdict rule.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseSubjectDump(raw: unknown): SubjectDump;
|
|
16
|
+
export declare function assembleEffects(args: {
|
|
17
|
+
given: Given;
|
|
18
|
+
dump?: SubjectDump;
|
|
19
|
+
callbacks: CallbackEffect[];
|
|
20
|
+
}): RecordedEffects;
|
|
21
|
+
/**
|
|
22
|
+
* Classify only what the AIC wrapper actually established. The snapshots use
|
|
23
|
+
* unified `nodeState.keys/get`, so a changed or added value is observable but
|
|
24
|
+
* its bucket is not. Declared seeds remain exact only while unchanged; a seed
|
|
25
|
+
* missing afterwards is an exact removal only when it existed in one bucket.
|
|
26
|
+
* State present before the subject but absent from `given` is ambient.
|
|
27
|
+
*/
|
|
28
|
+
export declare function classifyFinal(given: Given, before: JsonObject, final: JsonObject): Pick<Required<RecordedEffects>, "sharedState" | "transientState" | "secureState" | "evidence">;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { STATE_CHANNELS } from "../case/types.js";
|
|
2
|
+
import { deepEqual } from "../case/equal.js";
|
|
3
|
+
import { diffState } from "../case/state.js";
|
|
4
|
+
import { isPlainObject, parseJsonObject } from "../case/util.js";
|
|
5
|
+
const AIC_UNOBSERVED = ["openidm", "http", "logs"];
|
|
6
|
+
/**
|
|
7
|
+
* Parse the result-script payload. The script records `outcome` and the
|
|
8
|
+
* unified `nodeState` map; this module classifies keys into shared/transient/
|
|
9
|
+
* secure using `given`, so the dump does not reimplement the verdict rule.
|
|
10
|
+
*/
|
|
11
|
+
export function parseSubjectDump(raw) {
|
|
12
|
+
if (typeof raw === "string") {
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
throw new Error(`rhino-local: harness dump is not JSON: ${message}`, { cause: error });
|
|
20
|
+
}
|
|
21
|
+
return parseSubjectDump(parsed);
|
|
22
|
+
}
|
|
23
|
+
if (!isPlainObject(raw)) {
|
|
24
|
+
throw new Error("rhino-local: harness dump is not an object");
|
|
25
|
+
}
|
|
26
|
+
if (typeof raw.outcome !== "string") {
|
|
27
|
+
throw new Error("rhino-local: harness dump.outcome must be a string");
|
|
28
|
+
}
|
|
29
|
+
if (!("before" in raw) || !("final" in raw)) {
|
|
30
|
+
throw new Error("rhino-local: harness dump must have before and final");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
outcome: raw.outcome,
|
|
34
|
+
before: parseJsonObject(raw.before, "dump.before"),
|
|
35
|
+
final: parseJsonObject(raw.final, "dump.final"),
|
|
36
|
+
...(typeof raw.leaseDigest === "string" ? { leaseDigest: raw.leaseDigest } : {}),
|
|
37
|
+
...(typeof raw.invocationNonce === "string"
|
|
38
|
+
? { invocationNonce: raw.invocationNonce }
|
|
39
|
+
: {}),
|
|
40
|
+
...(typeof raw.subjectDigest === "string"
|
|
41
|
+
? { subjectDigest: raw.subjectDigest }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function assembleEffects(args) {
|
|
46
|
+
const sharedInitial = args.given.sharedState ?? {};
|
|
47
|
+
const transientInitial = args.given.transientState ?? {};
|
|
48
|
+
const secureInitial = args.given.secureState ?? {};
|
|
49
|
+
if (args.dump === undefined) {
|
|
50
|
+
// Subject suspended with callbacks: the result node never ran, so we
|
|
51
|
+
// did not observe nodeState. Claiming every given key was removed would
|
|
52
|
+
// be a false mutation.
|
|
53
|
+
return {
|
|
54
|
+
outcome: null,
|
|
55
|
+
sharedState: { initial: sharedInitial, final: { ...sharedInitial } },
|
|
56
|
+
transientState: { initial: transientInitial, final: { ...transientInitial } },
|
|
57
|
+
secureState: { initial: secureInitial, final: { ...secureInitial } },
|
|
58
|
+
callbacks: args.callbacks,
|
|
59
|
+
openidm: [],
|
|
60
|
+
http: [],
|
|
61
|
+
logs: [],
|
|
62
|
+
evidence: {
|
|
63
|
+
stateBuckets: "unified",
|
|
64
|
+
ambientState: {},
|
|
65
|
+
unbucketedState: [],
|
|
66
|
+
unobservedChannels: [
|
|
67
|
+
"sharedState",
|
|
68
|
+
"transientState",
|
|
69
|
+
"secureState",
|
|
70
|
+
...AIC_UNOBSERVED,
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const classified = classifyFinal(args.given, args.dump.before, args.dump.final);
|
|
76
|
+
return {
|
|
77
|
+
outcome: args.dump.outcome,
|
|
78
|
+
sharedState: classified.sharedState,
|
|
79
|
+
transientState: classified.transientState,
|
|
80
|
+
secureState: classified.secureState,
|
|
81
|
+
callbacks: args.callbacks,
|
|
82
|
+
openidm: [],
|
|
83
|
+
http: [],
|
|
84
|
+
logs: [],
|
|
85
|
+
evidence: classified.evidence,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Classify only what the AIC wrapper actually established. The snapshots use
|
|
90
|
+
* unified `nodeState.keys/get`, so a changed or added value is observable but
|
|
91
|
+
* its bucket is not. Declared seeds remain exact only while unchanged; a seed
|
|
92
|
+
* missing afterwards is an exact removal only when it existed in one bucket.
|
|
93
|
+
* State present before the subject but absent from `given` is ambient.
|
|
94
|
+
*/
|
|
95
|
+
export function classifyFinal(given, before, final) {
|
|
96
|
+
const sharedInitial = given.sharedState ?? {};
|
|
97
|
+
const transientInitial = given.transientState ?? {};
|
|
98
|
+
const secureInitial = given.secureState ?? {};
|
|
99
|
+
verifySeedsVisible(given, before);
|
|
100
|
+
const sharedFinal = { ...sharedInitial };
|
|
101
|
+
const transientFinal = { ...transientInitial };
|
|
102
|
+
const secureFinal = { ...secureInitial };
|
|
103
|
+
const ambientState = {};
|
|
104
|
+
for (const [key, value] of Object.entries(before)) {
|
|
105
|
+
if (seedBuckets(given, key).length === 0) {
|
|
106
|
+
ambientState[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const unbucketedState = [];
|
|
110
|
+
for (const mutation of diffState(before, final)) {
|
|
111
|
+
const buckets = seedBuckets(given, mutation.key);
|
|
112
|
+
if (mutation.operation === "removed" && buckets.length === 1) {
|
|
113
|
+
const bucket = buckets[0];
|
|
114
|
+
if (bucket !== undefined) {
|
|
115
|
+
delete finalFor(bucket, sharedFinal, transientFinal, secureFinal)[mutation.key];
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
unbucketedState.push({
|
|
120
|
+
...mutation,
|
|
121
|
+
possibleBuckets: buckets.length === 0 && mutation.operation === "added"
|
|
122
|
+
? ["sharedState", "transientState"]
|
|
123
|
+
: [...STATE_CHANNELS],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
sharedState: { initial: sharedInitial, final: sharedFinal },
|
|
128
|
+
transientState: { initial: transientInitial, final: transientFinal },
|
|
129
|
+
secureState: { initial: secureInitial, final: secureFinal },
|
|
130
|
+
evidence: {
|
|
131
|
+
stateBuckets: "unified",
|
|
132
|
+
ambientState,
|
|
133
|
+
unbucketedState,
|
|
134
|
+
unobservedChannels: [...AIC_UNOBSERVED],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function seedBuckets(given, key) {
|
|
139
|
+
const buckets = [];
|
|
140
|
+
if (Object.prototype.hasOwnProperty.call(given.sharedState ?? {}, key)) {
|
|
141
|
+
buckets.push("sharedState");
|
|
142
|
+
}
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(given.transientState ?? {}, key)) {
|
|
144
|
+
buckets.push("transientState");
|
|
145
|
+
}
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(given.secureState ?? {}, key)) {
|
|
147
|
+
buckets.push("secureState");
|
|
148
|
+
}
|
|
149
|
+
return buckets;
|
|
150
|
+
}
|
|
151
|
+
function verifySeedsVisible(given, before) {
|
|
152
|
+
const keys = new Set([
|
|
153
|
+
...Object.keys(given.sharedState ?? {}),
|
|
154
|
+
...Object.keys(given.secureState ?? {}),
|
|
155
|
+
...Object.keys(given.transientState ?? {}),
|
|
156
|
+
]);
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
const expected = visibleSeed(given, key);
|
|
159
|
+
if (!Object.prototype.hasOwnProperty.call(before, key) ||
|
|
160
|
+
!deepEqual(before[key], expected)) {
|
|
161
|
+
throw new Error(`rhino-local: AIC subject state did not contain the declared seed ${JSON.stringify(key)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function visibleSeed(given, key) {
|
|
166
|
+
// AM's unified lookup precedence is transient → secure → shared.
|
|
167
|
+
if (Object.prototype.hasOwnProperty.call(given.transientState ?? {}, key)) {
|
|
168
|
+
return given.transientState?.[key];
|
|
169
|
+
}
|
|
170
|
+
if (Object.prototype.hasOwnProperty.call(given.secureState ?? {}, key)) {
|
|
171
|
+
return given.secureState?.[key];
|
|
172
|
+
}
|
|
173
|
+
return given.sharedState?.[key];
|
|
174
|
+
}
|
|
175
|
+
function finalFor(bucket, shared, transient, secure) {
|
|
176
|
+
if (bucket === "sharedState") {
|
|
177
|
+
return shared;
|
|
178
|
+
}
|
|
179
|
+
if (bucket === "transientState") {
|
|
180
|
+
return transient;
|
|
181
|
+
}
|
|
182
|
+
return secure;
|
|
183
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AicResourceKind = "script" | "node" | "tree";
|
|
2
|
+
/** Strip server-owned node metadata even though AM accepts some of it. */
|
|
3
|
+
export declare function nodeRequestProjection(value: Readonly<Record<string, unknown>>): Record<string, unknown>;
|
|
4
|
+
export declare function resourceRequestProjection(kind: AicResourceKind, value: Readonly<Record<string, unknown>>): Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Return the snapshot from the confirming GET, never from submitted bytes.
|
|
7
|
+
*
|
|
8
|
+
* Script and tree `description` round-trips byte-exact (measured 2026-09-14,
|
|
9
|
+
* `docs/api/04-scripts.md`), so the marker stays in the comparison rather than
|
|
10
|
+
* being normalized away — a rewritten marker is a real failure, not noise.
|
|
11
|
+
*/
|
|
12
|
+
export declare function confirmResourceSnapshot(kind: AicResourceKind, submitted: Readonly<Record<string, unknown>>, confirmed: unknown): Record<string, unknown>;
|