@botbuddy/cli 1.64.33 → 1.65.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.
@@ -10,7 +10,7 @@ export const BINDING_KEYS_HELP = ` Registered per tenant in the BotBuddy contro
10
10
  pool_id, environment "staging", app_slug, adapter "supabase-gotrue-totp",
11
11
  app_origin, supabase_url, publishable_key (public), storage_key,
12
12
  production_origins, password_env_file, launchd_label, docs,
13
- personas { <alias>: { email, password_env, default_count 0-4 } }.
13
+ personas { <alias>: { email, password_env, default_count 0-4, sign_in? } }.
14
14
  No secret is registered: each persona's password stays in password_env_file
15
15
  (0600) on this Mac. Install refuses a production, http or loopback origin
16
16
  whatever is registered. No repository checkout is read.`;
@@ -69,7 +69,7 @@ IF IT REFUSES
69
69
  export const STAGING_POOL_HELP = `bb staging-pool — the human-installed job that keeps signed-in staging sessions ready for agents.
70
70
 
71
71
  A launchd job on this Mac signs each allowlisted seeded persona in to the
72
- tenant's staging app (password + TOTP via BotBuddy get_totp_code) every
72
+ tenant's staging app (password + TOTP, or Okta SSO via sign_in) every
73
73
  5 minutes and keeps a few single-use sessions ready. Agents never sign in: they
74
74
  take a session with bb pw --environment staging <lane> auth --persona <alias>
75
75
  (bb pw auth --help).
@@ -110,8 +110,8 @@ HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Cod
110
110
  tick [--pool <pool_id>] [--home <pool home>]
111
111
  One refresh and refill pass now (what the job runs).
112
112
  e.g. bb staging-pool tick
113
- mint-one --persona <alias>
114
- One real sign-in into the pool, to prove a repair worked.
113
+ mint-one --persona <alias> [--retry-held]
114
+ One real sign-in, to prove a repair worked.
115
115
  e.g. bb staging-pool mint-one --persona staging-admin
116
116
  uninstall [--keep-sessions]
117
117
  Stops the job and deletes its sessions (keeps them with --keep-sessions).
@@ -0,0 +1,120 @@
1
+ // BOT-3145 (ported from supplyspark-ent PR #3534, ENT-5553): the real Okta Identity Engine
2
+ // sign-in steps the staging session pool drives for an `okta-saml` persona
3
+ // (signin-okta-saml.mjs). Node-stdlib-only and self-contained: `page` is any Playwright Page
4
+ // (duck-typed: locator().first(), url()).
5
+ //
6
+ // Markup: Okta Sign-In Widget 7.50.0 on integrator-4707698.okta.com (IDX pipeline),
7
+ // captured read-only on 2026-10-14 (fixtures/2026-10-14-okta-sso-signin.json). The same
8
+ // selectors drive Supply Guard's daily real-Okta CI lane (e2e/sso/okta-login-driver.ts,
9
+ // green every day through 2026-11-10). Recapture the fixture when the widget version changes.
10
+
11
+ export const OKTA_SELECTORS = Object.freeze({
12
+ identifier: 'input[name="identifier"], input[name="username"], input#okta-signin-username',
13
+ password: 'input[type="password"], input[name="credentials.passcode"]',
14
+ submit: 'input[type="submit"], button[type="submit"]',
15
+ // The widget's form-level error callout (e.g. a rejected password).
16
+ formError: '[data-se="o-form-error-container"] .o-form-error-container, [data-se="o-form-error-container"], .okta-form-infobox-error',
17
+ heading: '[data-se="o-form-head"]',
18
+ // A challenge or enrolment for a factor other than the password (Okta Verify, email, phone,
19
+ // a TOTP code): the app's Okta policy is password-only 1FA, so any of these is a misconfig.
20
+ authenticatorList: '[data-se="authenticator-verify-list"], [data-se="authenticator-enroll-list"], .authenticator-verify-list, .authenticator-enroll-list',
21
+ });
22
+
23
+ /** Best-effort resilient driver for the real Okta Identity Engine sign-in form.
24
+ * Handles both the single-page and the two-step (identifier → password) layouts.
25
+ * Deliberately tolerant: a real external IdP's markup is outside our control, so
26
+ * the assertion that the login actually worked is the minted staging session. */
27
+ export async function loginToOkta(page, username, password, expectedHost, { onPasswordSubmit, beforePasswordFill } = {}) {
28
+ // Fail closed before ever entering the credential: only ever type it on the EXACT
29
+ // registered Okta tenant host, so a misconfigured/compromised provider that 302s to a
30
+ // look-alike (e.g. okta.com.attacker.example) cannot harvest it.
31
+ const assertOnOktaHost = (stage) => {
32
+ let host = "";
33
+ try {
34
+ const url = new URL(page.url());
35
+ // BOT-3145: https only, so the password never goes into a plaintext page on the right host.
36
+ host = url.protocol === "https:" ? url.hostname : `${url.protocol}//${url.hostname}`;
37
+ } catch {
38
+ /* handled below */
39
+ }
40
+ if (host !== expectedHost) {
41
+ throw new Error(
42
+ `refusing to enter Okta credentials at ${stage}: page host ${host || "(unparseable)"} is not the registered Okta host ${expectedHost}.`,
43
+ );
44
+ }
45
+ };
46
+
47
+ const identifier = page.locator(OKTA_SELECTORS.identifier).first();
48
+ await identifier.waitFor({ state: "visible", timeout: 45_000 });
49
+ assertOnOktaHost("identifier");
50
+ await identifier.fill(username);
51
+
52
+ const passwordField = page.locator(OKTA_SELECTORS.password).first();
53
+ if (!(await passwordField.isVisible().catch(() => false))) {
54
+ // Two-step layout: advance past the identifier page.
55
+ await page.locator(OKTA_SELECTORS.submit).first().click();
56
+ await passwordField.waitFor({ state: "visible", timeout: 45_000 });
57
+ }
58
+ assertOnOktaHost("password");
59
+ // BOT-3145: the caller persists a pending credential hold here, BEFORE the password is typed, so a
60
+ // crash after the submit can never lose it; a failure to persist aborts with nothing typed.
61
+ if (beforePasswordFill) {
62
+ await beforePasswordFill();
63
+ assertOnOktaHost("password");
64
+ }
65
+ await passwordField.fill(password);
66
+ // From here the password counts as submitted, even if the click itself throws: the pool
67
+ // never re-submits it within a mint (ENT-5553 HF-01).
68
+ onPasswordSubmit?.();
69
+ await page.locator(OKTA_SELECTORS.submit).first().click();
70
+ }
71
+
72
+ async function visibleText(page, selector) {
73
+ const locator = page.locator(selector).first();
74
+ if (!(await locator.isVisible().catch(() => false))) return null;
75
+ const text = await locator.innerText().catch(() => "");
76
+ return String(text ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
77
+ }
78
+
79
+ /** A read-only, value-free snapshot of what the browser shows after the password submit.
80
+ * Never reads input values; text is the widget's own heading / error copy. */
81
+ export async function readOktaPageState(page) {
82
+ let host = "";
83
+ let path = "";
84
+ let protocol = "";
85
+ try {
86
+ const url = new URL(page.url());
87
+ host = url.hostname;
88
+ path = url.pathname;
89
+ protocol = url.protocol;
90
+ } catch {
91
+ /* unparseable: classified as unknown */
92
+ }
93
+ const [passwordVisible, identifierVisible, authenticatorListVisible, errorText, headingText] = await Promise.all([
94
+ page.locator(OKTA_SELECTORS.password).first().isVisible().catch(() => false),
95
+ page.locator(OKTA_SELECTORS.identifier).first().isVisible().catch(() => false),
96
+ page.locator(OKTA_SELECTORS.authenticatorList).first().isVisible().catch(() => false),
97
+ visibleText(page, OKTA_SELECTORS.formError),
98
+ visibleText(page, OKTA_SELECTORS.heading),
99
+ ]);
100
+ return { host, path, protocol, passwordVisible, identifierVisible, authenticatorListVisible, errorText, headingText };
101
+ }
102
+
103
+ const LOCKED_OUT = /locked|suspended|deactivated/i;
104
+ const MFA_HEADING = /security method|okta verify|verify with (?!your password)|enter (a|the|your) code|set up|enroll|authenticator|push notification|phone|email/i;
105
+
106
+ /** Pure classification of a page state on the Okta host after the password submit.
107
+ * Returns one of: "pending" (still working — keep waiting), "password_rejected",
108
+ * "account_locked", "mfa_prompt", "off_okta" (not on the Okta host). */
109
+ export function classifyOktaPageState(state, { expectedHost }) {
110
+ if (!state || state.host !== expectedHost) return "off_okta";
111
+ if (state.errorText && LOCKED_OUT.test(state.errorText)) return "account_locked";
112
+ // A factor challenge reuses the passcode input name, so the heading decides first: the
113
+ // password step's own heading ("Verify with your password") never matches.
114
+ if (state.authenticatorListVisible) return "mfa_prompt";
115
+ if (state.headingText && MFA_HEADING.test(state.headingText)) return "mfa_prompt";
116
+ // Any form-level error while the password step is still on screen is a rejected
117
+ // credential (the widget words it "Password is incorrect" / "Unable to sign in").
118
+ if (state.errorText && (state.passwordVisible || state.identifierVisible)) return "password_rejected";
119
+ return "pending";
120
+ }
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { execFileSync } from "node:child_process";
12
12
 
13
+ import { TOTP_SIGN_IN } from "./sign-in-kinds.mjs";
14
+
13
15
  export class MintRefusedError extends Error {
14
16
  constructor(code, message) {
15
17
  super(message);
@@ -75,6 +77,8 @@ export function stagingTargetFromBinding(binding) {
75
77
  appSlug: binding.appSlug,
76
78
  tenant: binding.tenant,
77
79
  personaEmails: Object.freeze(Object.values(binding.personas).map((persona) => persona.email)),
80
+ // BOT-3145: each persona's sign-in kind, from the binding only (state-file claim checks).
81
+ personaSignIns: Object.freeze(Object.fromEntries(Object.values(binding.personas).map((persona) => [persona.email, persona.signIn ?? TOTP_SIGN_IN]))),
78
82
  loopback: false,
79
83
  });
80
84
  }
@@ -88,12 +88,98 @@ export function createSessionPool({
88
88
  const lastTickPath = join(poolDir, "last-tick.json");
89
89
  const healthPath = join(poolDir, "health.json");
90
90
 
91
- async function readHealth() {
91
+ // strict (tick, mint-one): only a missing file reads as empty. Anything else would silently drop a
92
+ // credential hold (BOT-3145), so it fails closed with health_unreadable. status stays lenient.
93
+ // Strict only for a pool that serves an okta-saml persona (review round 2, P3-2): a TOTP-only
94
+ // pool keeps its old self-healing rewrite. status marks an unreadable file so --check names it.
95
+ const holdsCredentials = Object.values(target.personaSignIns ?? {}).some((spec) => spec?.kind === "okta-saml");
96
+ async function readHealth({ strict = false } = {}) {
92
97
  try {
93
98
  const health = await readJson(healthPath);
94
99
  return { lastSuccessfulTickAt: health.lastSuccessfulTickAt ?? null, personas: health.personas ?? {} };
100
+ } catch (error) {
101
+ if (error?.code === "ENOENT") return { lastSuccessfulTickAt: null, personas: {} };
102
+ if (strict && holdsCredentials) {
103
+ throw Object.assign(new Error(`${healthPath} is unreadable (${error?.code ?? error?.name ?? "error"}); a credential hold may be in it.`), { code: "health_unreadable" });
104
+ }
105
+ return { lastSuccessfulTickAt: null, personas: {}, ...(holdsCredentials ? { unreadable: true } : {}) };
106
+ }
107
+ }
108
+
109
+ async function writeHealth(health) {
110
+ await replaceFile(healthPath, `${JSON.stringify(health, null, 2)}\n`);
111
+ }
112
+
113
+ // Review round 2, P2-1: the pending hold is on disk BEFORE the password is typed, so a crash,
114
+ // reboot or uninstall mid-sign-in can never lose it. Cleared below when the outcome proves the
115
+ // password was accepted or never submitted.
116
+ function holdPersister(health, entry) {
117
+ return async (holdKey) => {
118
+ entry.credentialHold = holdKey;
119
+ entry.credentialHoldPending = true;
120
+ await writeHealth(health);
121
+ };
122
+ }
123
+
124
+ // A pending hold is released only when the adapter vouched the outcome is not a refusal
125
+ // (credentialChecked). Anything else — a crash, an unclassified error — keeps it (fail closed).
126
+ function recordMintFailureHold(entry, error) {
127
+ if (typeof error?.holdKey === "string" && error.holdKey) entry.credentialHold = error.holdKey;
128
+ else if (entry.credentialHoldPending && error?.credentialChecked === true) entry.credentialHold = null;
129
+ delete entry.credentialHoldPending;
130
+ }
131
+
132
+ function healthEntry(health, persona) {
133
+ const entry = (health.personas[persona] ??= {});
134
+ entry.belowTargetTicks ??= 0;
135
+ entry.consecutiveMintFailures ??= 0;
136
+ entry.backoffMintFailures ??= 0;
137
+ entry.nextMintAttemptAt ??= null;
138
+ entry.lastMintError ??= null;
139
+ return entry;
140
+ }
141
+
142
+ // BOT-3145: true while the persona's held credential is still the one it would sign in with (or
143
+ // that cannot be told); a changed credential clears the hold.
144
+ async function credentialStillHeld(entry, persona) {
145
+ if (!entry.credentialHold) return false;
146
+ const holdKey = await currentHoldKey(persona);
147
+ // "<key id>:<fingerprint>": a replaced hold key (another key id) keeps the hold (round 2, P3-3).
148
+ const keyId = (value) => String(value).split(":")[0];
149
+ if (holdKey !== null && keyId(holdKey) === keyId(entry.credentialHold) && holdKey !== entry.credentialHold) {
150
+ entry.credentialHold = null;
151
+ logger({ event: "credential_hold_cleared", persona });
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+
157
+ function recordMintSuccess(entry) {
158
+ entry.consecutiveMintFailures = 0;
159
+ entry.backoffMintFailures = 0;
160
+ entry.nextMintAttemptAt = null;
161
+ entry.lastMintError = null;
162
+ entry.credentialHold = null;
163
+ delete entry.credentialHoldPending;
164
+ }
165
+
166
+ // BOT-3145: status shows only that a persona's credential is held, never its fingerprint.
167
+ function publicHealth(health) {
168
+ const personas = {};
169
+ for (const [persona, entry] of Object.entries(health.personas)) {
170
+ const { credentialHold, credentialHoldPending: _pending, ...rest } = entry ?? {};
171
+ personas[persona] = credentialHold ? { ...rest, credentialHeld: true } : rest;
172
+ }
173
+ return { ...health, personas };
174
+ }
175
+
176
+ // BOT-3145: the fingerprint of the credential this persona would sign in with now, or null.
177
+ async function currentHoldKey(persona) {
178
+ if (typeof signIn.credentialHoldKey !== "function") return null;
179
+ try {
180
+ return (await signIn.credentialHoldKey(persona)) ?? null;
95
181
  } catch {
96
- return { lastSuccessfulTickAt: null, personas: {} };
182
+ return null;
97
183
  }
98
184
  }
99
185
 
@@ -315,11 +401,11 @@ export function createSessionPool({
315
401
  }
316
402
 
317
403
  // Caller holds the tick lock.
318
- async function mintIntoReady(persona) {
404
+ async function mintIntoReady(persona, { persistHold } = {}) {
319
405
  const mintedAt = new Date(now()).toISOString();
320
406
  const slotId = `${personaKey(persona)}~${now()}~${randomId()}`;
321
407
  const workDir = join(dirs.work, slotId);
322
- const storedSession = await signIn.mint({ target, persona });
408
+ const storedSession = await signIn.mint({ target, persona, ...(persistHold ? { persistHold } : {}) });
323
409
  await ensureDir(workDir);
324
410
  try {
325
411
  const meta = buildMeta({ persona, slotId, storedSession, mintedAt, refreshCount: 0 });
@@ -477,20 +563,22 @@ export function createSessionPool({
477
563
  }
478
564
 
479
565
  const counts = await countReady(plan.map(([persona]) => persona));
480
- const health = await readHealth();
566
+ const health = await readHealth({ strict: true });
481
567
  summary.backoffSkips = 0;
568
+ summary.heldSkips = 0;
482
569
  for (const [persona, want] of plan) {
483
- const entry = (health.personas[persona] ??= {
484
- belowTargetTicks: 0,
485
- consecutiveMintFailures: 0,
486
- backoffMintFailures: 0,
487
- nextMintAttemptAt: null,
488
- lastMintError: null,
489
- });
570
+ const entry = healthEntry(health, persona);
490
571
  // Every refill sign-in's MFA verify deletes the persona's AAL1 sessions on
491
572
  // staging (ENT-4994), so repeated failures back off instead of retrying
492
573
  // every tick.
493
- if (counts[persona] < want && entry.nextMintAttemptAt && Date.parse(entry.nextMintAttemptAt) > now()) {
574
+ // BOT-3145: a persona whose last mint refused (or could not confirm) its IdP password is
575
+ // held until that password changes: no sign-in may submit it again (Okta lockout).
576
+ // Re-checked every tick, full or not, so a fixed password clears a stale hold (P2-c).
577
+ const held = await credentialStillHeld(entry, persona);
578
+ if (held && counts[persona] < want) {
579
+ summary.heldSkips += 1;
580
+ logger({ event: "mint_held_skip", persona, lastMintError: entry.lastMintError });
581
+ } else if (counts[persona] < want && entry.nextMintAttemptAt && Date.parse(entry.nextMintAttemptAt) > now()) {
494
582
  summary.backoffSkips += 1;
495
583
  logger({
496
584
  event: "mint_backoff_skip",
@@ -503,16 +591,14 @@ export function createSessionPool({
503
591
  while (counts[persona] < want) {
504
592
  summary.signIns += 1;
505
593
  try {
506
- await mintIntoReady(persona);
594
+ await mintIntoReady(persona, { persistHold: holdPersister(health, entry) });
507
595
  counts[persona] += 1;
508
- entry.consecutiveMintFailures = 0;
509
- entry.backoffMintFailures = 0;
510
- entry.nextMintAttemptAt = null;
511
- entry.lastMintError = null;
596
+ recordMintSuccess(entry);
512
597
  } catch (error) {
513
598
  summary.mintFailures += 1;
514
599
  entry.consecutiveMintFailures += 1;
515
600
  entry.lastMintError = error?.code ?? "error";
601
+ recordMintFailureHold(entry, error);
516
602
  // BOT-2952: an outage that never got a password grant created no AAL1 session and
517
603
  // ran no MFA verify, so it retries on the next tick and does not lengthen a later
518
604
  // backoff. A 429 is Auth asking us to slow down, so it backs off like any other
@@ -544,10 +630,15 @@ export function createSessionPool({
544
630
  }
545
631
  // A persona the config no longer serves (removed or 0) drops its backoff and streaks, so
546
632
  // re-enabling it after a repair mints on the next tick (Codex R13).
633
+ // BOT-3145: a credential hold outlives the persona being switched off, so re-enabling it never
634
+ // re-submits a refused password.
547
635
  for (const persona of Object.keys(health.personas)) {
548
- if (!(wanted.get(persona) > 0)) delete health.personas[persona];
636
+ if (wanted.get(persona) > 0) continue;
637
+ const hold = health.personas[persona]?.credentialHold;
638
+ if (hold) health.personas[persona] = { credentialHold: hold };
639
+ else delete health.personas[persona];
549
640
  }
550
- if (summary.mintFailures === 0 && summary.backoffSkips === 0) health.lastSuccessfulTickAt = summary.at;
641
+ if (summary.mintFailures === 0 && summary.backoffSkips === 0 && summary.heldSkips === 0) health.lastSuccessfulTickAt = summary.at;
551
642
  await replaceFile(healthPath, `${JSON.stringify(health, null, 2)}\n`);
552
643
  summary.ready = counts;
553
644
  logger({ event: "tick", ...summary });
@@ -556,10 +647,29 @@ export function createSessionPool({
556
647
  });
557
648
  }
558
649
 
559
- async function mintOne({ persona }) {
650
+ // BOT-3145: mint-one honours and records credential holds like a tick. retryHeld is the human's
651
+ // deliberate single re-submission of a held credential (after unlocking or checking the IdP user).
652
+ async function mintOne({ persona, retryHeld = false }) {
560
653
  assertPersonaAllowed(target, persona);
561
654
  const result = await withTickLock(async () => {
562
- const minted = await mintIntoReady(persona);
655
+ const health = await readHealth({ strict: true });
656
+ const entry = healthEntry(health, persona);
657
+ if (!retryHeld && (await credentialStillHeld(entry, persona))) {
658
+ await writeHealth(health);
659
+ throw Object.assign(new Error(`${persona} is held: its IdP refused, or never confirmed, the configured password.`), { code: "credential_held" });
660
+ }
661
+ let minted;
662
+ try {
663
+ minted = await mintIntoReady(persona, { persistHold: holdPersister(health, entry) });
664
+ } catch (error) {
665
+ entry.consecutiveMintFailures += 1;
666
+ entry.lastMintError = error?.code ?? "error";
667
+ recordMintFailureHold(entry, error);
668
+ await writeHealth(health);
669
+ throw error;
670
+ }
671
+ recordMintSuccess(entry);
672
+ await writeHealth(health);
563
673
  logger({ event: "mint_one", persona, slotId: minted.slotId, signIns: 1 });
564
674
  return minted;
565
675
  });
@@ -650,6 +760,8 @@ export function createSessionPool({
650
760
  slotId,
651
761
  persona: meta.persona,
652
762
  aal: meta.aal,
763
+ // BOT-3145: the auth methods (e.g. totp, sso/saml) — metadata, never session material.
764
+ amr: Array.isArray(meta.amr) ? meta.amr : [],
653
765
  sessionRef: meta.sessionRef,
654
766
  ageSeconds: Math.round((now() - Date.parse(meta.mintedAt)) / 1000),
655
767
  verifiedAgeSeconds: Math.round((now() - Date.parse(meta.verifiedAt)) / 1000),
@@ -676,7 +788,7 @@ export function createSessionPool({
676
788
  return {
677
789
  poolDir,
678
790
  target: target.name,
679
- health: await readHealth(),
791
+ health: publicHealth(await readHealth()),
680
792
  ready: await collect(dirs.ready),
681
793
  taken: (await list(dirs.taken)).length,
682
794
  work: (await list(dirs.work)).length,
@@ -0,0 +1,87 @@
1
+ // BOT-3145: how a pool persona signs in, and which session claims prove it.
2
+ //
3
+ // A persona with no `sign_in` block signs in with password + TOTP (BOT-2084) and must
4
+ // hold an aal2 session. A persona with `sign_in: { kind: "okta-saml", ... }` signs in
5
+ // through its tenant's Okta IdP over SAML (signin-okta-saml.mjs) and must hold a
6
+ // session whose amr carries `sso/saml` (GoTrue issues it at aal1: the IdP owns the
7
+ // second factor). The kind comes only from the tenant's authenticated binding, so
8
+ // nothing an agent passes can turn a TOTP persona into an SSO one.
9
+ //
10
+ // Node-stdlib-only and self-contained (the installed job copies this directory).
11
+
12
+ export const SIGN_IN_KINDS = Object.freeze({ TOTP: "gotrue-totp", OKTA_SAML: "okta-saml" });
13
+
14
+ const SIGN_IN_KEYS = new Set(["kind", "sso_domain", "idp_host", "admission_function", "admission_route"]);
15
+ // A bare DNS hostname: labels of [a-z0-9-], at least one dot, no port, path or scheme.
16
+ const HOSTNAME_RE = /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
17
+ // An Edge Function slug on the binding's own supabase_url, never a URL.
18
+ const FUNCTION_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
19
+ // The app path the admission function must answer with for an admitted member (e.g. "/dashboard").
20
+ const ROUTE_RE = /^\/[A-Za-z0-9/_-]{0,100}$/;
21
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
22
+
23
+ export const TOTP_SIGN_IN = Object.freeze({ kind: SIGN_IN_KINDS.TOTP });
24
+
25
+ // raw: a persona's `sign_in` value from the binding (undefined → TOTP). Returns the
26
+ // frozen camelCase spec, or a { field, message } problem. Shared by the CLI's binding
27
+ // parser; the control-plane registry (stagingSessionPoolRegistry.ts) mirrors these rules, and
28
+ // both test suites carry the same refusal cases.
29
+ export function parseSignIn(raw) {
30
+ if (raw === undefined) return { ok: true, signIn: TOTP_SIGN_IN };
31
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return problem("sign_in", "sign_in must be an object");
32
+ for (const key of Object.keys(raw)) {
33
+ if (!SIGN_IN_KEYS.has(key)) return problem("sign_in", `unknown sign_in key ${JSON.stringify(key.slice(0, 40))}`);
34
+ }
35
+ if (raw.kind !== SIGN_IN_KINDS.OKTA_SAML) return problem("sign_in.kind", `sign_in.kind must be "${SIGN_IN_KINDS.OKTA_SAML}" (omit sign_in for password + TOTP)`);
36
+ if (!isHostname(raw.sso_domain)) return problem("sign_in.sso_domain", "sign_in.sso_domain must be the SSO provider's email domain");
37
+ if (!isHostname(raw.idp_host) || LOOPBACK_HOSTS.has(String(raw.idp_host).toLowerCase())) {
38
+ return problem("sign_in.idp_host", "sign_in.idp_host must be the IdP's bare public hostname (credentials are typed only there)");
39
+ }
40
+ if (raw.admission_function !== undefined && (typeof raw.admission_function !== "string" || !FUNCTION_SLUG_RE.test(raw.admission_function))) {
41
+ return problem("sign_in.admission_function", "sign_in.admission_function must be an Edge Function slug");
42
+ }
43
+ if (raw.admission_route !== undefined && (raw.admission_function === undefined || typeof raw.admission_route !== "string" || !ROUTE_RE.test(raw.admission_route))) {
44
+ return problem("sign_in.admission_route", "sign_in.admission_route must be an app path, and needs admission_function");
45
+ }
46
+ return {
47
+ ok: true,
48
+ signIn: Object.freeze({
49
+ kind: SIGN_IN_KINDS.OKTA_SAML,
50
+ ssoDomain: raw.sso_domain.toLowerCase(),
51
+ idpHost: raw.idp_host.toLowerCase(),
52
+ admissionFunction: raw.admission_function ?? null,
53
+ admissionRoute: raw.admission_route ?? null,
54
+ }),
55
+ };
56
+ }
57
+
58
+ function isHostname(value) {
59
+ return typeof value === "string" && HOSTNAME_RE.test(value);
60
+ }
61
+
62
+ function problem(field, message) {
63
+ return { ok: false, field, message };
64
+ }
65
+
66
+ export function amrMethods(claims) {
67
+ return Array.isArray(claims?.amr) ? claims.amr.map((entry) => entry?.method ?? String(entry)) : [];
68
+ }
69
+
70
+ // Why a session's claims are wrong for a persona of this sign-in kind, or null.
71
+ // TOTP: aal2. okta-saml: amr carries sso/saml at aal1 or aal2. Email always matches.
72
+ export function sessionClaimsProblem(claims, { email, signIn = TOTP_SIGN_IN } = {}) {
73
+ if (String(claims?.email ?? "").toLowerCase() !== String(email ?? "").toLowerCase()) return "persona_mismatch";
74
+ if (signIn.kind === SIGN_IN_KINDS.OKTA_SAML) {
75
+ if (!amrMethods(claims).includes("sso/saml")) return "not_sso_session";
76
+ if (claims.aal !== "aal1" && claims.aal !== "aal2") return "aal_unknown";
77
+ return null;
78
+ }
79
+ if (claims?.aal !== "aal2") return "aal_downgraded";
80
+ return null;
81
+ }
82
+
83
+ // The take side's requirement for a persona, in a form the lane daemon can check
84
+ // without importing this module (pw/staging-auth.mjs installCode).
85
+ export function requiredAuthFor(signIn = TOTP_SIGN_IN) {
86
+ return signIn.kind === SIGN_IN_KINDS.OKTA_SAML ? Object.freeze({ amr: "sso/saml" }) : Object.freeze({ aal: "aal2" });
87
+ }
@@ -0,0 +1,31 @@
1
+ // BOT-3145: routes each pool persona to its sign-in adapter by the binding's `sign_in` kind
2
+ // (sign-in-kinds.mjs): password + TOTP (signin-gotrue-totp.mjs) or Okta SAML
3
+ // (signin-okta-saml.mjs). The pool stays kind-agnostic; it sees one { mint, refresh,
4
+ // credentialHoldKey } recipe. Node-stdlib-only and self-contained.
5
+
6
+ import { SIGN_IN_KINDS, TOTP_SIGN_IN } from "./sign-in-kinds.mjs";
7
+ import { SignInError } from "./signin-gotrue-totp.mjs";
8
+
9
+ export function signInLookup(binding) {
10
+ const byEmail = new Map(Object.values(binding?.personas ?? {}).map((persona) => [persona.email, persona.signIn ?? TOTP_SIGN_IN]));
11
+ return (persona) => byEmail.get(persona) ?? TOTP_SIGN_IN;
12
+ }
13
+
14
+ export function createBindingSignIn({ binding, totp, okta }) {
15
+ if (!totp) throw new Error("createBindingSignIn needs the TOTP adapter.");
16
+ const signInFor = signInLookup(binding);
17
+ const adapterFor = (persona) => {
18
+ if (signInFor(persona).kind !== SIGN_IN_KINDS.OKTA_SAML) return totp;
19
+ if (!okta) throw new SignInError("okta_adapter_unavailable", `${persona} signs in through Okta, and this job has no Okta adapter.`);
20
+ return okta;
21
+ };
22
+ return {
23
+ mint: async (args) => adapterFor(args.persona).mint(args),
24
+ refresh: async (args) => adapterFor(args.persona).refresh(args),
25
+ // A fingerprint of the credential a held persona would sign in with; null for kinds with no hold.
26
+ async credentialHoldKey(persona) {
27
+ const adapter = adapterFor(persona);
28
+ return typeof adapter.credentialHoldKey === "function" ? adapter.credentialHoldKey(persona) : null;
29
+ },
30
+ };
31
+ }