haechi 0.9.0 → 1.0.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.
@@ -0,0 +1,608 @@
1
+ // The worker-isolated authProvider sandbox (Haechi 1.0 §2.3/§2.4/§7.4).
2
+ //
3
+ // HONEST MODEL (read packages/../docs/current/release-1.0-implementation-scope.md
4
+ // §1): node:worker_threads is NOT a capability sandbox. A worker shares the
5
+ // process and a malicious *signed* plugin can still use fs/net/process.env — that
6
+ // residual is accepted and gated ONLY by the PR2 signing/trust gate, never by the
7
+ // worker. What the worker DOES give us, and all this module claims:
8
+ // - V8-heap memory isolation (the plugin cannot read the host's crypto key,
9
+ // token vault, or audit sink — only a typed JSON-string message crosses);
10
+ // - crash/hang containment via resourceLimits + a per-call timeout that
11
+ // terminates the worker (a hang fails closed → deny);
12
+ // - data minimization (the worker receives ONLY the credential slice, never the
13
+ // request body / key / sink; the HOST builds the keyed-HMAC identity);
14
+ // - a narrow, audited, correlation-id'd contract.
15
+ //
16
+ // Zero runtime dependency: node:worker_threads + node:crypto + node:fs only, plus
17
+ // in-repo haechi/plugin (PR2 verify) and haechi/auth (identity + conformance).
18
+
19
+ import { Worker } from "node:worker_threads";
20
+ import { lstatSync, readFileSync, statSync } from "node:fs";
21
+ import { createHash, randomUUID } from "node:crypto";
22
+ import { dirname, resolve as resolvePath, sep as pathSep } from "node:path";
23
+ import { verifySignedPlugin } from "./signing.mjs";
24
+ import { validatePluginManifest } from "./index.mjs";
25
+ import { assertAuthProviderConformance, buildExternalIdentity } from "../auth/index.mjs";
26
+
27
+ // The only own-enumerable keys the host accepts back from the worker. Anything
28
+ // else (incl. __proto__/constructor/prototype) is dropped at the boundary.
29
+ const CLAIM_ALLOWLIST = ["subject", "issuer", "type", "scopes", "labels"];
30
+ // Defensive bounds so a hostile claims object cannot blow up the host build.
31
+ const MAX_SCOPES = 64;
32
+ const MAX_LABELS = 32;
33
+ const MAX_STRING_LEN = 1024;
34
+
35
+ // The wire harness the host wraps around the worker so a generic codeString plugin
36
+ // only has to .on("message")/.postMessage JSON strings. Each plugin entry exports
37
+ // (default or named) `authenticate(credential) -> claims | { deny: true } | null`.
38
+ // We inline the harness as a string (NOT a path import) because the worker runs
39
+ // from the in-memory verified bytes — it has no module graph back to this repo,
40
+ // and a shipped packages/ file must never import a tests/ or scripts/ helper.
41
+ function workerHarness(entrySource) {
42
+ return [
43
+ "const { parentPort } = require('worker_threads');",
44
+ "let __plugin = null;",
45
+ "async function __load() {",
46
+ " if (__plugin) return __plugin;",
47
+ " const mod = await import('data:text/javascript;base64,' + " +
48
+ JSON.stringify(Buffer.from(entrySource, "utf8").toString("base64")) + ");",
49
+ " __plugin = (typeof mod.default === 'function') ? mod.default",
50
+ " : (typeof mod.authenticate === 'function') ? mod.authenticate",
51
+ " : (mod.default && typeof mod.default.authenticate === 'function') ? mod.default.authenticate",
52
+ " : null;",
53
+ " if (typeof __plugin !== 'function') throw new Error('plugin entry must export an authenticate function');",
54
+ " return __plugin;",
55
+ "}",
56
+ "parentPort.on('message', async (raw) => {",
57
+ " let cid = null;",
58
+ " try {",
59
+ " const msg = JSON.parse(raw);",
60
+ " cid = msg.cid;",
61
+ " const authenticate = await __load();",
62
+ " const out = await authenticate(msg.credential);",
63
+ " if (!out || out.deny === true || typeof out !== 'object') {",
64
+ " parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
65
+ " return;",
66
+ " }",
67
+ " parentPort.postMessage(JSON.stringify({ cid, claims: out }));",
68
+ " } catch (err) {",
69
+ // A plugin throw NEVER propagates: it surfaces to the host as a deny.
70
+ " parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
71
+ " }",
72
+ "});"
73
+ ].join("\n");
74
+ }
75
+
76
+ function sha256Hex(bytes) {
77
+ return createHash("sha256").update(bytes).digest("hex");
78
+ }
79
+
80
+ // Same parsing the bearer provider uses — ONLY the Authorization header, never
81
+ // the request body. Returns the bearer token slice (the credential) or null.
82
+ function bearerCredentialFromRequest(request) {
83
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
84
+ if (typeof header !== "string") {
85
+ return null;
86
+ }
87
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
88
+ return match ? match[1].trim() : null;
89
+ }
90
+
91
+ // Reconstruct the PR2 signed envelope ({ payload, signerKeyId, alg, signature })
92
+ // from a worker-isolated manifest's stored fields. Authors produce this with
93
+ // signPluginManifest; the manifest persists it under haechiPlugin.signed and
94
+ // mirrors the flat fields so validatePluginManifest can check the shape.
95
+ function envelopeFromManifest(plugin) {
96
+ if (plugin.signed && typeof plugin.signed === "object") {
97
+ return plugin.signed;
98
+ }
99
+ // Fallback: assemble from the flat manifest fields (signature/signerKeyId +
100
+ // the signed payload mirror under haechiPlugin.signedPayload).
101
+ return {
102
+ payload: plugin.signedPayload,
103
+ signerKeyId: plugin.signerKeyId,
104
+ alg: plugin.alg ?? "ed25519",
105
+ signature: plugin.signature
106
+ };
107
+ }
108
+
109
+ // Host-side claims sanitizer. The reply is parsed, then ONLY the allowlisted
110
+ // own-enumerable keys are copied onto a null-prototype object — __proto__/
111
+ // constructor/prototype can never reach buildExternalIdentity, and array/string
112
+ // sizes are bounded. Returns a plain {subject,issuer,type,scopes,labels} or
113
+ // throws (→ deny) on a structurally invalid claim.
114
+ function sanitizeClaims(rawClaims) {
115
+ if (!rawClaims || typeof rawClaims !== "object" || Array.isArray(rawClaims)) {
116
+ throw new Error("claims must be an object");
117
+ }
118
+ const out = Object.create(null);
119
+ for (const key of CLAIM_ALLOWLIST) {
120
+ // Own-enumerable only; never walk the prototype.
121
+ if (!Object.prototype.hasOwnProperty.call(rawClaims, key)) {
122
+ continue;
123
+ }
124
+ out[key] = rawClaims[key];
125
+ }
126
+ // type-validate / bound each value at the boundary.
127
+ if (typeof out.subject !== "string" || out.subject.length === 0 || out.subject.length > MAX_STRING_LEN) {
128
+ throw new Error("claims.subject must be a bounded non-empty string");
129
+ }
130
+ if (typeof out.issuer !== "string" || out.issuer.length === 0 || out.issuer.length > MAX_STRING_LEN) {
131
+ throw new Error("claims.issuer must be a bounded non-empty string");
132
+ }
133
+ if (out.type !== undefined && typeof out.type !== "string") {
134
+ throw new Error("claims.type must be a string");
135
+ }
136
+ if (out.scopes !== undefined) {
137
+ if (!Array.isArray(out.scopes) || out.scopes.length > MAX_SCOPES
138
+ || !out.scopes.every((s) => typeof s === "string" && s.length > 0 && s.length <= MAX_STRING_LEN)) {
139
+ throw new Error("claims.scopes must be a bounded array of non-empty strings");
140
+ }
141
+ }
142
+ if (out.labels !== undefined) {
143
+ if (!out.labels || typeof out.labels !== "object" || Array.isArray(out.labels)) {
144
+ throw new Error("claims.labels must be an object");
145
+ }
146
+ const labelKeys = Object.keys(out.labels);
147
+ if (labelKeys.length > MAX_LABELS) {
148
+ throw new Error("claims.labels exceeds the size bound");
149
+ }
150
+ const bounded = Object.create(null);
151
+ for (const k of labelKeys) {
152
+ const v = out.labels[k];
153
+ if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRING_LEN) {
154
+ throw new Error(`claims.labels.${k} must be a bounded non-empty string`);
155
+ }
156
+ bounded[k] = v;
157
+ }
158
+ out.labels = bounded;
159
+ }
160
+ return out;
161
+ }
162
+
163
+ function createSandboxedAuthProviderHandle({
164
+ manifestPath,
165
+ trustAnchors,
166
+ allowCapabilities = [],
167
+ pin = null,
168
+ revoked = {},
169
+ versionFloor = {},
170
+ cryptoProvider,
171
+ auditSink,
172
+ timeoutMs,
173
+ maxPendingCalls = 8,
174
+ maxMessageBytes = 16384,
175
+ resourceLimits,
176
+ coreVersion = null,
177
+ now = Date.now,
178
+ allowedLabelKeys
179
+ } = {}) {
180
+ if (!manifestPath || typeof manifestPath !== "string") {
181
+ throw new Error("createSandboxedAuthProvider requires a manifestPath string");
182
+ }
183
+ if (typeof cryptoProvider?.hmac !== "function") {
184
+ throw new Error("createSandboxedAuthProvider requires a cryptoProvider with hmac()");
185
+ }
186
+ if (!auditSink || typeof auditSink.record !== "function") {
187
+ throw new Error("createSandboxedAuthProvider requires an auditSink with record()");
188
+ }
189
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
190
+ throw new Error("createSandboxedAuthProvider requires a positive integer timeoutMs");
191
+ }
192
+ if (!Number.isInteger(maxPendingCalls) || maxPendingCalls < 1) {
193
+ throw new Error("maxPendingCalls must be a positive integer");
194
+ }
195
+ if (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1) {
196
+ throw new Error("maxMessageBytes must be a positive integer");
197
+ }
198
+ const nowFn = typeof now === "function" ? now : () => now;
199
+
200
+ // Fire-and-forget audit; lifecycle audit must never make the auth path throw.
201
+ const audit = (event) => {
202
+ try {
203
+ const out = auditSink.record(event);
204
+ if (out && typeof out.then === "function") {
205
+ out.catch(() => {});
206
+ }
207
+ } catch {
208
+ // swallow — auditing is best-effort and never blocks fail-closed behavior
209
+ }
210
+ };
211
+
212
+ // Read+validate the manifest, resolve the entry path, read the entry bytes into
213
+ // memory, and run the FULL PR2 gate. Returns { verified, entrySource,
214
+ // entrySha256, pluginId }. Throws (after emitting plugin.load.refused) on any
215
+ // refusal. Re-run on every (re)spawn — the gate is not a one-time check.
216
+ function loadAndVerify() {
217
+ let manifestRaw;
218
+ let plugin;
219
+ let entryPath;
220
+ let entrySource;
221
+ let entrySha256;
222
+ let pluginIdForAudit;
223
+ let signerKeyIdForAudit;
224
+ try {
225
+ manifestRaw = JSON.parse(readFileSync(manifestPath, "utf8"));
226
+ plugin = manifestRaw?.haechiPlugin;
227
+ pluginIdForAudit = plugin?.id;
228
+ const validation = validatePluginManifest(manifestRaw);
229
+ if (!validation.valid) {
230
+ throw refuse("manifest-invalid", `manifest invalid: ${validation.errors.join("; ")}`);
231
+ }
232
+ if (plugin.runtime !== "worker-isolated") {
233
+ throw refuse("manifest-invalid", "sandbox requires runtime worker-isolated");
234
+ }
235
+ if (plugin.kind !== "authProvider") {
236
+ throw refuse("manifest-invalid", "sandbox requires kind authProvider");
237
+ }
238
+
239
+ // Resolve the entry against the manifest dir. Reject a symlinked entry
240
+ // (anti-TOCTOU / swap): we hash and spawn from the in-memory bytes only.
241
+ const manifestDir = resolvePath(dirname(resolvePath(manifestPath)));
242
+ entryPath = resolvePath(manifestDir, plugin.entrypoint);
243
+
244
+ // FIX C — entrypoint confinement: the resolved entry path MUST be inside
245
+ // the manifest directory. An absolute path or a `../`-escaping value
246
+ // resolves outside manifestDir and is an arbitrary-file-read primitive
247
+ // (code execution is still blocked by the entrySha256 hash check, but
248
+ // reading an arbitrary host file into memory is unintended).
249
+ // We check BEFORE lstatSync / readFileSync so no I/O occurs on the path.
250
+ if (!entryPath.startsWith(manifestDir + pathSep) && entryPath !== manifestDir) {
251
+ throw refuse("manifest-invalid", `entry path escapes the manifest directory: ${plugin.entrypoint}`);
252
+ }
253
+
254
+ const st = lstatSync(entryPath);
255
+ if (st.isSymbolicLink()) {
256
+ throw refuse("tampered-entry", "entry path is a symlink (refused)");
257
+ }
258
+
259
+ // FIX C — max-size bound: refuse to read an unreasonably large entry into
260
+ // memory. A few MiB is generous for any auth plugin; beyond that it is
261
+ // almost certainly a mistake or an attempt to exhaust host memory.
262
+ const MAX_ENTRY_BYTES = 4 * 1024 * 1024; // 4 MiB
263
+ const entrySize = statSync(entryPath).size;
264
+ if (entrySize > MAX_ENTRY_BYTES) {
265
+ throw refuse("manifest-invalid", `entry file exceeds maximum size (${entrySize} > ${MAX_ENTRY_BYTES} bytes)`);
266
+ }
267
+
268
+ const entryBytes = readFileSync(entryPath); // INTO MEMORY — read exactly once.
269
+ entrySource = entryBytes.toString("utf8");
270
+ entrySha256 = sha256Hex(entryBytes);
271
+
272
+ const envelope = envelopeFromManifest(plugin);
273
+ signerKeyIdForAudit = envelope?.signerKeyId;
274
+ } catch (error) {
275
+ if (error?.__haechiRefusal) {
276
+ throw error.cause;
277
+ }
278
+ // A read/parse error before validation runs as a manifest refusal.
279
+ const refusal = refuse("manifest-invalid", `manifest load failed: ${error.message}`, pluginIdForAudit);
280
+ throw refusal.cause;
281
+ }
282
+
283
+ // The PR2 gate (signature + anchor + revocation + tamper + window + floor +
284
+ // pin + capability allowlist + coreVersionRange). Any failure throws a
285
+ // PluginLoadError whose .reason is the audit reason.
286
+ let verified;
287
+ try {
288
+ verified = verifySignedPlugin({
289
+ signed: envelopeFromManifest(plugin),
290
+ entryBytes: Buffer.from(entrySource, "utf8"),
291
+ trustAnchors,
292
+ revoked,
293
+ pin,
294
+ versionFloor,
295
+ allowCapabilities,
296
+ coreVersion,
297
+ now: nowFn()
298
+ });
299
+ } catch (error) {
300
+ const reason = typeof error?.reason === "string" ? error.reason : "manifest-invalid";
301
+ audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId: pluginIdForAudit, signerKeyId: signerKeyIdForAudit });
302
+ throw error;
303
+ }
304
+
305
+ return {
306
+ verified,
307
+ entrySource,
308
+ entrySha256,
309
+ pluginId: verified.pluginId,
310
+ signerKeyId: envelopeFromManifest(plugin).signerKeyId
311
+ };
312
+ }
313
+
314
+ // A tagged-throw helper so loadAndVerify can emit the refused audit at one site.
315
+ function refuse(reason, message, pluginId) {
316
+ const err = new Error(message);
317
+ err.reason = reason;
318
+ audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId });
319
+ return { __haechiRefusal: true, cause: err };
320
+ }
321
+
322
+ // ---- worker lifecycle ----------------------------------------------------
323
+
324
+ let worker = null;
325
+ let pluginId = null;
326
+ let closed = false;
327
+ // cid -> settle(reply). Drops late/duplicate/unmatched replies by cid. Only one
328
+ // entry is ever live at a time (single-occupancy via the serialization chain).
329
+ const pending = new Map();
330
+ let respawning = null; // single-flight respawn guard
331
+ // Serialization chain: worker round-trips run ONE AT A TIME (single-occupancy),
332
+ // so a per-call timeout-terminate can never kill a sibling. queueDepth bounds
333
+ // how many calls may be waiting+running before the worker; excess → deny.
334
+ let chain = Promise.resolve();
335
+ let queueDepth = 0;
336
+
337
+ function spawnFromVerified({ entrySource, pluginId: pid }) {
338
+ const code = workerHarness(entrySource);
339
+ const w = new Worker(code, {
340
+ eval: true,
341
+ resourceLimits,
342
+ // NO host secrets, NO key, NO sink, NO request body cross the boundary.
343
+ workerData: {}
344
+ });
345
+ w.on("message", (raw) => {
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
349
+ } catch {
350
+ return; // unparseable → drop
351
+ }
352
+ const cid = parsed?.cid;
353
+ const settle = pending.get(cid);
354
+ if (!settle) {
355
+ return; // unmatched / duplicate / late → drop
356
+ }
357
+ pending.delete(cid);
358
+ settle(parsed);
359
+ });
360
+ // FIX D — stale-worker error race: guard with `worker === w` (same as the
361
+ // exit handler) so a late async error from an already-terminated worker
362
+ // (e.g. from the previous incarnation after a timeout-terminate and respawn)
363
+ // cannot spuriously terminate the live replacement worker.
364
+ w.on("error", () => { if (worker === w) terminateWorker("crash"); });
365
+ w.on("exit", (exitCode) => {
366
+ if (exitCode !== 0 && worker === w) {
367
+ terminateWorker("crash");
368
+ }
369
+ });
370
+ worker = w;
371
+ pluginId = pid;
372
+ }
373
+
374
+ // Drop the live worker (audit the cause), failing any matched in-flight call
375
+ // closed. Respawn happens lazily on the next call (re-running the full gate).
376
+ function terminateWorker(cause) {
377
+ const terminated = worker;
378
+ worker = null;
379
+ if (terminated) {
380
+ audit({ type: "plugin.worker.terminated", decision: "plugin.worker.terminated", pluginId, cause });
381
+ try { terminated.terminate(); } catch { /* already gone */ }
382
+ }
383
+ for (const [, settle] of pending) {
384
+ settle(null);
385
+ }
386
+ pending.clear();
387
+ }
388
+
389
+ // LAZY (re)spawn behind a single-flight guard that RE-RUNS THE FULL PR2 GATE
390
+ // (re-verify signature + anchor + pin + revocation + capabilities + window).
391
+ async function ensureWorker() {
392
+ if (worker || closed) {
393
+ return;
394
+ }
395
+ if (respawning) {
396
+ return respawning;
397
+ }
398
+ respawning = (async () => {
399
+ const loaded = loadAndVerify();
400
+ spawnFromVerified(loaded);
401
+ })();
402
+ try {
403
+ await respawning;
404
+ } finally {
405
+ respawning = null;
406
+ }
407
+ }
408
+
409
+ // One serialized worker round-trip. Resolves to the parsed reply, null (crash /
410
+ // spawn failure), or { __timeout: true }. Runs alone — single-occupancy.
411
+ async function roundTrip(credential) {
412
+ await ensureWorker();
413
+ if (!worker) {
414
+ return null; // spawn failed → fail closed
415
+ }
416
+ const cid = randomUUID();
417
+ const message = JSON.stringify({ cid, credential });
418
+ if (Buffer.byteLength(message, "utf8") > maxMessageBytes) {
419
+ return { __oversized: true };
420
+ }
421
+ return new Promise((resolve) => {
422
+ let done = false;
423
+ const settle = (value) => {
424
+ if (done) return;
425
+ done = true;
426
+ clearTimeout(timer);
427
+ resolve(value);
428
+ };
429
+ const timer = setTimeout(() => {
430
+ pending.delete(cid);
431
+ // Timeout → terminate the worker (audited), deny. Respawn lazily.
432
+ terminateWorker("timeout");
433
+ settle({ __timeout: true });
434
+ }, timeoutMs);
435
+ pending.set(cid, settle);
436
+ try {
437
+ worker.postMessage(message);
438
+ } catch {
439
+ pending.delete(cid);
440
+ settle(null); // worker already dead → fail closed
441
+ }
442
+ });
443
+ }
444
+
445
+ // The sandboxed provider as the conformance harness / proxy see it. It proxies
446
+ // authenticate() into the worker, then the HOST sanitizes + builds the identity.
447
+ // NEVER throws into the caller (catch-all → null).
448
+ async function authenticate(request) {
449
+ try {
450
+ const credential = bearerCredentialFromRequest(request);
451
+ if (credential === null) {
452
+ return null; // missing credential → deny (no worker round-trip needed)
453
+ }
454
+
455
+ // Pending cap: bound concurrency so a burst can never queue unbounded.
456
+ if (queueDepth >= maxPendingCalls) {
457
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "over-capacity" });
458
+ return null;
459
+ }
460
+
461
+ // Serialize: single-occupancy worker. Each call waits its turn; distinct
462
+ // cids guarantee replies never cross even though calls are queued.
463
+ queueDepth += 1;
464
+ const myTurn = chain;
465
+ let release;
466
+ chain = new Promise((r) => { release = r; });
467
+ let reply;
468
+ try {
469
+ await myTurn;
470
+ reply = await roundTrip(credential);
471
+ } finally {
472
+ queueDepth -= 1;
473
+ release();
474
+ }
475
+
476
+ if (reply && reply.__oversized) {
477
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "oversized" });
478
+ return null;
479
+ }
480
+ if (!reply || reply.__timeout) {
481
+ if (reply && reply.__timeout) {
482
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "timeout" });
483
+ }
484
+ return null;
485
+ }
486
+ if (reply.deny === true || reply.claims === undefined) {
487
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "deny" });
488
+ return null;
489
+ }
490
+
491
+ let claims;
492
+ try {
493
+ claims = sanitizeClaims(reply.claims);
494
+ } catch {
495
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
496
+ return null;
497
+ }
498
+
499
+ // The HOST builds the keyed-HMAC identity. The key NEVER crossed to the
500
+ // worker; PII-safety is (re-)enforced here on every call.
501
+ try {
502
+ return await buildExternalIdentity({
503
+ provider: `plugin:${pluginId}`,
504
+ subject: claims.subject,
505
+ issuer: claims.issuer,
506
+ type: claims.type ?? "user",
507
+ scopes: claims.scopes ?? [],
508
+ labels: claims.labels ?? {},
509
+ ...(allowedLabelKeys ? { allowedLabelKeys } : {})
510
+ }, cryptoProvider);
511
+ } catch {
512
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
513
+ return null;
514
+ }
515
+ } catch {
516
+ // Catch-all: authenticate NEVER throws into the caller.
517
+ return null;
518
+ }
519
+ }
520
+
521
+ async function close() {
522
+ closed = true;
523
+ const terminated = worker;
524
+ worker = null;
525
+ pending.clear();
526
+ if (terminated) {
527
+ try { await terminated.terminate(); } catch { /* already gone */ }
528
+ }
529
+ }
530
+
531
+ // ---- construct: synchronous load+verify+spawn (PR2 gate throws here), then a
532
+ // one-time async conformance gate. The sync gate runs eagerly so a refused load
533
+ // throws at construction; conformance runs through the SAME worker wire.
534
+
535
+ const initial = loadAndVerify();
536
+ spawnFromVerified(initial);
537
+
538
+ const provider = { id: `plugin:${initial.pluginId}`, authenticate, close };
539
+
540
+ // The conformance run, executed once. Emits load.accepted on pass; on fail it
541
+ // emits load.refused{conformance-failed}, closes the worker, and rejects.
542
+ const conformance = assertAuthProviderConformance(provider, { now: nowFn() })
543
+ .then((result) => {
544
+ if (!result.ok) {
545
+ audit({
546
+ type: "plugin.load.refused",
547
+ decision: "plugin.load.refused",
548
+ reason: "conformance-failed",
549
+ pluginId: initial.pluginId,
550
+ signerKeyId: initial.signerKeyId
551
+ });
552
+ return close().then(() => {
553
+ throw new Error(`plugin conformance failed: ${result.failures.join("; ")}`);
554
+ });
555
+ }
556
+ audit({
557
+ type: "plugin.load.accepted",
558
+ decision: "plugin.load.accepted",
559
+ pluginId: initial.pluginId,
560
+ version: initial.verified.version,
561
+ entrySha256: initial.entrySha256,
562
+ signerKeyId: initial.signerKeyId,
563
+ capabilitiesGranted: Object.entries(initial.verified.capabilities)
564
+ .filter(([, v]) => v === true)
565
+ .map(([k]) => k)
566
+ });
567
+ return provider;
568
+ });
569
+
570
+ // ready resolves when conformance passes / rejects when it fails — the runtime
571
+ // (sync) path awaits this lazily; direct callers await the returned promise.
572
+ provider.ready = conformance;
573
+ return { provider, conformance, pluginId: initial.pluginId, entrySha256: initial.entrySha256, signerKeyId: initial.signerKeyId };
574
+ }
575
+
576
+ // Async factory: resolves to the live provider AFTER conformance passes, rejects
577
+ // on ANY load failure (PR2 gate or conformance). Direct (test) callers await this.
578
+ export async function createSandboxedAuthProvider(options) {
579
+ const { conformance } = createSandboxedAuthProviderHandle(options);
580
+ return conformance;
581
+ }
582
+
583
+ // Synchronous factory for the runtime composition root: the PR2 gate runs eagerly
584
+ // (so a refused load throws at createRuntime time), and conformance is gated
585
+ // lazily behind provider.ready — authenticate() awaits readiness and fails closed
586
+ // (null) if conformance rejected. Returns the host-side authProvider immediately.
587
+ export function createSandboxedAuthProviderSync(options) {
588
+ const { provider, conformance } = createSandboxedAuthProviderHandle(options);
589
+ // Gate readiness on conformance WITHOUT mutating provider.authenticate — the
590
+ // conformance run itself calls provider.authenticate, so wrapping that method
591
+ // in place would make conformance await itself (deadlock). Return a NEW object
592
+ // whose authenticate awaits readiness then delegates to the (untouched) raw
593
+ // provider.authenticate.
594
+ const ready = conformance.then(() => true, () => false);
595
+ return {
596
+ id: provider.id,
597
+ async authenticate(request) {
598
+ if (!(await ready)) {
599
+ return null; // conformance failed → permanently fail closed
600
+ }
601
+ return provider.authenticate(request);
602
+ },
603
+ close() {
604
+ return provider.close();
605
+ },
606
+ ready
607
+ };
608
+ }