cortad 0.3.1-rc.9 → 0.3.2

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.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/lib/mint.mjs CHANGED
@@ -100,57 +100,169 @@ async function firebaseIdToken(account, apiKey) {
100
100
  return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
101
101
  }
102
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 ?? `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
+ // Each hosted service's sign-in, as the value the role's header carries and how long it holds.
180
+ const SIGN_IN = new Map([
181
+ ["firebase", async ({ env, all }) => {
182
+ const account = serviceAccountOf(env);
183
+ const apiKey = webApiKeyOf(all);
184
+ if (!account || !apiKey) return { status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
185
+ const got = await firebaseIdToken(account, apiKey);
186
+ if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
187
+ 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)` };
188
+ }],
189
+ ["clerk", async ({ env, account, session }) => {
190
+ const got = await clerkToken(env, account, session);
191
+ 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;
192
+ }],
193
+ ["supabase-admin", async ({ env, account, header }) => {
194
+ const got = await supabaseSession(env, account);
195
+ if (!got.session) return got;
196
+ const s = got.session;
197
+ return {
198
+ value: header === "cookie" ? supabaseCookie(got.origin, s) : `Bearer ${s.access_token}`, secrets: [s.access_token, s.refresh_token],
199
+ 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",
200
+ };
201
+ }],
202
+ ]);
203
+ // Renewed this long after a sign-in: Clerk's token lives 60 s, Supabase's and Firebase's an hour.
204
+ const RENEW_MS = { firebase: 50 * 60_000, clerk: 50_000, "supabase-admin": 55 * 60_000 };
205
+ const PER_PERSON = new Set(["clerk", "supabase-admin"]);
206
+ // A simulated person's own caller: the run sends `clerk:p1a2b3c4` for the person behind a trial.
207
+ const PERSONA = /^([a-z][a-z0-9_-]{0,30}):p[0-9a-f]{8}$/;
208
+
103
209
  export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], say, keepSecret, appDir = () => root }) {
104
210
  const dir = join(work, "identities");
105
211
  const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
106
- const firebaseRoles = new Map();
212
+ const hosted = new Map();
213
+ // The roles signed in on this machine so far, in the order they were: the connect command's own
214
+ // test requests ask as the first when an endpoint refuses a visitor.
215
+ const minted = new Set();
107
216
  const envs = () => {
108
217
  // The app's own folder first: in a monorepo the root's env file is not the backend's.
109
218
  const homes = [appDir(), root];
110
219
  const own = homes.flatMap((h) => [envFiles.find((f) => dirname(f) === h && basename(f) === ".env"), envFiles.find((f) => dirname(f) === h)]).find(Boolean) ?? envFiles[0];
111
220
  return { path: own ?? join(root, ".env"), all: [process.env, ...envFiles.map(readEnv)] };
112
221
  };
113
- const keep = (role, header, value) => { writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 }); keepSecret(value.replace(/^Bearer\s+/i, "")); };
222
+ const merged = () => Object.assign({}, ...[...envs().all].reverse());
223
+ const keep = (role, header, value, secrets = []) => {
224
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
225
+ writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 });
226
+ for (const secret of [value.replace(/^Bearer\s+/i, ""), ...secrets]) keepSecret(secret);
227
+ };
114
228
 
