cortad 0.3.11 → 0.4.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/lib/keeping.mjs DELETED
@@ -1,181 +0,0 @@
1
- // What one session keeps out of the run's way and brings up for it: the app's database files and
2
- // servers, copied before it starts (lib/data.mjs, lib/stores.mjs), and the stores and services it
3
- // reaches, started when nothing answers there (lib/backing.mjs). Every sentence goes to the terminal
4
- // and to the run: the copies made through lines(), and each store with none through blocked().
5
- import { createHash } from "node:crypto";
6
- import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
7
- import { keepData } from "./data.mjs";
8
- import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedHeld } from "./stores.mjs";
9
-
10
- // A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
11
- const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
12
- // A store at a network address; a socket path, or no host at all, is Postgres's own socket.
13
- const addressed = (s) => Boolean(s.host) && !s.host.startsWith("/") && Boolean(s.port);
14
- // One nothing answers at is left for later: trials cannot write into it.
15
- const isDown = async (s) => addressed(s) && !(await tcpOpen(s.host, s.port));
16
- // The stores the settings name on this machine, as ports to bring up; a compose image may serve them.
17
- const localStores = (k) => k.named.filter((s) => addressed(s) && onThisMachine(s.host)).map((s) => ({ port: s.port, names: s.names, store: true }));
18
- // The API keeps eight stores that hold Run. Past that, the rest travel as one row, and a yes to that
19
- // row is a yes to each of them; a row more changes its id, so an old yes no longer covers it.
20
- const BLOCKED_MAX = 8;
21
- const overflow = (blocked) => blocked.slice(BLOCKED_MAX - 1);
22
- // How a service on this machine that answers nothing is named to the API: by its port, never an address.
23
- const downId = (port) => createHash("sha256").update(`down:${port}`).digest("hex").slice(0, 16);
24
- const overflowId = (rest) => createHash("sha256").update(rest.map((b) => b.id).join()).digest("hex").slice(0, 16);
25
- function blockedRows(blocked) {
26
- const rows = blocked.map(({ port: _port, ...b }) => ({ ...b, why: b.why.slice(0, 400) }));
27
- if (rows.length <= BLOCKED_MAX) return rows;
28
- const rest = overflow(rows);
29
- const names = [...new Set(rest.flatMap((b) => b.names))].slice(0, 8);
30
- return [...rows.slice(0, BLOCKED_MAX - 1), { id: overflowId(rest), names, why: `These ${rest.length} stores hold Run too. ${rest[0].why}`.slice(0, 400) }];
31
- }
32
-
33
- // settings(): the environment the app starts with, the env file each name came from, the app's
34
- // folder and its source files. launch(plan): a service of the repository started the way the app
35
- // is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
36
- // the caller has the app on the copies made, since until then it still writes into the originals.
37
- // `typed`: the person gave the start command. It is run as it is, with the settings and the stores
38
- // that command gives the app: a legal research API typed ENV=staging and was started on copies of
39
- // the test databases its default settings file names, where its library is empty.
40
- // `chosen`: the store settings of the file the person chose for the run (--data). Those stores are
41
- // the ones copied, and the app is handed all of those settings, so it reaches the server they name.
42
- const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
43
- export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false, chosen = {} }) {
44
- let pending = null;
45
- let now = null;
46
- let backing = null;
47
- let backedUp = false;
48
- let seen = 0;
49
- const tell = (lines) => { for (const line of lines) say(lower(line)); };
50
- const held = (blocked) => tell(blocked.map((b) => `One question for the person on the card in the browser: ${lower(b.why)}`));
51
-
52
- async function keepAll(started) {
53
- const { values, from, appDir, sources } = settings();
54
- // Which stores a typed command gives the app is its own: only those chosen for the run are copied,
55
- // and each other store it reaches holds Run once the hook sees it (watch). A store it reaches for
56
- // and finds nothing at is still brought up: a session that ended stopped the ones it had started,
57
- // and the app started next with its own command met "connection refused".
58
- const disk = typed ? { env: {}, said: Object.keys(chosen).length ? [] : [AS_TYPED], held: [], values: chosen } : keepData({ values, from, appDir, root, work, sources, started });
59
- const stores = storesOf(disk.values);
60
- const keeper = storeKeeper({ ledgerFile, values: disk.values, dirs: [...new Set([appDir, root])] });
61
- const got = await keeper.keep(stores, { started, down: isDown });
62
- backing ??= makeBacking({ root, appDir, values: typed ? values : disk.values, onPath, ledgerFile, launch, adopt: started });
63
- now = { env: { ...chosen, ...disk.env, ...got.env }, notes: [...disk.said, ...got.copies], blocked: [...disk.held, ...got.blocked], pass: got.pass, stores, named: stores, keeper, values: disk.values, started };
64
- tell(now.notes);
65
- held(now.blocked);
66
- return now;
67
- }
68
- // A read that failed is tried again on the next ask, never handed back as the same failure.
69
- const once = (started) => (pending ??= keepAll(started).catch((e) => { pending = null; throw e; }));
70
- // The store ids the person's yes covers: a yes to the row the rest travel as is a yes to each.
71
- const yesOf = (stores) => {
72
- const yes = new Set(stores);
73
- const rest = now && now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
74
- if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
75
- return yes;
76
- };
77
-
78
- // Brings up what is wanted, copies each store that now answers, and says both.
79
- async function bringUp(wants) {
80
- const k = await once(true);
81
- hold();
82
- const up = await backing.up(wants);
83
- const got = await k.keeper.keep(k.stores, { started: k.started, down: isDown });
84
- Object.assign(k.env, got.env);
85
- // Said once: every look brings the same lines back, and status printed one hold four times.
86
- k.notes.push(...[...up.lines, ...got.copies].filter((l) => !k.notes.includes(l)));
87
- // A service a setting names that answers nothing holds Run until it does; one that answers now
88
- // lets go. ai-appointment-setter's only free run played with its knowledge base down.
89
- const checked = new Set(wants.map((w) => w.port));
90
- const stillDown = new Set(up.down.map((d) => d.port));
91
- for (let i = k.blocked.length - 1; i >= 0; i--) if (k.blocked[i].down && checked.has(k.blocked[i].port) && !stillDown.has(k.blocked[i].port)) k.blocked.splice(i, 1);
92
- for (const d of up.down) if (!k.blocked.some((b) => b.down && b.port === d.port)) k.blocked.push({ id: downId(d.port), names: d.names, why: d.why, down: true, port: d.port });
93
- k.blocked.push(...got.blocked.filter((b) => !k.blocked.some((x) => x.id === b.id)));
94
- k.pass.push(...got.pass);
95
- const said = [...up.lines, ...got.copies];
96
- tell(said);
97
- held(got.blocked);
98
- return { changed: said.length > 0 || got.blocked.length > 0, came: up.ports.length > 0, copied: Object.keys(got.env).length > 0 };
99
- }
100
- // Ports on this machine the hook saw the app reach. A compose image serves only a store's port: a
101
- // model server behind a compose file is gigabytes nobody asked for.
102
- const reached = (appPort) => connections().filter((c) => onThisMachine(c.host) && c.port !== appPort).map((c) => ({ port: c.port, store: isStorePort(c.port) }));
103
-
104
- return {
105
- // The settings the app starts with: the copies it runs against.
106
- env: async (started) => (await once(started)).env,
107
- // The copies so far, for a service of the repository started beside the app.
108
- envNow: () => now?.env ?? {},
109
- // The copies trials write into; the API keeps eight sentences of 400 characters.
110
- lines: async (started) => (await once(started)).notes.map((l) => l.slice(0, 400)).slice(0, 8),
111
- // The stores no copy could be made of: each holds Run until the person says yes.
112
- blocked: async (started) => blockedRows((await once(started)).blocked),
113
- // The hosts a trial's writes may reach: the copies made off this machine, and the hosts and
114
- // stores the person said yes to (setup.consent). Before the copies are made, or after the app
115
- // was attached to instead, only the hosts.
116
- outbound: ({ hosts = [], stores = [] } = {}) => {
117
- if (!now) return [...new Set(hosts)];
118
- const yes = yesOf(stores);
119
- return [...new Set([...hosts, ...now.pass, ...now.stores.filter((s) => yes.has(storeId(s)) && !onThisMachine(s.host)).map((s) => String(s.host).toLowerCase())])];
120
- },
121
- // The stores with no copy the person has not said yes to writing into. A service that is down
122
- // holds Run too, but nothing can be written into it.
123
- holding: ({ stores = [] } = {}) => {
124
- const yes = yesOf(stores);
125
- return now?.blocked.filter((b) => !b.down && !yes.has(b.id)) ?? [];
126
- },
127
- // The app stopped before it answered: each store its settings name and each port it reached that
128
- // is down is brought up, once. True when the app is worth starting again.
129
- async backUp(appPort) {
130
- if (backedUp) return false;
131
- backedUp = true;
132
- const k = await once(true);
133
- const got = await bringUp([...localStores(k), ...reached(appPort)]);
134
- return got.came || got.copied;
135
- },
136
- // The app answers: each store and service its settings name on this machine that nothing
137
- // answers at is brought up, since an app that connects on first use starts without them.
138
- // `copied` says the app has copies it was not started on.
139
- async services(appPort) {
140
- const k = await once(true);
141
- return bringUp([...localStores(k), ...localAddresses(k.values)].filter((a) => a.port !== appPort));
142
- },
143
- // What the app reached since the last look. A live store no setting names is said, what is down
144
- // is brought up, and `copied` says the app has copies it was not started on.
145
- async watch(appPort) {
146
- const conns = connections();
147
- // A service that held Run and answers now lets go without a reconnect: the person started
148
- // Supabase, and the hold stood until they disconnected and connected again.
149
- const heldDown = now?.blocked.filter((b) => b.down) ?? [];
150
- const cameUp = [];
151
- for (const b of heldDown) if (await tcpOpen("127.0.0.1", b.port)) cameUp.push({ port: b.port, names: b.names });
152
- if (cameUp.length) { const got = await bringUp(cameUp); if (!pending || conns.length === seen) return { changed: true, copied: got.copied }; }
153
- if (!pending || conns.length === seen) return { changed: false, copied: false };
154
- seen = conns.length;
155
- const k = await pending;
156
- const live = [];
157
- for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
158
- const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
159
- k.blocked.push(...bypassed);
160
- held(bypassed);
161
- const got = await bringUp(reached(appPort));
162
- return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
163
- },
164
- // An app attached to instead of started keeps its own connections: its copies are of no use.
165
- async forget() {
166
- const k = await pending;
167
- pending = null;
168
- now = null;
169
- await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
170
- await k?.keeper.drop();
171
- },
172
- // Once the app has stopped: a copy is dropped when nothing holds it open, and a store is stopped
173
- // only after its copy is gone.
174
- async close() {
175
- const k = await pending?.catch(() => null);
176
- await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
177
- await k?.keeper.drop().catch(() => {});
178
- await backing?.stop().catch(() => {});
179
- },
180
- };
181
- }
package/lib/ledger.mjs DELETED
@@ -1,20 +0,0 @@
1
- // What this command made on the person's machine and has not yet undone: a database copied, a
2
- // service started. Kept in the project's folder under ~/.cortad, so a session that died (a killed
3
- // terminal, a closed lid) is undone by the next one. Rows name what was made, never an address or
4
- // a password.
5
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname } from "node:path";
7
-
8
- const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code !== "ESRCH"; } };
9
-
10
- export function ledger(file) {
11
- const read = () => { try { const rows = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(rows) ? rows : []; } catch { return []; } };
12
- const write = (rows) => { try { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(rows), { mode: 0o600 }); } catch { /* undone by hand then */ } };
13
- const same = (a, b) => a.kind === b.kind && a.key === b.key && a.made === b.made;
14
- return {
15
- add: (row) => write([...read().filter((r) => !same(r, row)), { ...row, pid: process.pid }]),
16
- remove: (row) => write(read().filter((r) => !same(r, row))),
17
- // Rows of `kind` whose session has ended.
18
- orphans: (kind) => read().filter((r) => r.kind === kind && !alive(r.pid)),
19
- };
20
- }
package/lib/listing.mjs DELETED
@@ -1,12 +0,0 @@
1
- // Where a provider key is asked for its model listing: the base URL their app is pointed at, when
2
- // their env sets one beside the key (OPENAI_BASE_URL beside OPENAI_API_KEY), else the provider's own
3
- // host. A Fireworks key under OPENAI_API_KEY is refused by api.openai.com and works where it is sent.
4
- export function listingUrl(name, canonical, values) {
5
- const prefix = name.replace(/_API_KEY$/, "");
6
- const base = values[`${prefix}_BASE_URL`] || values[`${prefix}_API_BASE`];
7
- if (!base) return canonical;
8
- try {
9
- const u = new URL(base);
10
- return /^https?:$/.test(u.protocol) ? `${base.replace(/\/+$/, "")}/models` : canonical;
11
- } catch { return canonical; }
12
- }
package/lib/mint.mjs DELETED
@@ -1,350 +0,0 @@
1
- // Signing in as the app's own callers, on the machine that holds its secrets. The cloud sends
2
- // recipes: names of env variables, routes and body fields, never a value and never code. They are
3
- // gated again here, the values are opened here, and what the app's own door issues is kept in a
4
- // folder only this user can read and wiped when the command exits. A request that should speak as
5
- // a role arrives carrying a marker; the real header is put in its place here, so neither a secret
6
- // nor a token ever leaves this machine.
7
- import { execFile } from "node:child_process";
8
- import { createHmac, createSign, randomBytes } from "node:crypto";
9
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { basename, dirname, join } from "node:path";
12
- import { promisify } from "node:util";
13
- import { envCredentialRecipes, identityScript, parseIdentityReport } from "./identities.mjs";
14
-
15
- const exec = promisify(execFile);
16
- export const AS_HEADER = "x-cortad-as";
17
- const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
18
- const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
19
- const TEST_UID = "brainsless-test-user";
20
-
21
- // The account each role signs in as, the same on every run from this folder: derived from a key that
22
- // never leaves this machine, so no server holds a password to any of their accounts.
23
- export function accountsFor(root, roles, keyFile = join(homedir(), ".cortad", "identity.key")) {
24
- if (!existsSync(keyFile)) { mkdirSync(dirname(keyFile), { recursive: true, mode: 0o700 }); writeFileSync(keyFile, randomBytes(32), { mode: 0o600 }); }
25
- const key = readFileSync(keyFile);
26
- const mac = (what) => createHmac("sha256", key).update(`${what}\0${root}`).digest("base64url");
27
- return Object.fromEntries(roles.filter((role) => ROLE.test(role)).map((role) => {
28
- const username = `cortad-${slug(role)}-${mac(`name\0${role}`).replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase()}`;
29
- return [role, { username, email: `${username}@example.invalid`, password: `${mac(`pw\0${role}`).slice(0, 24)}A1!`, name: `Cortad test ${role}` }];
30
- }));
31
- }
32
-
33
- // Who this machine signs in as, whatever a recipe asks: an account made for the session, or a test
34
- // id that belongs to nobody. Never a person who already has an account, never an admin, and never a
35
- // key the app does not already hand to every browser. The recipes come from the cloud; these rules
36
- // are here, beside the secrets, so a recipe cannot talk its way past them.
37
- const ELEVATED = /admin|owner|super|root/i;
38
- const PUBLIC_NAME = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|NUXT_PUBLIC_|PUBLIC_|GATSBY_)/;
39
- const ACCOUNT_FIELD = /^(e-?mail|user(_?name)?|login|phone|account|identifier)$/i;
40
- const ID_CLAIM = /^(sub|id|uid|user_?id|userid)$/i;
41
-
42
- // Whether a value is already in the code the app ships: then every browser holds it, and sending it
43
- // as the caller's key tells the app nothing a visitor could not.
44
- function inSource(root, files, value) {
45
- for (const rel of files) {
46
- try { if (readFileSync(join(root, rel), "utf8").includes(value)) return true; } catch { /* gone */ }
47
- }
48
- return false;
49
- }
50
-
51
- export function refusal(recipe, env, shipped) {
52
- if ([recipe?.role, ...(recipe?.roles ?? [])].some((r) => ELEVATED.test(String(r ?? "")))) return "an admin role; only an ordinary test account is signed in";
53
- if (recipe?.kind === "jwt" && recipe.claim && !ID_CLAIM.test(recipe.claim)) return `a token field (${recipe.claim}) other than the user's id`;
54
- if (recipe?.kind === "header") {
55
- const names = recipe.env ?? [];
56
- const value = names.map((n) => env[n]).find(Boolean);
57
- if (value && !names.some((n) => PUBLIC_NAME.test(n)) && !shipped(value)) return `${names.join(" or ")} is a private key, never sent as a caller's own`;
58
- }
59
- for (const step of [...(recipe?.steps ?? []), ...(recipe?.alt ?? [])])
60
- for (const [field, spec] of Object.entries(step?.body ?? {}))
61
- if (ACCOUNT_FIELD.test(field) && String(spec).startsWith("env:")) return "an existing account from your environment; only a new test account is signed in";
62
- return null;
63
- }
64
-
65
- export function readEnv(file) {
66
- const out = {};
67
- let text = "";
68
- try { text = readFileSync(file, "utf8"); } catch { return out; }
69
- for (const line of text.split("\n")) {
70
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
71
- if (m) out[m[1]] = m[2].trim().replace(/^(['"])([\s\S]*)\1$/, "$2");
72
- }
73
- return out;
74
- }
75
-
76
- // The admin credential the app itself runs with, under the names projects really use.
77
- function serviceAccountOf(env) {
78
- const parse = (s) => { try { const j = JSON.parse(s); return j?.private_key && j?.client_email ? j : null; } catch { return null; } };
79
- for (const [name, value] of Object.entries(env)) {
80
- if (!/SERVICE_ACCOUNT|FIREBASE_ADMIN|GOOGLE_CREDENTIALS/.test(name) || !value) continue;
81
- const found = parse(value) ?? parse(Buffer.from(value, "base64").toString("utf8"));
82
- if (found) return found;
83
- }
84
- if (env.GOOGLE_APPLICATION_CREDENTIALS) { try { const found = parse(readFileSync(env.GOOGLE_APPLICATION_CREDENTIALS, "utf8")); if (found) return found; } catch { /* not a file here */ } }
85
- return env.FIREBASE_PRIVATE_KEY && env.FIREBASE_CLIENT_EMAIL ? { private_key: env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), client_email: env.FIREBASE_CLIENT_EMAIL } : null;
86
- }
87
- const webApiKeyOf = (envs) => envs.flatMap((env) => Object.entries(env)).find(([name, value]) => /FIREBASE\w*API_KEY$/.test(name) && /^AIza[\w-]{20,}$/.test(value))?.[1] ?? null;
88
-
89
- // A custom token signed with their own admin credential, exchanged at their own project for the ID
90
- // token their app verifies. The first exchange creates the test user in that project, by that uid.
91
- async function firebaseIdToken(account, apiKey) {
92
- const now = Math.floor(Date.now() / 1000);
93
- const part = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
94
- const unsigned = `${part({ alg: "RS256", typ: "JWT" })}.${part({ iss: account.client_email, sub: account.client_email, aud: "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit", iat: now, exp: now + 3600, uid: TEST_UID })}`;
95
- const token = `${unsigned}.${createSign("RSA-SHA256").update(unsigned).sign(account.private_key).toString("base64url")}`;
96
- const res = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`, {
97
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, returnSecureToken: true }), signal: AbortSignal.timeout(20_000),
98
- });
99
- const out = await res.json().catch(() => null);
100
- return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
101
- }
102
-
103
- const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
104
- // A server key goes only over TLS, or to a service on this machine (a local Supabase).
105
- function serviceOrigin(value) {
106
- try { const u = new URL(value); return u.protocol === "https:" || (u.protocol === "http:" && LOOPBACK.has(u.hostname)) ? u.origin : null; } catch { return null; }
107
- }
108
- async function send(url, { method = "POST", headers = {}, body } = {}) {
109
- const res = await fetch(url, { method, headers: { "content-type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
110
- return { ok: res.ok, status: res.status, json: await res.json().catch(() => null) };
111
- }
112
- const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? r.json?.error?.message ?? `status ${r.status}`).slice(0, 120);
113
- const refusedBy = (who, r) => ({ status: "refused", note: `${who} refused the test account: ${saidBy(r)}` });
114
- // An account the service already held may have been given a role since this machine made it.
115
- const ELEVATED_ACCOUNT = { status: "refused", note: "the test account carries an admin role; only an ordinary account is signed in" };
116
- const elevated = (...claims) => claims.some((c) => c && ELEVATED.test(JSON.stringify(c)));
117
-
118
- // Clerk's Backend API, in their development instance only: the account, a session for it, and the
119
- // session's token, which lives a minute and is minted again from the same session before it lapses.
120
- export async function clerkToken(env, account, session) {
121
- const key = env.CLERK_SECRET_KEY;
122
- if (!key) return { status: "absent", note: "no CLERK_SECRET_KEY in your environment" };
123
- if (!key.startsWith("sk_test_")) return { status: "refused", note: "your CLERK_SECRET_KEY is a production key; test accounts are made only in a development instance" };
124
- const origin = serviceOrigin(env.CLERK_API_URL || "https://api.clerk.com");
125
- if (!origin) return { status: "refused", note: "CLERK_API_URL is not an https address" };
126
- const api = `${origin}/v1`;
127
- const auth = { authorization: `Bearer ${key}` };
128
- const tokenOf = async (id) => (await send(`${api}/sessions/${encodeURIComponent(id)}/tokens`, { headers: auth })).json?.jwt;
129
- const again = session && await tokenOf(session);
130
- if (again) return { token: again, session };
131
- // Clerk sends nothing to a +clerk_test address; it exists for exactly this.
132
- const email = `${account.username}+clerk_test@example.com`;
133
- const found = await send(`${api}/users?email_address=${encodeURIComponent(email)}`, { method: "GET", headers: auth });
134
- let user = Array.isArray(found.json) ? found.json[0] : null;
135
- if (!user) {
136
- // No password: a session is made through this API, and an instance that signs in only by email
137
- // code or OAuth refuses a user created with one.
138
- const made = await send(`${api}/users`, { headers: auth, body: { email_address: [email], skip_password_requirement: true } });
139
- if (!made.ok || !made.json?.id) return refusedBy("your Clerk instance", made);
140
- user = made.json;
141
- }
142
- if (elevated(user.public_metadata, user.private_metadata)) return ELEVATED_ACCOUNT;
143
- const opened = await send(`${api}/sessions`, { headers: auth, body: { user_id: user.id } });
144
- if (!opened.ok || !opened.json?.id) return refusedBy("your Clerk instance", opened);
145
- const token = await tokenOf(opened.json.id);
146
- return token ? { token, session: opened.json.id } : { status: "refused", note: "your Clerk instance issued no session token" };
147
- }
148
-
149
- const SUPABASE_URL = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_URL$/;
150
- const SUPABASE_SERVICE = /^SUPABASE_(?:SERVICE_ROLE|SERVICE|SECRET)_KEY$/;
151
- const SUPABASE_PUBLIC = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_(?:ANON|PUBLISHABLE)(?:_DEFAULT)?_KEY$/;
152
- const named = (env, pattern) => Object.entries(env).find(([name, value]) => pattern.test(name) && value)?.[1] ?? null;
153
-
154
- // Supabase Auth's admin API makes the account already confirmed, with the service key their server
155
- // holds; a password sign-in then issues the session their own client would hold.
156
- export async function supabaseSession(env, account) {
157
- const url = named(env, SUPABASE_URL);
158
- const service = named(env, SUPABASE_SERVICE);
159
- if (!url || !service) return { status: "absent", note: `no ${url ? "SUPABASE_SERVICE_ROLE_KEY" : "SUPABASE_URL"} in your environment` };
160
- const origin = serviceOrigin(url);
161
- if (!origin) return { status: "refused", note: "SUPABASE_URL is not an https address" };
162
- const made = await send(`${origin}/auth/v1/admin/users`, { headers: { apikey: service, authorization: `Bearer ${service}` }, body: { email: account.email, password: account.password, email_confirm: true } });
163
- if (!made.ok && !/already|exists|registered/i.test(saidBy(made))) return refusedBy("your Supabase project", made);
164
- const got = await send(`${origin}/auth/v1/token?grant_type=password`, { headers: { apikey: named(env, SUPABASE_PUBLIC) ?? service }, body: { email: account.email, password: account.password } });
165
- if (!got.ok || typeof got.json?.access_token !== "string") return refusedBy("your Supabase project", got);
166
- if (elevated(got.json.user?.app_metadata, got.json.user?.role)) return ELEVATED_ACCOUNT;
167
- return { session: got.json, origin };
168
- }
169
-
170
- // @supabase/ssr's own cookie: the session as base64url JSON under sb-<ref>-auth-token, cut into
171
- // numbered chunks past 3180 characters the way the library cuts it.
172
- export function supabaseCookie(origin, session) {
173
- const name = `sb-${new URL(origin).hostname.split(".")[0]}-auth-token`;
174
- const value = `base64-${Buffer.from(JSON.stringify(session)).toString("base64url")}`;
175
- if (value.length <= 3180) return `${name}=${value}`;
176
- return value.match(/.{1,3180}/g).map((part, i) => `${name}.${i}=${part}`).join("; ");
177
- }
178
-
179
- // The Auth emulator their app verifies against while FIREBASE_AUTH_EMULATOR_HOST is set: it signs up any
180
- // address under any key. The account is this machine's own test account, made once and signed in after.
181
- export async function emulatorToken(host, account) {
182
- // An emulator runs beside the app on this machine, and the test account's password goes to it in
183
- // plain http: never to another host.
184
- if (!/^(?:localhost|127\.0\.0\.1|\[::1\]):\d{2,5}$/.test(host)) return { status: "refused", note: "FIREBASE_AUTH_EMULATOR_HOST does not name an emulator on this machine" };
185
- const at = (verb) => `http://${host}/identitytoolkit.googleapis.com/v1/accounts:${verb}?key=cortad-test`;
186
- const body = { email: account.email, password: account.password, returnSecureToken: true };
187
- let got = await send(at("signUp"), { body });
188
- if (!got.ok && /EMAIL_EXISTS/.test(saidBy(got))) got = await send(at("signInWithPassword"), { body });
189
- if (!got.ok || typeof got.json?.idToken !== "string") return refusedBy("your Firebase Auth emulator", got);
190
- return { idToken: got.json.idToken, ttl: Number(got.json.expiresIn) || 3600 };
191
- }
192
-
193
- // Each hosted service's sign-in, as the value the role's header carries and how long it holds.
194
- const SIGN_IN = new Map([
195
- ["firebase", async ({ env, all, account }) => {
196
- if (env.FIREBASE_AUTH_EMULATOR_HOST) {
197
- const got = await emulatorToken(env.FIREBASE_AUTH_EMULATOR_HOST, account);
198
- return got.idToken ? { value: `Bearer ${got.idToken}`, ttl: got.ttl, door: "your Firebase Auth emulator", said: "made a test account in your Firebase Auth emulator for the run to sign in with" } : got;
199
- }
200
- const admin = serviceAccountOf(env);
201
- const apiKey = webApiKeyOf(all);
202
- if (!admin || !apiKey) return { status: "absent", note: !admin ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
203
- const got = await firebaseIdToken(admin, apiKey);
204
- if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
205
- return { value: `Bearer ${got.idToken}`, ttl: 3600, door: "your Firebase project", said: `signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)` };
206
- }],
207
- ["clerk", async ({ env, account, session }) => {
208
- const got = await clerkToken(env, account, session);
209
- return got.token ? { value: `Bearer ${got.token}`, ttl: 60, session: got.session, door: "your Clerk development instance", said: "made a test account in your Clerk development instance for the run to sign in with" } : got;
210
- }],
211
- ["supabase-admin", async ({ env, account, header }) => {
212
- const got = await supabaseSession(env, account);
213
- if (!got.session) return got;
214
- const s = got.session;
215
- return {
216
- value: header === "cookie" ? supabaseCookie(got.origin, s) : `Bearer ${s.access_token}`, secrets: [s.access_token, s.refresh_token],
217
- ttl: Number(s.expires_in) || 3600, door: "your Supabase project", said: "made a test account in your Supabase project for the run to sign in with",
218
- };
219
- }],
220
- ]);
221
- // Renewed this long after a sign-in: Clerk's token lives 60 s, Supabase's and Firebase's an hour.
222
- const RENEW_MS = { firebase: 50 * 60_000, clerk: 50_000, "supabase-admin": 55 * 60_000 };
223
- const PER_PERSON = new Set(["clerk", "supabase-admin"]);
224
- // A simulated person's own caller: the run sends `clerk:p1a2b3c4` for the person behind a trial.
225
- const PERSONA = /^([a-z][a-z0-9_-]{0,30}):p[0-9a-f]{8}$/;
226
-
227
- export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], say, keepSecret, appDir = () => root }) {
228
- const dir = join(work, "identities");
229
- const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
230
- const hosted = new Map();
231
- // The roles signed in on this machine so far, in the order they were: the connect command's own
232
- // test requests ask as the first when an endpoint refuses a visitor.
233
- const minted = new Set();
234
- const envs = () => {
235
- // The app's own folder first: in a monorepo the root's env file is not the backend's.
236
- const homes = [appDir(), root];
237
- const own = homes.flatMap((h) => [envFiles.find((f) => dirname(f) === h && basename(f) === ".env"), envFiles.find((f) => dirname(f) === h)]).find(Boolean) ?? envFiles[0];
238
- return { path: own ?? join(root, ".env"), all: [process.env, ...envFiles.map(readEnv)] };
239
- };
240
- const merged = () => Object.assign({}, ...[...envs().all].reverse());
241
- const keep = (role, header, value, secrets = []) => {
242
- mkdirSync(dir, { recursive: true, mode: 0o700 });
243
- writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 });
244
- for (const secret of [value.replace(/^Bearer\s+/i, ""), ...secrets]) keepSecret(secret);
245
- };
246
-
247
- // A hosted service's sign-in, kept on this machine and renewed through headerFor. Only the
248
- // bearer or the cookie is ever filled, whatever header a recipe names.
249
- async function signIn(row) {
250
- const header = row.header === "cookie" ? "cookie" : "authorization";
251
- const { all } = envs();
252
- const got = await SIGN_IN.get(row.kind)({ env: merged(), all, header, account: accountsFor(root, [row.role])[row.role], session: hosted.get(row.role)?.session })
253
- .catch((e) => ({ status: "unreachable", note: String(e?.message ?? e).slice(0, 120) }));
254
- if (!got.value) return { ...row, status: got.status, note: got.note };
255
- keep(row.role, header, got.value, got.secrets);
256
- if (!hosted.has(row.role) && !PERSONA.test(row.role)) say(got.said);
257
- hosted.set(row.role, { row: { ...row, header }, at: Date.now(), session: got.session });
258
- return { ...row, header, status: "minted", door: got.door, expires: Math.floor(Date.now() / 1000) + got.ttl };
259
- }
260
-
261
- async function mint(body, port) {
262
- let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
263
- // On a refused first pass the raise asks again with this flag: sign in with the credential the
264
- // app's own environment names, filling its sign-in form or a Basic header from that pair.
265
- if (body?.envCreds) recipes = envCredentialRecipes(recipes, Object.keys(merged()), body.loginRoutes).slice(0, 12);
266
- if (!recipes.length || !port) return { identities: [] };
267
- mkdirSync(dir, { recursive: true, mode: 0o700 });
268
- const out = join(dir, "bl-identities.json");
269
- rmSync(out, { force: true });
270
- // ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
271
- // nothing and says so; port the program to this file if that turns out to be common.
272
- // A caller a hosted service verifies is signed in at that service, below, a Firebase one at the
273
- // Auth emulator when the app's settings name one: the program reads one env file and never saw an
274
- // emulator named anywhere else, so a Python app verified by the emulator was knocked unsigned.
275
- const env = merged();
276
- const signsHere = (r) => SIGN_IN.has(r?.kind);
277
- const refused = new Map();
278
- // A pair the environment names for the app's own account is the one credential the raise is
279
- // allowed to reuse: the elevation and existing-account guards do not apply to it.
280
- for (const r of recipes) { const why = r?.envCred ? null : refusal(r, env, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
281
- const forProgram = recipes.filter((r) => !refused.has(r?.role) && !signsHere(r));
282
- // The program signs in once per role value ("staff" as sales, support, marketing), each named
283
- // role:value; an account made for "staff" alone was never the one it asked for.
284
- const accounts = accountsFor(root, forProgram.flatMap((r) => (Array.isArray(r.roles) && r.roles.length ? r.roles.map((v) => `${r.role}:${v}`) : [r.role])));
285
- const script = identityScript(forProgram, { envPath: envs().path, base: `http://127.0.0.1:${port}`, headers: body?.headers ?? {} }, accounts).split("/tmp/bl-identit").join(join(dir, "bl-identit"));
286
- const file = join(dir, "mint.py");
287
- writeFileSync(file, script, { mode: 0o600 });
288
- const ran = forProgram.length ? await exec("python3", [file], { cwd: root, timeout: 115_000, maxBuffer: 1 << 20 }).then(() => true, (e) => e?.code !== "ENOENT") : true;
289
- rmSync(file, { force: true });
290
- const report = parseIdentityReport(existsSync(out) ? readFileSync(out, "utf8") : "");
291
- const known = new Map((report?.identities ?? []).map((r) => [r.role, r]));
292
- const rows = [];
293
- for (const recipe of recipes) {
294
- if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
295
- const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
296
- if (refused.has(recipe.role)) { rows.push({ ...blank, status: "refused", note: refused.get(recipe.role) }); continue; }
297
- if (signsHere(recipe)) { rows.push(await signIn({ ...blank, status: "upstream" })); continue; }
298
- rows.push(known.get(recipe.role) ?? { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
299
- }
300
- for (const row of rows.filter((r) => r.status === "minted")) {
301
- minted.add(row.role);
302
- if (existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
303
- }
304
- return { identities: rows };
305
- }
306
-
307
- // A hosted token renewed before it lapses, and a simulated person's own account signed in the
308
- // first time that person speaks. One sign-in per role at a time: lanes asking together share it.
309
- const renewing = new Map();
310
- const tried = new Set();
311
- const shared = new Set();
312
- async function due(role) {
313
- const held = hosted.get(role);
314
- if (held) { if (Date.now() - held.at > RENEW_MS[held.row.kind]) await signIn(held.row); return; }
315
- const owner = hosted.get(PERSONA.exec(role)?.[1]);
316
- if (!owner || !PER_PERSON.has(owner.row.kind) || tried.has(role)) return;
317
- tried.add(role);
318
- const got = await signIn({ ...owner.row, role });
319
- if (got.status !== "minted" && !shared.has(owner.row.role)) {
320
- shared.add(owner.row.role);
321
- say(`a simulated person's own test account could not be made (${got.note ?? got.status}), so that person signs in as the shared test account`);
322
- }
323
- }
324
- const renewed = (role) => {
325
- if (!renewing.has(role)) renewing.set(role, due(role).catch(() => null).finally(() => renewing.delete(role)));
326
- return renewing.get(role);
327
- };
328
-
329
- // A token the app's door issued, or a credential that is itself one of their secrets (an admin
330
- // key). In the sandbox the second never leaves the box; here it never leaves this machine, which
331
- // is the same promise, because this is where the request is sent from.
332
- function held(role) {
333
- const file = [headerFile(role), headerFile(role).replace(/\.header$/, ".sealed")].find((f) => { try { return statSync(f).isFile(); } catch { return false; } });
334
- if (!file) return null;
335
- const line = readFileSync(file, "utf8").split("\n")[0] ?? "";
336
- const at = line.indexOf(": ");
337
- return at > 0 ? { name: line.slice(0, at).trim().toLowerCase(), value: line.slice(at + 2).trim() } : null;
338
- }
339
-
340
- // The header a role speaks with, or nothing. A person whose own account could not be made speaks
341
- // as the role that person belongs to, the one account every trial used before.
342
- async function headerFor(role) {
343
- if (!ROLE.test(role)) return null;
344
- await renewed(role);
345
- const base = PERSONA.exec(role)?.[1];
346
- return held(role) ?? (base ? held(base) : null);
347
- }
348
-
349
- return { mint, headerFor, roles: () => [...minted] };
350
- }