@botbuddy/cli 1.60.5 → 1.60.7

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,198 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 signin-recipe.mjs): the
2
+ // "supabase-gotrue-totp" sign-in adapter for seeded personas.
3
+ //
4
+ // Node-stdlib-only (global fetch) and self-contained. It performs the same GoTrue calls the staging
5
+ // sign-in form makes — password grant, then the TOTP challenge/verify of the
6
+ // persona's verified factor — and returns the aal2 session in the shape supabase-js
7
+ // persists. It never calls logout (any scope), never enrols/unenrols a factor and
8
+ // never touches app data (design constraint 6).
9
+ //
10
+ // GoTrue deletes a user's other AAL1 sessions on each successful MFA verify
11
+ // (ENT-4994), so a concurrent CI sign-in can revoke this recipe's AAL1 session
12
+ // between the password grant and the verify. That 403 session_not_found restarts
13
+ // the attempt from the password grant.
14
+
15
+ import { assertPersonaAllowed } from "./policy.mjs";
16
+ import { decodeJwtClaims, toStoredSession } from "./state-file.mjs";
17
+
18
+ export class SignInError extends Error {
19
+ constructor(code, message, { transient = false, status } = {}) {
20
+ super(message);
21
+ this.name = "SignInError";
22
+ this.code = code;
23
+ this.transient = transient;
24
+ this.status = status;
25
+ }
26
+ }
27
+
28
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
29
+ const REVOKED_ERROR_CODES = new Set(["session_not_found", "bad_jwt", "refresh_token_not_found"]);
30
+
31
+ function describeFailure(response) {
32
+ const errorCode = response.body?.error_code ?? response.body?.error ?? "unknown";
33
+ return `HTTP ${response.status} ${errorCode}`;
34
+ }
35
+
36
+ // passwordFor({ persona }) resolves the seeded persona's password at mint time
37
+ // (password-source.mjs reads it from the binding's 0600 env file); it is never
38
+ // logged, returned or written anywhere.
39
+ export function createGoTrueTotpSignIn({
40
+ fetchImpl = globalThis.fetch,
41
+ totpProvider,
42
+ passwordFor,
43
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
44
+ nowMs = () => Date.now(),
45
+ logger = () => {},
46
+ maxAttempts = 3,
47
+ totpPeriodSeconds = 30,
48
+ backoffMs = 2_000,
49
+ } = {}) {
50
+ if (typeof totpProvider !== "function") throw new Error("createGoTrueTotpSignIn needs a totpProvider.");
51
+ if (typeof passwordFor !== "function") throw new Error("createGoTrueTotpSignIn needs a passwordFor source.");
52
+
53
+ async function gotrue(target, path, { body, accessToken } = {}) {
54
+ const headers = { apikey: target.publishableKey, "Content-Type": "application/json" };
55
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
56
+ let response;
57
+ try {
58
+ response = await fetchImpl(`${target.supabaseUrl}${path}`, {
59
+ method: "POST",
60
+ headers,
61
+ body: JSON.stringify(body ?? {}),
62
+ });
63
+ } catch (error) {
64
+ return { status: 0, body: null, networkError: error?.message ?? String(error) };
65
+ }
66
+ let parsed = null;
67
+ try {
68
+ parsed = await response.json();
69
+ } catch {
70
+ parsed = null;
71
+ }
72
+ return { status: response.status, body: parsed };
73
+ }
74
+
75
+ const isTransient = (response) => response.status === 0 || RETRYABLE_STATUSES.has(response.status);
76
+ const isRevoked = (response) =>
77
+ (response.status === 401 || response.status === 403) && REVOKED_ERROR_CODES.has(response.body?.error_code);
78
+
79
+ function assertAal2ForPersona(storedSession, persona) {
80
+ const claims = decodeJwtClaims(storedSession.access_token);
81
+ if (claims.aal !== "aal2") throw new SignInError("not_aal2", `Verify returned an ${claims.aal ?? "unknown"} session, not aal2.`);
82
+ if (claims.email !== persona) throw new SignInError("persona_mismatch", "Verify returned a session for a different user.");
83
+ }
84
+
85
+ async function waitForNextTotpWindow() {
86
+ const intoWindowMs = nowMs() % (totpPeriodSeconds * 1000);
87
+ await sleep(totpPeriodSeconds * 1000 - intoWindowMs + 1000);
88
+ }
89
+
90
+ async function mint({ target, persona }) {
91
+ assertPersonaAllowed(target, persona);
92
+ let lastFailure = "no attempt made";
93
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
94
+ const retry = async (reason, { waitWindow = false } = {}) => {
95
+ lastFailure = reason;
96
+ logger({ event: "mint_retry", persona, attempt, reason });
97
+ if (attempt < maxAttempts) {
98
+ if (waitWindow) await waitForNextTotpWindow();
99
+ else await sleep(backoffMs * attempt);
100
+ }
101
+ };
102
+
103
+ const password = await gotrue(target, "/auth/v1/token?grant_type=password", {
104
+ body: { email: persona, password: await passwordFor({ persona }) },
105
+ });
106
+ if (password.status !== 200) {
107
+ if (isTransient(password)) {
108
+ await retry(`password grant ${describeFailure(password)}`);
109
+ continue;
110
+ }
111
+ throw new SignInError("password_rejected", `Password grant refused: ${describeFailure(password)}.`, {
112
+ status: password.status,
113
+ });
114
+ }
115
+ const aal1Token = password.body?.access_token;
116
+ const factor = (password.body?.user?.factors ?? []).find(
117
+ (candidate) => candidate.factor_type === "totp" && candidate.status === "verified",
118
+ );
119
+ if (!factor) {
120
+ throw new SignInError("no_verified_totp_factor", `${persona} has no verified TOTP factor on ${target.name}.`);
121
+ }
122
+
123
+ const challenge = await gotrue(target, `/auth/v1/factors/${factor.id}/challenge`, { accessToken: aal1Token });
124
+ if (challenge.status !== 200) {
125
+ if (isRevoked(challenge)) {
126
+ await retry("aal1 session revoked before challenge");
127
+ continue;
128
+ }
129
+ if (isTransient(challenge)) {
130
+ await retry(`challenge ${describeFailure(challenge)}`);
131
+ continue;
132
+ }
133
+ throw new SignInError("challenge_failed", `MFA challenge refused: ${describeFailure(challenge)}.`, {
134
+ status: challenge.status,
135
+ });
136
+ }
137
+
138
+ const code = await totpProvider({ persona, environment: target.totpEnvironment });
139
+ const verify = await gotrue(target, `/auth/v1/factors/${factor.id}/verify`, {
140
+ accessToken: aal1Token,
141
+ body: { challenge_id: challenge.body?.id, code },
142
+ });
143
+ if (verify.status !== 200) {
144
+ if (isRevoked(verify)) {
145
+ await retry("aal1 session revoked before verify");
146
+ continue;
147
+ }
148
+ if (verify.status === 422 && verify.body?.error_code === "mfa_verification_failed") {
149
+ await retry("totp code rejected", { waitWindow: true });
150
+ continue;
151
+ }
152
+ if (isTransient(verify)) {
153
+ await retry(`verify ${describeFailure(verify)}`);
154
+ continue;
155
+ }
156
+ throw new SignInError("verify_failed", `MFA verify refused: ${describeFailure(verify)}.`, { status: verify.status });
157
+ }
158
+
159
+ let storedSession;
160
+ try {
161
+ storedSession = toStoredSession(verify.body);
162
+ } catch (error) {
163
+ throw new SignInError("verify_response_invalid", error.message);
164
+ }
165
+ assertAal2ForPersona(storedSession, persona);
166
+ logger({ event: "mint_ok", persona, attempt });
167
+ return storedSession;
168
+ }
169
+ throw new SignInError("mint_retries_exhausted", `Mint gave up after ${maxAttempts} attempts (last: ${lastFailure}).`, {
170
+ transient: true,
171
+ });
172
+ }
173
+
174
+ // Liveness for a slot this caller exclusively holds: the refresh grant returns a
175
+ // token minted from the session's CURRENT server-side state, so a deleted session
176
+ // fails and a downgraded one comes back aal1.
177
+ async function refresh({ target, storedSession, persona }) {
178
+ const response = await gotrue(target, "/auth/v1/token?grant_type=refresh_token", {
179
+ body: { refresh_token: storedSession.refresh_token },
180
+ });
181
+ if (response.status !== 200) {
182
+ if (isTransient(response)) return { ok: false, transient: true, reason: describeFailure(response) };
183
+ return { ok: false, transient: false, reason: response.body?.error_code ?? `http_${response.status}` };
184
+ }
185
+ let next;
186
+ try {
187
+ next = toStoredSession(response.body);
188
+ } catch {
189
+ return { ok: false, transient: false, reason: "refresh_response_invalid" };
190
+ }
191
+ const claims = decodeJwtClaims(next.access_token);
192
+ if (claims.aal !== "aal2") return { ok: false, transient: false, reason: "aal_downgraded" };
193
+ if (persona && claims.email !== persona) return { ok: false, transient: false, reason: "persona_mismatch" };
194
+ return { ok: true, storedSession: next };
195
+ }
196
+
197
+ return { mint, refresh };
198
+ }
@@ -0,0 +1,166 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 state-file.mjs): the pool's
2
+ // storage-state contract and its redaction.
3
+ //
4
+ // Node-stdlib-only and self-contained. A pool state file is a Playwright storage state that holds the
5
+ // target app origin and exactly one localStorage key — the Supabase auth session —
6
+ // and nothing else: no cookies, no other origin, no BotBuddy widget token.
7
+
8
+ import { createHash } from "node:crypto";
9
+
10
+ export class StateFileRejectedError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "StateFileRejectedError";
14
+ this.code = "state_file_rejected";
15
+ }
16
+ }
17
+
18
+ const STORED_SESSION_FIELDS = ["access_token", "token_type", "expires_in", "expires_at", "refresh_token", "user"];
19
+
20
+ export function decodeJwtClaims(token) {
21
+ if (typeof token !== "string") throw new Error("JWT is not a string.");
22
+ const parts = token.split(".");
23
+ if (parts.length !== 3) throw new Error("JWT does not have three segments.");
24
+ try {
25
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
26
+ } catch {
27
+ throw new Error("JWT payload is not valid JSON.");
28
+ }
29
+ }
30
+
31
+ // Shape persisted by supabase-js under the auth storage key (projected from a real
32
+ // Supply Guard staging browser state on 2026-10-05; BotBuddy's client uses the same
33
+ // supabase-js default): exactly these six fields.
34
+ export function toStoredSession(response) {
35
+ const accessToken = response?.access_token;
36
+ if (typeof accessToken !== "string" || !accessToken) throw new Error("Session response has no access_token.");
37
+ if (typeof response.refresh_token !== "string" || !response.refresh_token) {
38
+ throw new Error("Session response has no refresh_token.");
39
+ }
40
+ if (!response.user || typeof response.user !== "object") throw new Error("Session response has no user.");
41
+ const claims = decodeJwtClaims(accessToken);
42
+ if (!Number.isFinite(claims.exp)) throw new Error("Access token has no exp claim.");
43
+ return {
44
+ access_token: accessToken,
45
+ token_type: response.token_type ?? "bearer",
46
+ expires_in: Number(response.expires_in ?? claims.exp - Math.floor(Date.now() / 1000)),
47
+ expires_at: claims.exp,
48
+ refresh_token: response.refresh_token,
49
+ user: response.user,
50
+ };
51
+ }
52
+
53
+ export function buildStorageState({ target, storedSession }) {
54
+ return {
55
+ cookies: [],
56
+ origins: [
57
+ {
58
+ origin: target.appOrigin,
59
+ localStorage: [{ name: target.storageKey, value: JSON.stringify(storedSession) }],
60
+ },
61
+ ],
62
+ };
63
+ }
64
+
65
+ export function readStoredSession(state, target) {
66
+ const entry = state?.origins?.[0]?.localStorage?.find((item) => item.name === target.storageKey);
67
+ if (!entry) throw new StateFileRejectedError(`State has no ${target.storageKey} entry.`);
68
+ try {
69
+ return JSON.parse(entry.value);
70
+ } catch {
71
+ throw new StateFileRejectedError("Auth storage entry is not valid JSON.");
72
+ }
73
+ }
74
+
75
+ // Called before every write and after every read. Error messages name keys and
76
+ // origins only, never values.
77
+ export function assertStorageStateClean(state, target) {
78
+ if (!state || typeof state !== "object") throw new StateFileRejectedError("State is not an object.");
79
+ const topLevel = Object.keys(state).sort().join(",");
80
+ if (topLevel !== "cookies,origins") {
81
+ throw new StateFileRejectedError(`State has unexpected top-level keys (${topLevel}).`);
82
+ }
83
+ if (!Array.isArray(state.cookies) || state.cookies.length !== 0) {
84
+ throw new StateFileRejectedError("State must carry no cookies.");
85
+ }
86
+ if (!Array.isArray(state.origins) || state.origins.length !== 1) {
87
+ throw new StateFileRejectedError("State must carry exactly one origin.");
88
+ }
89
+ const [origin] = state.origins;
90
+ if (origin.origin !== target.appOrigin) {
91
+ throw new StateFileRejectedError(`State origin ${origin.origin} is not ${target.appOrigin}.`);
92
+ }
93
+ const keys = (origin.localStorage ?? []).map((item) => item.name);
94
+ if (keys.length !== 1 || keys[0] !== target.storageKey) {
95
+ throw new StateFileRejectedError(`State localStorage keys (${keys.join(",")}) must be exactly ${target.storageKey}.`);
96
+ }
97
+ const session = readStoredSession(state, target);
98
+ const fields = Object.keys(session).sort().join(",");
99
+ if (fields !== [...STORED_SESSION_FIELDS].sort().join(",")) {
100
+ throw new StateFileRejectedError(`Auth session has unexpected fields (${fields}).`);
101
+ }
102
+ let claims;
103
+ try {
104
+ claims = decodeJwtClaims(session.access_token);
105
+ } catch (error) {
106
+ throw new StateFileRejectedError(error.message);
107
+ }
108
+ if (claims.aal !== "aal2") throw new StateFileRejectedError(`Session is ${claims.aal ?? "unknown"}, not aal2.`);
109
+ if (!target.personaEmails?.includes(claims.email)) {
110
+ throw new StateFileRejectedError("Session persona is not an allowlisted seeded persona.");
111
+ }
112
+ return session;
113
+ }
114
+
115
+ export function sessionRef(sessionId) {
116
+ return createHash("sha256").update(String(sessionId)).digest("hex").slice(0, 12);
117
+ }
118
+
119
+ export function describeStoredSession(storedSession, { nowMs = Date.now() } = {}) {
120
+ const claims = decodeJwtClaims(storedSession.access_token);
121
+ return {
122
+ persona: claims.email ?? null,
123
+ aal: claims.aal ?? null,
124
+ amr: Array.isArray(claims.amr) ? claims.amr.map((entry) => entry?.method ?? String(entry)) : [],
125
+ accessExpiresAt: new Date(claims.exp * 1000).toISOString(),
126
+ accessExpiresInSeconds: Math.round(claims.exp - nowMs / 1000),
127
+ sessionRef: claims.session_id ? sessionRef(claims.session_id) : null,
128
+ };
129
+ }
130
+
131
+ const MIN_SECRET_LENGTH = 6;
132
+ const PATTERNS = [
133
+ /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, // JWTs
134
+ /((?<![A-Za-z_])"?(?:access_token|refresh_token|code|password|provider_token)"?\s*[:=]\s*"?)[^",\s}]+/gi,
135
+ /(Bearer\s+)[^\s",]+/gi,
136
+ ];
137
+
138
+ export function createRedactor() {
139
+ const secrets = new Set();
140
+ const redactString = (input) => {
141
+ let text = String(input);
142
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
143
+ text = text.split(secret).join("[REDACTED]");
144
+ }
145
+ text = text.replace(PATTERNS[0], "[REDACTED]");
146
+ text = text.replace(PATTERNS[1], "$1[REDACTED]");
147
+ text = text.replace(PATTERNS[2], "$1[REDACTED]");
148
+ return text;
149
+ };
150
+ const redact = (value) => {
151
+ if (typeof value === "string") return redactString(value);
152
+ if (Array.isArray(value)) return value.map(redact);
153
+ if (value && typeof value === "object") {
154
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]));
155
+ }
156
+ return value;
157
+ };
158
+ return {
159
+ register(secret) {
160
+ const value = String(secret ?? "");
161
+ if (value.length < MIN_SECRET_LENGTH) throw new Error("Refusing to register a redaction secret that is too short.");
162
+ secrets.add(value);
163
+ },
164
+ redact,
165
+ };
166
+ }
@@ -0,0 +1,106 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 botbuddy-totp.mjs): staging TOTP
2
+ // codes come from BotBuddy get_totp_code via the botbuddy CLI, pinned to the
3
+ // binding's tenant and app_slug. Node-stdlib-only and self-contained. The code is returned to the caller and
4
+ // never logged; errors never echo the CLI's stdout.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+
9
+ import { AGENT_CONTEXT_ENV_MARKERS } from "./policy.mjs";
10
+
11
+ export class TotpSourceError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = "TotpSourceError";
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ const MIN_REMAINING_SECONDS = 5;
20
+
21
+ const defaultExec = promisify(execFile);
22
+
23
+ function childEnv(env) {
24
+ const next = { ...env };
25
+ for (const name of AGENT_CONTEXT_ENV_MARKERS) delete next[name];
26
+ return next;
27
+ }
28
+
29
+ async function callBotBuddy({ exec, botbuddyBin, env, tenant, tool, args }) {
30
+ try {
31
+ const { stdout } = await exec(botbuddyBin, ["call", tool, "--pin", tenant, "--json", JSON.stringify(args)], {
32
+ env: childEnv(env),
33
+ timeout: 60_000,
34
+ maxBuffer: 1024 * 1024,
35
+ });
36
+ return stdout;
37
+ } catch (error) {
38
+ if (error?.code === "ENOENT") {
39
+ throw new TotpSourceError("botbuddy_cli_missing", `botbuddy CLI not found at ${botbuddyBin}; install @botbuddy/cli or re-run install.`);
40
+ }
41
+ const stderr = String(error?.stderr ?? "").replace(/\d{6}/g, "[REDACTED]").slice(0, 300);
42
+ throw new TotpSourceError("totp_source_failed", `botbuddy call ${tool} failed (exit ${error?.code ?? "?"}): ${stderr}`);
43
+ }
44
+ }
45
+
46
+ export function createBotBuddyTotpProvider({
47
+ exec = defaultExec,
48
+ botbuddyBin,
49
+ tenant,
50
+ appSlug,
51
+ env = process.env,
52
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
53
+ } = {}) {
54
+ if (!botbuddyBin) throw new TotpSourceError("config_missing", "botbuddyBin (absolute path to the botbuddy CLI) is required.");
55
+ if (!tenant || !appSlug) throw new TotpSourceError("config_missing", "The binding's tenant and app_slug are required.");
56
+
57
+ async function fetchOnce({ persona, environment }) {
58
+ const stdout = await callBotBuddy({
59
+ exec,
60
+ botbuddyBin,
61
+ env,
62
+ tenant,
63
+ tool: "get_totp_code",
64
+ args: { app_slug: appSlug, seed_user_slug: persona, environment },
65
+ });
66
+ let body;
67
+ try {
68
+ body = JSON.parse(stdout);
69
+ } catch {
70
+ throw new TotpSourceError("totp_response_invalid", "get_totp_code returned non-JSON output.");
71
+ }
72
+ if (typeof body?.code !== "string" || !/^\d{6}$/.test(body.code)) {
73
+ throw new TotpSourceError("totp_response_invalid", "get_totp_code returned no 6-digit code.");
74
+ }
75
+ if (body.environment !== environment || body.seed_user_slug !== persona || body.app_slug !== appSlug) {
76
+ throw new TotpSourceError("totp_response_mismatch", "get_totp_code answered for a different app, persona or environment.");
77
+ }
78
+ return body;
79
+ }
80
+
81
+ return async function botbuddyTotp({ persona, environment }) {
82
+ let body = await fetchOnce({ persona, environment });
83
+ if (Number.isFinite(body.remaining_seconds) && body.remaining_seconds < MIN_REMAINING_SECONDS) {
84
+ await sleep((body.remaining_seconds + 1) * 1000);
85
+ body = await fetchOnce({ persona, environment });
86
+ }
87
+ return body.code;
88
+ };
89
+ }
90
+
91
+ // The botbuddy CLI auth boundary probe. Lists seed metadata only; never mints a code.
92
+ export async function probeBotBuddy({ exec = defaultExec, botbuddyBin, env = process.env, tenant, appSlug, environment = "staging", personaEmails = [] } = {}) {
93
+ if (!botbuddyBin) throw new TotpSourceError("config_missing", "botbuddyBin is required.");
94
+ const stdout = await callBotBuddy({ exec, botbuddyBin, env, tenant, tool: "list_totp_seeds", args: { app_slug: appSlug } });
95
+ let seeds;
96
+ try {
97
+ seeds = JSON.parse(stdout);
98
+ } catch {
99
+ throw new TotpSourceError("probe_response_invalid", "list_totp_seeds returned non-JSON output.");
100
+ }
101
+ if (!Array.isArray(seeds)) throw new TotpSourceError("probe_response_invalid", "list_totp_seeds did not return a list.");
102
+ const matching = seeds.filter((seed) => seed.environment === environment && seed.app_slug === appSlug);
103
+ const registered = new Set(matching.map((seed) => seed.seed_user_slug));
104
+ const missing = personaEmails.filter((email) => !registered.has(email));
105
+ return { ok: missing.length === 0, appSlug, environment, seeds: matching.length, missingPersonas: missing };
106
+ }