115
- async function firebase(row) {
229
+ // A hosted service's sign-in, kept on this machine and renewed through headerFor. Only the
230
+ // bearer or the cookie is ever filled, whatever header a recipe names.
231
+ async function signIn(row) {
232
+ const header = row.header === "cookie" ? "cookie" : "authorization";
116
233
  const { all } = envs();
117
- const merged = Object.assign({}, ...[...all].reverse());
118
- if (merged.FIREBASE_AUTH_EMULATOR_HOST) return row;
119
- const account = serviceAccountOf(merged);
120
- const apiKey = webApiKeyOf(all);
121
- if (!account || !apiKey) return { ...row, status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
122
- const got = await firebaseIdToken(account, apiKey).catch((e) => ({ error: String(e?.message ?? e).slice(0, 120) }));
123
- if (!got.idToken) return { ...row, status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
124
- keep(row.role, row.header, `Bearer ${got.idToken}`);
125
- if (!firebaseRoles.has(row.role)) say(`signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)`);
126
- firebaseRoles.set(row.role, { header: row.header, at: Date.now() });
127
- return { ...row, status: "minted", door: "your Firebase project", expires: Math.floor(Date.now() / 1000) + 3600 };
234
+ 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 })
235
+ .catch((e) => ({ status: "unreachable", note: String(e?.message ?? e).slice(0, 120) }));
236
+ if (!got.value) return { ...row, status: got.status, note: got.note };
237
+ keep(row.role, header, got.value, got.secrets);
238
+ if (!hosted.has(row.role) && !PERSONA.test(row.role)) say(got.said);
239
+ hosted.set(row.role, { row: { ...row, header }, at: Date.now(), session: got.session });
240
+ return { ...row, header, status: "minted", door: got.door, expires: Math.floor(Date.now() / 1000) + got.ttl };
128
241
  }
129
242
 
130
243
  async function mint(body, port) {
131
244
  let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
132
245
  // On a refused first pass the raise asks again with this flag: sign in with the credential the
133
246
  // app's own environment names, filling its sign-in form or a Basic header from that pair.
134
- if (body?.envCreds) {
135
- const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
136
- recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
137
- }
247
+ if (body?.envCreds) recipes = envCredentialRecipes(recipes, Object.keys(merged()), body.loginRoutes).slice(0, 12);
138
248
  if (!recipes.length || !port) return { identities: [] };
139
249
  mkdirSync(dir, { recursive: true, mode: 0o700 });
140
250
  const out = join(dir, "bl-identities.json");
141
251
  rmSync(out, { force: true });
142
252
  // ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
143
253
  // nothing and says so; port the program to this file if that turns out to be common.
144
- // A Firebase caller with no emulator on this machine is signed in at their real project, below.
145
- // Handed to the program it waited its whole deadline on an emulator that was never there, and
146
- // one app's sign-in arrived after the world had given up waiting for it.
147
- const merged = Object.assign({}, ...[...envs().all].reverse());
148
- const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
254
+ // A caller a hosted service verifies is signed in at that service, below; a Firebase caller goes
255
+ // to the program only when an emulator stands in for their project. Handed to the program it
256
+ // waited its whole deadline on an emulator that was never there, and one app's sign-in arrived
257
+ // after the world had given up waiting for it.
258
+ const env = merged();
259
+ const emulated = Boolean(env.FIREBASE_AUTH_EMULATOR_HOST);
260
+ const signsHere = (r) => SIGN_IN.has(r?.kind) && !(r.kind === "firebase" && emulated);
149
261
  const refused = new Map();
150
262
  // A pair the environment names for the app's own account is the one credential the raise is
151
263
  // allowed to reuse: the elevation and existing-account guards do not apply to it.
152
- for (const r of recipes) { const why = r?.envCred ? null : refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
153
- const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
264
+ 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); }
265
+ const forProgram = recipes.filter((r) => !refused.has(r?.role) && !signsHere(r));
154
266
  // The program signs in once per role value ("staff" as sales, support, marketing), each named
155
267
  // role:value; an account made for "staff" alone was never the one it asked for.
156
268
  const accounts = accountsFor(root, forProgram.flatMap((r) => (Array.isArray(r.roles) && r.roles.length ? r.roles.map((v) => `${r.role}:${v}`) : [r.role])));
@@ -166,22 +278,42 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
166
278
  if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
167
279
  const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
168
280
  if (refused.has(recipe.role)) { rows.push({ ...blank, status: "refused", note: refused.get(recipe.role) }); continue; }
169
- const row = known.get(recipe.role) ?? (recipe.kind === "firebase" && !emulated ? { ...blank, status: "upstream" } : { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
170
- rows.push(recipe.kind === "firebase" && row.status !== "minted" ? await firebase(row) : row);
281
+ if (signsHere(recipe)) { rows.push(await signIn({ ...blank, status: "upstream" })); continue; }
282
+ rows.push(known.get(recipe.role) ?? { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
283
+ }
284
+ for (const row of rows.filter((r) => r.status === "minted")) {
285
+ minted.add(row.role);
286
+ if (existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
171
287
  }
172
- for (const row of rows) if (row.status === "minted" && existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
173
288
  return { identities: rows };
174
289
  }
175
290
 
176
- // The header a role speaks with, or nothing. A Firebase token lives an hour; it is renewed here
177
- // when a run outlasts it.
178
- async function headerFor(role) {
179
- if (!ROLE.test(role)) return null;
180
- const fb = firebaseRoles.get(role);
181
- if (fb && Date.now() - fb.at > 50 * 60_000) await firebase({ role, header: fb.header, kind: "firebase" }).catch(() => null);
182
- // A token the app's door issued, or a credential that is itself one of their secrets (an admin
183
- // key). In the sandbox the second never leaves the box; here it never leaves this machine, which
184
- // is the same promise, because this is where the request is sent from.
291
+ // A hosted token renewed before it lapses, and a simulated person's own account signed in the
292
+ // first time that person speaks. One sign-in per role at a time: lanes asking together share it.
293
+ const renewing = new Map();
294
+ const tried = new Set();
295
+ const shared = new Set();
296
+ async function due(role) {
297
+ const held = hosted.get(role);
298
+ if (held) { if (Date.now() - held.at > RENEW_MS[held.row.kind]) await signIn(held.row); return; }
299
+ const owner = hosted.get(PERSONA.exec(role)?.[1]);
300
+ if (!owner || !PER_PERSON.has(owner.row.kind) || tried.has(role)) return;
301
+ tried.add(role);
302
+ const got = await signIn({ ...owner.row, role });
303
+ if (got.status !== "minted" && !shared.has(owner.row.role)) {
304
+ shared.add(owner.row.role);
305
+ 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`);
306
+ }
307
+ }
308
+ const renewed = (role) => {
309
+ if (!renewing.has(role)) renewing.set(role, due(role).catch(() => null).finally(() => renewing.delete(role)));
310
+ return renewing.get(role);
311
+ };
312
+
313
+ // A token the app's door issued, or a credential that is itself one of their secrets (an admin
314
+ // key). In the sandbox the second never leaves the box; here it never leaves this machine, which
315
+ // is the same promise, because this is where the request is sent from.
316
+ function held(role) {
185
317
  const file = [headerFile(role), headerFile(role).replace(/\.header$/, ".sealed")].find((f) => { try { return statSync(f).isFile(); } catch { return false; } });
186
318
  if (!file) return null;
187
319
  const line = readFileSync(file, "utf8").split("\n")[0] ?? "";
@@ -189,5 +321,14 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
189
321
  return at > 0 ? { name: line.slice(0, at).trim().toLowerCase(), value: line.slice(at + 2).trim() } : null;
190
322
  }
191
323
 
192
- return { mint, headerFor };
324
+ // The header a role speaks with, or nothing. A person whose own account could not be made speaks
325
+ // as the role that person belongs to, the one account every trial used before.
326
+ async function headerFor(role) {
327
+ if (!ROLE.test(role)) return null;
328
+ await renewed(role);
329
+ const base = PERSONA.exec(role)?.[1];
330
+ return held(role) ?? (base ? held(base) : null);
331
+ }
332
+
333
+ return { mint, headerFor, roles: () => [...minted] };
193
334
  }
package/lib/mongo.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // A MongoDB database copied for a session on the server it lives on, with MongoDB's own tools: a
2
+ // dump of it restored under a name of ours, and dropped after. The address carries the password, so
3
+ // it reaches mongodump and mongorestore through a config file only this user can read, and mongosh
4
+ // through its environment: never a command line another process on this machine can read.
5
+ import { spawn } from "node:child_process";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ // A database bigger than this is not copied: said instead, so the person decides.
11
+ export const MONGO_CAP = 1024 ** 3;
12
+
13
+ // The address with another database in it. A user kept in the app's own database signs in there,
14
+ // which the address says by naming that database, so the copy's address names it as the place to
15
+ // sign in. Rewritten as text: a replica set's host list (a:27017,b:27018) is no URL a parser takes.
16
+ export function withDb(uri, db, source) {
17
+ const [, head, hosts, path = "", query = "", tail = ""] = /^([^:]+:\/\/)([^/?#]*)(?:\/([^?#]*))?(\?[^#]*)?(#.*)?$/.exec(uri);
18
+ const cut = hosts.lastIndexOf("@");
19
+ const signsIn = cut > 0 && !hosts.startsWith(":") && !/[?&]authSource=/i.test(query);
20
+ const own = (() => { try { return decodeURIComponent(path); } catch { return path; } })();
21
+ const from = signsIn ? `${query ? `${query}&` : "?"}authSource=${encodeURIComponent(own || source || "admin")}` : query;
22
+ return `${head}${hosts}/${encodeURIComponent(db)}${from}${tail}`;
23
+ }
24
+
25
+ function run(bin, args, { env = {}, stdin = null } = {}) {
26
+ return new Promise((done) => {
27
+ const child = spawn(bin, args, { env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env }, stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"] });
28
+ let out = "", err = "";
29
+ child.stdout.on("data", (d) => { out += d; });
30
+ child.stderr.on("data", (d) => { err = (err + d).slice(-4000); });
31
+ if (stdin) stdin.pipe(child.stdin);
32
+ child.on("error", (e) => done({ code: e.code === "ENOENT" ? "missing" : 1, out, err: String(e.message) }));
33
+ child.on("close", (code) => done({ code, out, err }));
34
+ });
35
+ }
36
+ // One statement against the server, the address in the environment.
37
+ const shell = (uri, script) => run("mongosh", ["--nodb", "--quiet", "--eval", `const c = new Mongo(process.env.CORTAD_MONGO); ${script}`], { env: { CORTAD_MONGO: uri } });
38
+
39
+ // Why a tool refused, as a class the sentence is written from, never in the server's own words.
40
+ export function refusal(r) {
41
+ if (r.code === "missing") return "missing";
42
+ if (/Authentication failed|AuthenticationFailed|auth error/i.test(r.err)) return "auth";
43
+ if (/not authorized|Unauthorized|requires authentication/i.test(r.err)) return "rights";
44
+ if (/ECONNREFUSED|connection refused|server selection|No servers available|timed out/i.test(r.err)) return "down";
45
+ return "other";
46
+ }
47
+
48
+ export async function cloneMongo(conn, source, clone) {
49
+ const size = await shell(withDb(conn.uri, source, source), `print(c.getDB(${JSON.stringify(source)}).stats().dataSize)`);
50
+ if (size.code !== 0) return { why: refusal(size) };
51
+ if (Number(size.out.trim()) > MONGO_CAP) return { why: "big" };
52
+ const dir = mkdtempSync(join(tmpdir(), "cortad-mongo-"));
53
+ try {
54
+ writeFileSync(join(dir, "from.yaml"), `uri: ${JSON.stringify(withDb(conn.uri, source, source))}\n`, { mode: 0o600 });
55
+ writeFileSync(join(dir, "to.yaml"), `uri: ${JSON.stringify(withDb(conn.uri, "", source))}\n`, { mode: 0o600 });
56
+ const dump = spawn("mongodump", [`--config=${join(dir, "from.yaml")}`, `--db=${source}`, "--archive"], { env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" }, stdio: ["ignore", "pipe", "pipe"] });
57
+ let dumpErr = "";
58
+ dump.stderr.on("data", (d) => { dumpErr = (dumpErr + d).slice(-4000); });
59
+ const dumped = new Promise((done) => { dump.on("error", (e) => done(e.code === "ENOENT" ? "missing" : 1)); dump.on("close", done); });
60
+ const restored = await run("mongorestore", [`--config=${join(dir, "to.yaml")}`, "--archive", `--nsInclude=${source}.*`, `--nsFrom=${source}.*`, `--nsTo=${clone}.*`], { stdin: dump.stdout });
61
+ const dumpCode = await dumped;
62
+ if (dumpCode === 0 && restored.code === 0) return { how: "dump" };
63
+ await dropMongo(conn, clone, source);
64
+ // Either side ending stops the other, which then fails for that reason alone: the telling one wins.
65
+ const whys = [dumpCode === 0 ? null : refusal({ code: dumpCode, err: dumpErr }), restored.code === 0 ? null : refusal(restored)].filter(Boolean);
66
+ return { why: whys.find((w) => w !== "other") ?? "other" };
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ }
71
+
72
+ export async function dropMongo(conn, clone, source) {
73
+ return (await shell(withDb(conn.uri, clone, source), `c.getDB(${JSON.stringify(clone)}).dropDatabase()`)).code === 0;
74
+ }
@@ -0,0 +1,68 @@
1
+ import { clip, closeToCall, has, num, pack, pageOf, plural } from "./words.mjs";
2
+
3
+ // Every number the run measured, whole, one call away (`findings numbers`): each group with its count
4
+ // by class, of how many, the interval on the share that matters, and one exchange from the worst
5
+ // group of each.
6
+
7
+ const pct = (x) => `${Math.round(x * 100)}%`;
8
+ const interval = (b) => `${pct(b.lo)} to ${pct(b.hi)}`;
9
+ const isScale = (t) => t.classes.every((c) => /^\d+$/.test(c));
10
+
11
+ const units = (n, unit) => (unit === "replies" ? plural(n, "reply", "replies") : plural(n, "conversation"));
12
+
13
+ const SPLIT_WORDS = { journey: "By journey", segment: "By user segment", situation: "By place in your app", reply: "By reply" };
14
+ const SPLIT_WORD = { journey: "journey", segment: "user segment", situation: "place", reply: "reply" };
15
+ // The server counts in "trials"; the person reads conversations.
16
+ const unitSaid = (unit) => (unit === "replies" ? "replies" : "conversations");
17
+
18
+ // One group as a row under its header: the counts in the header's order, of how many, the average
19
+ // on a scale, then the share named with its interval.
20
+ function row(t, d, said) {
21
+ const of = units(d.of, t.unit);
22
+ const unsure = d.unsure ? `; ${closeToCall(d.unsure, said)}` : "";
23
+ const share = d.bad && d.of ? `, ${interval(d.bad)}` : "";
24
+ if (t.kind === "question") return `${num(d.counts?.broke ?? 0)} of ${of}${share}${unsure}`;
25
+ const counts = t.classes.map((c) => num(d.counts?.[c] ?? 0)).join(", ");
26
+ const mean = has(d.mean) ? `, average ${d.mean.toFixed(2)}` : "";
27
+ return `${counts} of ${of}${mean}${d.bad && t.badSaid ? `; ${num(d.bad.k)}${share}` : ""}${unsure}`;
28
+ }
29
+
30
+ // How the rows are read: a question's conversations broken, or a measurement's classes and its share.
31
+ const headerOf = (t) => (t.kind === "question"
32
+ ? ` Counted as: the ${unitSaid(t.unit)} it broke in, of the ${unitSaid(t.unit)} that asked it, with the 95% interval.`
33
+ : ` Counted as: ${isScale(t) ? `levels ${t.classes.join(", ")}` : t.classes.join(", ")} of ${unitSaid(t.unit)}${t.badSaid ? `; then how many ${t.badSaid}, with the 95% interval` : ""}.`);
34
+
35
+ function tableBlock(t) {
36
+ const said = {};
37
+ const lines = [`${t.kind === "question" ? `${t.id} ` : ""}${clip(t.title, 400)}${t.from ? ` (${t.from})` : ""}`];
38
+ if (t.legend) lines.push(` ${clip(t.legend, 500)}`);
39
+ lines.push(headerOf(t), ` All: ${row(t, t.all, said)}`);
40
+ for (const split of ["journey", "segment", "situation", "reply"]) {
41
+ const groups = t.by?.[split] ?? [];
42
+ if (!groups.length || (split !== "reply" && groups.length === 1 && groups[0].of === t.all.of)) continue;
43
+ lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (saved to test fixes)" : ""}: ${row(t, d, said)}`));
44
+ }
45
+ const w = t.worst;
46
+ if (w) {
47
+ const e = w.exchange;
48
+ lines.push(` Worst: ${SPLIT_WORD[w.split] ?? w.split} ${clip(w.value, 90)}.${e ? ` Conversation ${String(e.trialId).slice(0, 8)}, reply ${e.reply}.` : " Its conversations are saved to test fixes."}`);
49
+ if (e?.sent) lines.push(` Sent: "${clip(e.sent, 300)}"`);
50
+ if (e?.answer) lines.push(` Reply ${e.reply}: "${clip(e.answer, 300)}"`);
51
+ if (e?.after) lines.push(` Then the customer: "${clip(e.after, 300)}"`);
52
+ if (e?.quote && e.quote !== e.answer) lines.push(` It broke on: "${clip(e.quote, 240)}"`);
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+
57
+ // Every number, whole, in pages under what every client shows at once.
58
+ export function numbersText(d, page = 1) {
59
+ const n = d?.numbers;
60
+ if (!n) return d?.why ?? "No run has finished on this repository yet.";
61
+ const head = [
62
+ `Run ${n.runId}: every number it measured, ${plural(n.measures.length, "measurement")} and ${plural(n.questions.length, "question")} over ${plural(n.trials, "conversation")}, ${plural(n.replies, "reply", "replies")} and ${plural(n.readings, "reading")}.`,
63
+ "A conversation counts once in each group it played in; a reply split counts each conversation at that reply.",
64
+ ].join("\n");
65
+ const blocks = [...n.measures, ...n.questions].map(tableBlock);
66
+ const pages = pack(head, blocks.map((b) => `\n${b}`), `Run ${n.runId}, numbers continued.`);
67
+ return pageOf(pages, page, (k) => `findings numbers with page ${k}`);
68
+ }
package/lib/pg.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // A Postgres database copied for a session on the server it lives on, with the credentials the
2
+ // app's own settings carry, and dropped after. Postgres's own tools do the work (psql, and pg_dump
3
+ // when the database is in use); the password reaches them through their environment, never a
4
+ // command line another process on this machine can read.
5
+ import { spawn } from "node:child_process";
6
+
7
+ // A database bigger than this is not copied: said instead, so the person decides.
8
+ export const PG_CAP = 1024 ** 3;
9
+ const quote = (name) => `"${String(name).replace(/"/g, '""')}"`;
10
+ const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
11
+
12
+ const envOf = (conn, db) => ({
13
+ PATH: process.env.PATH ?? "",
14
+ HOME: process.env.HOME ?? "",
15
+ PGDATABASE: db,
16
+ PGCONNECT_TIMEOUT: "10",
17
+ PGAPPNAME: "cortad",
18
+ ...(conn.host ? { PGHOST: conn.host } : {}),
19
+ ...(conn.port ? { PGPORT: String(conn.port) } : {}),
20
+ ...(conn.user ? { PGUSER: conn.user } : {}),
21
+ ...(conn.password ? { PGPASSWORD: conn.password } : {}),
22
+ ...(conn.sslmode ? { PGSSLMODE: conn.sslmode } : {}),
23
+ });
24
+
25
+ function run(bin, args, env, input = null) {
26
+ return new Promise((done) => {
27
+ const child = spawn(bin, args, { env, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
28
+ let out = "", err = "";
29
+ child.stdout.on("data", (d) => { out += d; });
30
+ child.stderr.on("data", (d) => { err = (err + d).slice(-4000); });
31
+ if (input) input.pipe(child.stdin);
32
+ child.on("error", (e) => done({ code: e.code === "ENOENT" ? "missing" : 1, out, err: String(e.message) }));
33
+ child.on("close", (code) => done({ code, out, err }));
34
+ });
35
+ }
36
+
37
+ // One statement per -c: CREATE and DROP DATABASE refuse to run inside a transaction, and several
38
+ // statements in one string are one.
39
+ const psql = (conn, db, ...statements) =>
40
+ run("psql", ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose", ...statements.flatMap((s) => ["-c", s])], envOf(conn, db));
41
+
42
+ // Why psql refused, as a class the sentence is written from. Never the server's own words: they
43
+ // name the user and the host, and the sentence leaves this machine.
44
+ export function refusal(err) {
45
+ const code = /(?:ERROR|FATAL):\s+([0-9A-Z]{5}):/.exec(err)?.[1];
46
+ // A refusal while connecting carries no code, only the server's words.
47
+ if (code === "28P01" || code === "28000" || /password authentication failed|no pg_hba\.conf entry|role "[^"]*" does not exist|no password supplied/i.test(err)) return "auth";
48
+ if (code === "42501") return "rights";
49
+ if (code === "55006") return "busy";
50
+ if (code === "3D000" || /database "[^"]*" does not exist/i.test(err)) return "gone";
51
+ if (/server version mismatch|aborting because of server version/i.test(err)) return "version";
52
+ if (/Connection refused|could not connect|No such file or directory|timeout expired|could not translate host/i.test(err)) return "down";
53
+ return "other";
54
+ }
55
+
56
+ // The database to stand in while a copy is made: never the source, which a template copy needs
57
+ // free of every connection, ours included.
58
+ async function maintenance(conn, source) {
59
+ for (const db of ["postgres", "template1"].filter((d) => d !== source)) {
60
+ const r = await psql(conn, db, "select 1");
61
+ if (r.code === 0) return { db };
62
+ if (r.code === "missing") return { why: "missing" };
63
+ const why = refusal(r.err);
64
+ if (why !== "gone") return { why };
65
+ }
66
+ return { why: "other" };
67
+ }
68
+
69
+ // The copy: a template copy first, which Postgres makes file by file; when the database is in use,
70
+ // which a template copy refuses, a dump of it restored into an empty one. Answers { how } or { why }.
71
+ export async function clonePg(conn, source, clone) {
72
+ const home = await maintenance(conn, source);
73
+ if (!home.db) return { why: home.why };
74
+ const size = await psql(conn, home.db, `select pg_database_size(${literal(source)})`);
75
+ if (size.code !== 0) return { why: refusal(size.err) };
76
+ if (Number(size.out.trim()) > PG_CAP) return { why: "big" };
77
+ const made = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)} TEMPLATE ${quote(source)}`);
78
+ if (made.code === 0) return { how: "template" };
79
+ const why = refusal(made.err);
80
+ if (why !== "busy") return { why };
81
+ const empty = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)}`);
82
+ if (empty.code !== 0) return { why: refusal(empty.err) };
83
+ const dump = spawn("pg_dump", ["--no-owner", "--no-privileges"], { env: envOf(conn, source), stdio: ["ignore", "pipe", "pipe"] });
84
+ let dumpErr = "";
85
+ dump.stderr.on("data", (d) => { dumpErr = (dumpErr + d).slice(-4000); });
86
+ const dumped = new Promise((done) => { dump.on("error", (e) => done(e.code === "ENOENT" ? "missing" : 1)); dump.on("close", done); });
87
+ const restored = await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose"], envOf(conn, clone), dump.stdout);
88
+ const dumpCode = await dumped;
89
+ if (dumpCode === 0 && restored.code === 0) return { how: "dump" };
90
+ await dropPg(conn, clone);
91
+ if (dumpCode === "missing") return { why: "missing" };
92
+ // Either side ending stops the other, which then fails for that reason alone: the telling one wins.
93
+ const whys = [dumpCode === 0 ? null : refusal(dumpErr), restored.code === 0 ? null : refusal(restored.err)].filter(Boolean);
94
+ return { why: whys.find((w) => w !== "other") ?? "other" };
95
+ }
96
+
97
+ // An empty database on a server of this machine, for a stand-in of one kept elsewhere: the app's own
98
+ // migrations fill it. Answers { how } or { why }.
99
+ export async function emptyPg(conn, name) {
100
+ const home = await maintenance(conn);
101
+ if (!home.db) return { why: home.why };
102
+ const made = await psql(conn, home.db, `CREATE DATABASE ${quote(name)}`);
103
+ return made.code === 0 ? { how: "empty" } : { why: refusal(made.err) };
104
+ }
105
+
106
+ // One migration file, run whole: a failed statement stops it.
107
+ export const applySql = async (conn, db, file) =>
108
+ (await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-f", file], envOf(conn, db))).code === 0;
109
+
110
+ // The copy's own connections are ended first: the app is stopped by then, but a pool can outlive it.
111
+ export async function dropPg(conn, clone) {
112
+ const home = await maintenance(conn);
113
+ if (!home.db) return false;
114
+ const r = await psql(conn, home.db,
115
+ `select pg_terminate_backend(pid) from pg_stat_activity where datname = ${literal(clone)} and pid <> pg_backend_pid()`,
116
+ `DROP DATABASE IF EXISTS ${quote(clone)}`);
117
+ return r.code === 0;
118
+ }