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.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
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,
|
|
153
|
-
const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r
|
|
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
|
-
|
|
170
|
-
rows.push(recipe.
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
+
}
|