clearotron 0.3.0-beta.4 → 0.3.0-beta.6
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/.env.example +9 -0
- package/INSTALL.md +13 -0
- package/bin/connect.mjs +3 -1
- package/bin/onboard.mjs +80 -28
- package/bin/start.mjs +82 -18
- package/bin/update.mjs +38 -6
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/driver/CHANGELOG.md +36 -10
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/demo-container.mjs +30 -2
- package/driver/engine/openai-agent.mjs +60 -2
- package/driver/enqueue-schema.mjs +3 -1
- package/driver/gateway.mjs +12 -0
- package/driver/package.json +1 -1
- package/driver/portal-local-auth.mjs +35 -3
- package/driver/portal-service.mjs +80 -18
- package/driver/portal-upstream.mjs +20 -1
- package/driver/search-policy.mjs +7 -2
- package/driver/suite-census.json +71 -23
- package/mcp-server/CHANGELOG.md +12 -2
- package/mcp-server/CONNECT.md +3 -2
- package/mcp-server/lib/options.mjs +13 -5
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DWYCsOCJ.js → index-DBUVdQT-.js} +6 -0
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e.mjs +84 -12
- package/scripts/engine-probe.mjs +2 -2
- package/scripts/env-classify.mjs +3 -0
- package/shared/connect-clients.mjs +8 -0
- package/shared/names-in-force.mjs +1 -0
- package/shared/permanent-install.mjs +167 -0
- package/shared/stdio-connect.mjs +32 -14
- package/shared/verb-shim.mjs +10 -1
- package/shared/wsl.mjs +23 -0
|
@@ -221,7 +221,7 @@ export const E3_BACKLOG = [
|
|
|
221
221
|
where: "driver/skills/prelim-common-law/SKILL.md:192 (restated at driver/skills/prelim-search/synthesis-rules.md:394)",
|
|
222
222
|
surface: "skill-file",
|
|
223
223
|
evidence: "A clean PR/connotation row MUST cite its search — add a `**Connotation-search source:** <URL | \"perplexity_research — no result\">` line.",
|
|
224
|
-
reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated
|
|
224
|
+
reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated by correctionHint() in gateway.mjs",
|
|
225
225
|
removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
|
|
226
226
|
},
|
|
227
227
|
{
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
// stage apart. This module is the single answer. `cut/` cannot import it (that directory does not travel
|
|
24
24
|
// and this one does), so the pack gate restates the disjunction and its own test pins the two together.
|
|
25
25
|
|
|
26
|
-
import { cpSync, existsSync, mkdtempSync, readdirSync } from "node:fs";
|
|
26
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync } from "node:fs";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
|
-
import { join, resolve, sep } from "node:path";
|
|
28
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
29
29
|
|
|
30
30
|
/** The entry file each lane's publisher reads as its source, in the order a child is probed for one. */
|
|
31
31
|
export const ENTRY_FILES = Object.freeze(["report.md", "knockout-findings.json"]);
|
|
@@ -78,3 +78,31 @@ export function publishSource(dir, { repoRoot, tmp = tmpdir() } = {}) {
|
|
|
78
78
|
cpSync(here, copy, { recursive: true });
|
|
79
79
|
return copy;
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* THE DEMO'S SAMPLE RUNS, WHERE AN ASSISTANT LOOKS FOR RUNS.
|
|
84
|
+
*
|
|
85
|
+
* The demo published its samples as reports and made no run directory, so the connector its own connect
|
|
86
|
+
* line wires listed nothing: an assistant, one of the demo's three faces, had nothing to explore. Each
|
|
87
|
+
* sample's finished `run/` is copied to the layout the connector walks, under the DEMO'S OWN workspace:
|
|
88
|
+
* `<workspace>/workspace-<agent>/studio/prelim-search/<slug>/<date>-<codename>/`. Nothing is written
|
|
89
|
+
* anywhere else, so a real install started afterwards sees none of it (the demo is its own install).
|
|
90
|
+
*
|
|
91
|
+
* Copied, never linked: a connector reading a run may write beside it, and `demo/` is tracked. A run
|
|
92
|
+
* already in place is left alone, so a visitor's second start changes nothing.
|
|
93
|
+
*/
|
|
94
|
+
export function seedDemoRuns({ workspace, examplesDir }) {
|
|
95
|
+
const seeded = [], already = [];
|
|
96
|
+
for (const name of demoChildren(examplesDir)) {
|
|
97
|
+
const run = join(examplesDir, name, "run");
|
|
98
|
+
let s;
|
|
99
|
+
try { s = JSON.parse(readFileSync(join(run, "status.json"), "utf8")); } catch { continue; }
|
|
100
|
+
if (!s?.slug || !s?.codename || !s?.date) continue;
|
|
101
|
+
const dir = join(workspace, `workspace-${s.agent || "clawdi"}`, "studio", "prelim-search", s.slug, `${s.date}-${s.codename}`);
|
|
102
|
+
if (existsSync(join(dir, "status.json"))) { already.push(s.runId); continue; }
|
|
103
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
104
|
+
cpSync(run, dir, { recursive: true });
|
|
105
|
+
seeded.push(s.runId);
|
|
106
|
+
}
|
|
107
|
+
return { seeded, already };
|
|
108
|
+
}
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
// CLEAROTRON_CODEX_SANDBOX_BYPASS=1 swaps that `--sandbox workspace-write` for
|
|
23
23
|
// `--dangerously-bypass-approvals-and-sandbox` — see buildCodexArgs below for why.
|
|
24
24
|
|
|
25
|
-
import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
26
|
+
import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
|
|
26
27
|
import { tmpdir, homedir } from "node:os";
|
|
27
28
|
import { join } from "node:path";
|
|
28
29
|
import { runStreamingChild, absolutizeSkillRefs, WRITE_DISCIPLINE, buildEnvelope, resolveSpawnCwd } from "./common.mjs";
|
|
@@ -174,6 +175,49 @@ export function spawnEnv(base = process.env, codexHome) {
|
|
|
174
175
|
|
|
175
176
|
const authFilePath = (env = process.env) => env.CLEAROTRON_OPENAI_AUTH_FILE || join(homedir(), ".codex", "auth.json");
|
|
176
177
|
|
|
178
|
+
// ── THE LOGIN IS THE MASTER'S, NOT A COPY'S ─────────────────────────────────────────────────────────
|
|
179
|
+
//
|
|
180
|
+
// codex refreshes its access token inside a turn and rotates the refresh token as it does, and the
|
|
181
|
+
// provider accepts each refresh token once. The stage home used to be seeded with a COPY of the master
|
|
182
|
+
// login and deleted when its ladder settled, so the rotated token was deleted with it. The master kept a
|
|
183
|
+
// token the provider had already consumed, and every later stage on every run failed its refresh with a
|
|
184
|
+
// 401 until a person signed in again. The engine's only report was an exit code.
|
|
185
|
+
//
|
|
186
|
+
// So the stage home LINKS to the master: a refresh codex writes in place lands where the next stage
|
|
187
|
+
// reads, including a ladder running beside this one. If codex replaces the link with a file of its own,
|
|
188
|
+
// `returnAuth` writes that file back.
|
|
189
|
+
|
|
190
|
+
/** Point `<codexHome>/auth.json` at the master login. Returns what the master held, for `returnAuth`. */
|
|
191
|
+
function seedAuth(masterPath, codexHome) {
|
|
192
|
+
const seat = join(codexHome, "auth.json");
|
|
193
|
+
// A LINK LEFT BY THIS LADDER'S PREVIOUS TURN is removed first: copying the master onto a link to
|
|
194
|
+
// itself would truncate the one file the login lives in.
|
|
195
|
+
rmSync(seat, { force: true });
|
|
196
|
+
symlinkSync(masterPath, seat);
|
|
197
|
+
return readFileSync(masterPath, "utf8");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* After a turn: a login codex rewrote as its own file goes back to the master.
|
|
202
|
+
*
|
|
203
|
+
* ONLY WHILE THE MASTER STILL HOLDS WHAT THIS TURN WAS SEEDED WITH. A master that moved meanwhile was
|
|
204
|
+
* rotated by another ladder, and the provider accepts one refresh per token, so of two rotations from
|
|
205
|
+
* one seed only one can be live. Writing ours over theirs could put back the spent one.
|
|
206
|
+
*/
|
|
207
|
+
export function returnAuth(masterPath, codexHome, seeded) { // @internal
|
|
208
|
+
const seat = join(codexHome, "auth.json");
|
|
209
|
+
let st;
|
|
210
|
+
try { st = lstatSync(seat); } catch { return false; }
|
|
211
|
+
if (st.isSymbolicLink() || !st.isFile()) return false; // written through the link, or not there
|
|
212
|
+
const now = readFileSync(seat, "utf8");
|
|
213
|
+
if (now === seeded) return false;
|
|
214
|
+
let master;
|
|
215
|
+
try { master = readFileSync(masterPath, "utf8"); } catch { return false; }
|
|
216
|
+
if (master !== seeded) return false;
|
|
217
|
+
writeSecretFile(masterPath, now);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
177
221
|
// Build the codex argv. Prompt rides STDIN via the `-` placeholder (no MAX_ARG_STRLEN ceiling on a 150KB
|
|
178
222
|
// register prompt). Global flags precede the `resume` subcommand (flag-corpus ordering). config.toml
|
|
179
223
|
// (mcp_servers + developer_instructions) rides CODEX_HOME, not argv.
|
|
@@ -368,6 +412,10 @@ export function readServedModel(codexHome, sinceMs = 0) {
|
|
|
368
412
|
// that cannot recur. This alternation is still a list of prose a vendor may change without notice,
|
|
369
413
|
// which is exactly why `rateLimitBasis: "text-match"` stays on the record.
|
|
370
414
|
const RATE_LIMIT_RE = /\b429\b|rate.?limit|usage limit|quota|too many requests|insufficient_quota/i;
|
|
415
|
+
// A SIGN-IN THAT CAN NO LONGER BE REFRESHED, in codex's own words (measured on a live install, 2026-09-11).
|
|
416
|
+
// Narrow on purpose: a register provider's 401 or 403 can land in a stage's stderr too, and reading that as
|
|
417
|
+
// the operator's codex login having expired would send them to sign in again for nothing.
|
|
418
|
+
const REFRESH_FAILED_RE = /refresh token (?:has|was) already been used|access token could not be refreshed|failed to refresh token/i;
|
|
371
419
|
|
|
372
420
|
// A retry hint, WHEN THE MESSAGE HAPPENS TO CARRY ONE. Two anchored shapes and nothing clever: an
|
|
373
421
|
// explicit timestamp, or a relative delay with a unit. Anything else yields undefined and the driver
|
|
@@ -440,6 +488,7 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
440
488
|
// backoff (CLEAROTRON_RATE_LIMIT_DEFAULT_BACKOFF_MS), exactly as the anthropic no-reset 429 path does.
|
|
441
489
|
const rateLimitText = `${ev.turnFailed || ""}\n${ev.streamError || ""}\n${r.stderr || ""}`;
|
|
442
490
|
const rateLimited = !killed && RATE_LIMIT_RE.test(rateLimitText);
|
|
491
|
+
const signedOut = !killed && REFRESH_FAILED_RE.test(rateLimitText);
|
|
443
492
|
const resetsAt = rateLimited ? parseResetHint(rateLimitText) : undefined;
|
|
444
493
|
return {
|
|
445
494
|
code: killed ? 137 : (failed ? (r.rawCode || 1) : 0),
|
|
@@ -477,6 +526,9 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
477
526
|
// receives and is not this adapter's call — but nothing downstream can any longer fail to know.
|
|
478
527
|
mcpRefused: mcpToolGauge(ev).mcpToolCallsRefused > 0 || undefined,
|
|
479
528
|
rateLimited: rateLimited || undefined,
|
|
529
|
+
// The engine's sign-in could not be refreshed: what the operator runs to fix it. The gateway names the
|
|
530
|
+
// stage's failure with it.
|
|
531
|
+
signedOut: signedOut ? "codex sign-in expired — run `codex login`, then start the search again" : undefined,
|
|
480
532
|
rateLimitBasis: rateLimited ? "text-match" : undefined,
|
|
481
533
|
// resetsAtBasis (2026-08-20): same honesty as rateLimitBasis one line up, for the reset
|
|
482
534
|
// CLOCK rather than the classification. codex states its reset as human prose with NO timezone
|
|
@@ -530,6 +582,7 @@ export const openaiAgentEngine = {
|
|
|
530
582
|
const ownHome = !providedHome;
|
|
531
583
|
try { codexHome = providedHome ?? mkdtempSync(join(tmpdir(), "codex-home-")); }
|
|
532
584
|
catch (e) { return errResult(t0, e, resumeRef); }
|
|
585
|
+
let authMaster = null, seeded = null;
|
|
533
586
|
try {
|
|
534
587
|
// Auth toggle (may throw loud on api-key-without-key — a config error, not a retryable failure).
|
|
535
588
|
const { env, mode } = spawnEnv(process.env, codexHome);
|
|
@@ -537,7 +590,9 @@ export const openaiAgentEngine = {
|
|
|
537
590
|
const af = authFilePath(process.env);
|
|
538
591
|
if (!existsSync(af))
|
|
539
592
|
throw new Error(`CLEAROTRON_AI_BILLING=subscription but no auth.json at ${af} — run \`codex login\` (or set CLEAROTRON_OPENAI_AUTH_FILE — NOT an aliased name, and deliberately left as it is), or use CLEAROTRON_AI_BILLING=api-key + CODEX_API_KEY.`);
|
|
540
|
-
|
|
593
|
+
// The REAL path, so a master that is itself a link is written where its login actually lives.
|
|
594
|
+
authMaster = realpathSync(af);
|
|
595
|
+
seeded = seedAuth(authMaster, codexHome);
|
|
541
596
|
}
|
|
542
597
|
// Per-run config.toml: the gather MCP servers (translated from the claude-shaped mcpConfig, so
|
|
543
598
|
// gather-config.mjs is untouched) + developer_instructions = WRITE_DISCIPLINE (codex's
|
|
@@ -574,6 +629,9 @@ export const openaiAgentEngine = {
|
|
|
574
629
|
if (!ev.model) ev.model = readServedModel(codexHome, spawnedAtMs);
|
|
575
630
|
return settleTuple({ r, ev, resumeRef });
|
|
576
631
|
} finally {
|
|
632
|
+
// BEFORE the home can be deleted, here or by the ladder that owns it. A failed write-back leaves the
|
|
633
|
+
// turn's result as it was; the next turn is seeded from the master either way.
|
|
634
|
+
try { if (authMaster) returnAuth(authMaster, codexHome, seeded); } catch { /* the master is unchanged */ }
|
|
577
635
|
try { if (ownHome && codexHome) rmSync(codexHome, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
578
636
|
}
|
|
579
637
|
},
|
|
@@ -456,7 +456,9 @@ export function validateJob(job, { atClaim = false } = {}) {
|
|
|
456
456
|
if (projectKey) consulted.push(`the project ${JSON.stringify(projectKey)}`);
|
|
457
457
|
// The saved search is a rung above the project. `clarify` means the selector itself is wrong, which
|
|
458
458
|
// the recipeKey/product checks further down report properly — here it just means no scope to read.
|
|
459
|
-
|
|
459
|
+
// The store is read only for a job that names a saved search, as every other door reads it: one that
|
|
460
|
+
// cannot be read must not refuse a job that never asked for anything in it.
|
|
461
|
+
const resolved = resolveSearchPolicy(job, { profile, recipes: job.recipeKey ? loadRecipes({ force: true, proseGuard: recipeProseGuard }) : null });
|
|
460
462
|
if (job.recipeKey) consulted.push(`the saved search ${JSON.stringify(String(job.recipeKey))}`);
|
|
461
463
|
const scope = resolveEffectiveScope(job, profile, resolved?.clarify ? null : resolved);
|
|
462
464
|
inherited = Array.isArray(scope?.classes) ? scope.classes : [];
|
package/driver/gateway.mjs
CHANGED
|
@@ -1511,6 +1511,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1511
1511
|
// Guarded on `fail`: a turn whose finished, validated artifact was just accepted (the exit-1 rescue above)
|
|
1512
1512
|
// stays accepted — the NEXT stage's first turn hits the same cap at 0 tokens spent and postpones cleanly.
|
|
1513
1513
|
if (turn.signals?.rateLimited && fail) fail = "rate_limited";
|
|
1514
|
+
// AN ENGINE WHOSE SIGN-IN EXPIRED IS NAMED, not left as `nonzero_exit_1`. codex fails its refresh in
|
|
1515
|
+
// seconds with a bare exit code while `codex login status` still reports the user signed in, so the
|
|
1516
|
+
// one thing that fixes it has to be in the failure itself. AFTER the rate limit, which wins when both
|
|
1517
|
+
// appear: a postponed run resumes on its own, and a signed-out one cannot. The sentence rides after
|
|
1518
|
+
// the colon, as `model_mismatch:` carries its detail, so no run-level classifier needs a new token.
|
|
1519
|
+
else if (turn.signals?.signedOut && fail) fail = `engine_signed_out: ${turn.signals.signedOut}`;
|
|
1514
1520
|
// A6 (addendum 2026-07-30): stop_reason max_tokens with ZERO usable output is a DETECTED FAULT with a
|
|
1515
1521
|
// name — never a silent paid retry. The turn ran to its output-token ceiling and the artifact never
|
|
1516
1522
|
// landed (a content fail on a "successful" turn), or the turn itself died at the ceiling (transport
|
|
@@ -2002,6 +2008,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
2002
2008
|
note(`[${name}] model mismatch is deterministic — breaking the ladder (a retry re-buys the same wrong model)`);
|
|
2003
2009
|
break;
|
|
2004
2010
|
}
|
|
2011
|
+
// A sign-in that could not be refreshed refuses every attempt the same way: the next one sends the
|
|
2012
|
+
// same credential and gets the same 401, seconds apart. Break, and let the stage fail with it named.
|
|
2013
|
+
if (fail.startsWith("engine_signed_out:")) {
|
|
2014
|
+
note(`[${name}] the engine's sign-in could not be refreshed — breaking the ladder (a retry re-sends the same credential)`);
|
|
2015
|
+
break;
|
|
2016
|
+
}
|
|
2005
2017
|
// D3: overload (529 / status_overloaded) — stop the ladder here: an in-ladder re-attempt hammers an
|
|
2006
2018
|
// API that just said it is overloaded, seconds apart, on the SAME model. Breaking hands the failure
|
|
2007
2019
|
// to the existing machinery: the chain cascades models (FALLBACK_ELIGIBLE) and an exhausted chain
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.6",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
// need the prefix — one side of a pair is enough to separate the pair.
|
|
46
46
|
import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypto";
|
|
47
47
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
48
|
-
import { basename, dirname, join } from "node:path";
|
|
48
|
+
import { basename, dirname, join, relative, isAbsolute, sep } from "node:path";
|
|
49
49
|
import { homedir } from "node:os";
|
|
50
50
|
import { envPrefix } from "../shared/os-advice.mjs";
|
|
51
51
|
|
|
@@ -187,6 +187,30 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
|
|
|
187
187
|
return `${prefix.slice(0, at)}${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${prefix.slice(at)}clearotron passphrase --reset`;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The recovery line the SIGN-IN PAGE shows: one that resets the credential this portal reads, and that
|
|
192
|
+
* names no path on this machine.
|
|
193
|
+
*
|
|
194
|
+
* The page is read by somebody who is not signed in, so `passphraseResetCommand`'s line, with its
|
|
195
|
+
* `cd <install> && ` and its absolute `--base`, would show the server's home directory and account to
|
|
196
|
+
* anyone who loads it. So the install is named from `$HOME`, which POSIX shells and PowerShell both
|
|
197
|
+
* expand, and a demo run from npx's cache is reached as `npx clearotron@<version>`, which runs from any
|
|
198
|
+
* directory. The bare `clearotron passphrase --reset` this replaced reset the shared default, not a
|
|
199
|
+
* demo's own credential, and did not run at all where `clearotron` is not on the PATH (measured
|
|
200
|
+
* 2026-09-11 on a published beta).
|
|
201
|
+
*/
|
|
202
|
+
export function signInResetCommand({ credentialPath = null, home = null, npxVersion = null } = {}) {
|
|
203
|
+
const verb = `${npxVersion ? `npx clearotron@${npxVersion}` : "clearotron"} passphrase --reset`;
|
|
204
|
+
if (!credentialPath || basename(credentialPath) !== INSTALL_CREDENTIAL_FILE) return verb;
|
|
205
|
+
const h = home ?? homedir();
|
|
206
|
+
const dir = dirname(credentialPath);
|
|
207
|
+
if (dir === defaultInstallBase({ home: h })) return verb;
|
|
208
|
+
const rel = relative(h, dir);
|
|
209
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return `${verb} --base <this install's folder>`;
|
|
210
|
+
const at = `$HOME/${rel.split(sep).join("/")}`;
|
|
211
|
+
return `${verb} --base ${/\s/.test(at) ? `"${at}"` : at}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
190
214
|
/**
|
|
191
215
|
* The name an install's own credential has inside the install's directory: the shared default's name,
|
|
192
216
|
* so the two are one kind of file in two places.
|
|
@@ -287,7 +311,8 @@ export function establishCredential({ path, email, passphrase = null }) {
|
|
|
287
311
|
// 18 bytes → 24 base64url characters, no padding and no ambiguous punctuation: readable off a
|
|
288
312
|
// terminal, typeable into a browser, and 144 bits of entropy so a generated one is never the weak
|
|
289
313
|
// half of this design.
|
|
290
|
-
|
|
314
|
+
// Trimmed for the reason checkPassphrase trims: surrounding whitespace is never part of a passphrase.
|
|
315
|
+
const secret = generated ? newPassphrase() : String(passphrase).trim();
|
|
291
316
|
if (!secret) throw new Error("establishCredential: an empty passphrase is not a credential");
|
|
292
317
|
const salt = randomBytes(16).toString("base64url");
|
|
293
318
|
const rec = {
|
|
@@ -319,7 +344,14 @@ export function establishCredential({ path, email, passphrase = null }) {
|
|
|
319
344
|
*/
|
|
320
345
|
export function checkPassphrase(record, passphrase) {
|
|
321
346
|
if (!record || typeof record.salt !== "string" || typeof record.hash !== "string") return false;
|
|
322
|
-
if (typeof passphrase !== "string"
|
|
347
|
+
if (typeof passphrase !== "string") return false;
|
|
348
|
+
// SURROUNDING WHITESPACE IS NOT PART OF A PASSPHRASE. A value copied from a terminal or a chat carries a
|
|
349
|
+
// trailing space or line break, and the digest of `pass ` is not the digest of `pass`, so a correct
|
|
350
|
+
// passphrase was refused as wrong (measured 2026-09-11 on a published beta). Every passphrase is minted
|
|
351
|
+
// trimmed (establishCredential, below), and a generated one has no whitespace at all, so trimming here
|
|
352
|
+
// refuses nothing that matched before. Whitespace INSIDE a passphrase is kept.
|
|
353
|
+
passphrase = passphrase.trim();
|
|
354
|
+
if (!passphrase) return false;
|
|
323
355
|
if (record.algo !== ALGO) return false;
|
|
324
356
|
let got;
|
|
325
357
|
try { got = derive(passphrase, record.salt); } catch { return false; }
|
|
@@ -40,6 +40,8 @@ import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mj
|
|
|
40
40
|
import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
|
|
41
41
|
import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
|
|
42
42
|
import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
|
|
43
|
+
import { isWsl } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run
|
|
44
|
+
import { inNpxCache, ownVersion } from "../shared/permanent-install.mjs"; // — a demo from npx is reset through npx
|
|
43
45
|
import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -112,7 +114,7 @@ import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers,
|
|
|
112
114
|
// makePrincipal and assertPrincipal exactly as a Cloudflare-verified address does.
|
|
113
115
|
import { readLocalCredential, establishCredential, checkPassphrase, mintSession, verifySession,
|
|
114
116
|
makeAttemptLimiter, credentialPathFor,
|
|
115
|
-
localCredentialHandoff, firstRunCredentialLines, passphraseResetCommand } from "./portal-local-auth.mjs";
|
|
117
|
+
localCredentialHandoff, firstRunCredentialLines, passphraseResetCommand, signInResetCommand } from "./portal-local-auth.mjs";
|
|
116
118
|
import { appendFlag, feedbackDir, MAX_WHY, VERDICTS } from "./feedback-store.mjs";
|
|
117
119
|
import { loadRecipes, PRODUCT_POLICIES, policyFor, countJobMarks, kebabCollisions,
|
|
118
120
|
gateResolvedPolicy, productAvailability, UNAVAILABLE_NOTE, coverageDisclosure, reportIdentityFor } from "./search-policy.mjs";
|
|
@@ -1197,8 +1199,16 @@ export function makePortalService({
|
|
|
1197
1199
|
const usedJtis = new Map();
|
|
1198
1200
|
const sweepJtis = (now = Date.now()) => { for (const [k, exp] of usedJtis) if (exp < now) usedJtis.delete(k); };
|
|
1199
1201
|
|
|
1200
|
-
const searchesFor = (account) => {
|
|
1201
|
-
|
|
1202
|
+
const searchesFor = (account, principal) => {
|
|
1203
|
+
// A SAVED-SEARCH STORE THAT CANNOT BE READ COSTS THE SAVED SEARCHES, NOT THE MENU. Every product is
|
|
1204
|
+
// still offered; the list comes back empty with a sentence saying why, and only a person who sees
|
|
1205
|
+
// everything is shown the store's own error, which names a server path.
|
|
1206
|
+
let recipes = new Map(), recipesNote = null;
|
|
1207
|
+
try { recipes = loadRecipesImpl({}); }
|
|
1208
|
+
catch (e) {
|
|
1209
|
+
const cause = seesEverything(principal) ? ` (${String(e?.message ?? e).split("\n")[0].slice(0, 300)})` : "";
|
|
1210
|
+
recipesNote = `Saved searches could not be read on this installation, so none are listed${cause}.`;
|
|
1211
|
+
}
|
|
1202
1212
|
const built = readBuilt();
|
|
1203
1213
|
const canCount = readCanCount();
|
|
1204
1214
|
const territories = readTerritories();
|
|
@@ -1264,6 +1274,7 @@ export function makePortalService({
|
|
|
1264
1274
|
recipes: [...recipes.entries()]
|
|
1265
1275
|
.filter(([k, r]) => k.startsWith(`${account}/`) && !r.archived)
|
|
1266
1276
|
.map(([k, r]) => ({ slug: k.split("/")[1], label: r.label, base: r.base, version: r.version ?? null, nativeLanguage: r.nativeLanguage === true })),
|
|
1277
|
+
recipesNote,
|
|
1267
1278
|
// ── — THE MARK-NAME BUDGET, SENT RATHER THAN DUPLICATED ──────────────
|
|
1268
1279
|
//
|
|
1269
1280
|
// The owner typed a product description into the mark-name field and the product accepted it,
|
|
@@ -1547,7 +1558,7 @@ export function makePortalService({
|
|
|
1547
1558
|
if (parts[1] === "api" && parts[2] === "searches" && method === "GET") {
|
|
1548
1559
|
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
1549
1560
|
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
1550
|
-
return { status: 200, json: { account, ...searchesFor(account) } };
|
|
1561
|
+
return { status: 200, json: { account, ...searchesFor(account, principal) } };
|
|
1551
1562
|
}
|
|
1552
1563
|
// /portal/api/compose/read — turn a pasted brief into a filled-in composer.
|
|
1553
1564
|
//
|
|
@@ -2305,7 +2316,7 @@ export function makePortalService({
|
|
|
2305
2316
|
// COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
|
|
2306
2317
|
// path, so the three surfaces stating this route cannot drift apart even if someone tries.
|
|
2307
2318
|
const stdio = seesEverything(principal)
|
|
2308
|
-
? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null })
|
|
2319
|
+
? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })
|
|
2309
2320
|
: null;
|
|
2310
2321
|
|
|
2311
2322
|
// ── THE PAGE IS HANDED ANSWERS, NOT FACTS TO REASON FROM ─────────────
|
|
@@ -2330,8 +2341,9 @@ export function makePortalService({
|
|
|
2330
2341
|
const offers = connectOffers({
|
|
2331
2342
|
stdioRoutes: stdio
|
|
2332
2343
|
? Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
|
|
2333
|
-
[shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null })]))
|
|
2344
|
+
[shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })]))
|
|
2334
2345
|
: {},
|
|
2346
|
+
wsl: isWsl(),
|
|
2335
2347
|
// ONE ADDRESS, and it is the publicly reachable one ( §5). The
|
|
2336
2348
|
// `localAddress` and `clientDoorStanding` inputs are gone with the axis that read them: no
|
|
2337
2349
|
// surface serves a loopback address to anybody, and the door's running-or-not stopped being
|
|
@@ -3364,6 +3376,21 @@ async function readJsonBody(req, limitBytes = 131072) {
|
|
|
3364
3376
|
}
|
|
3365
3377
|
|
|
3366
3378
|
|
|
3379
|
+
/**
|
|
3380
|
+
* The instance a refusal page names: its organisation when one is configured, otherwise the host of the
|
|
3381
|
+
* sign-in service it sends people to (the OIDC issuer, or the Access team's own domain, derived as the
|
|
3382
|
+
* verifier derives it). `null` when neither is set. Read from the environment at call time.
|
|
3383
|
+
*/
|
|
3384
|
+
function instanceIdentity(env = process.env) {
|
|
3385
|
+
if (ORGANISATION_NAME) return `This is ${ORGANISATION_NAME}'s ${BRAND.name} portal`;
|
|
3386
|
+
const issuer = String(env.PORTAL_OIDC_ISSUER ?? "").trim();
|
|
3387
|
+
const team = String(env.CF_ACCESS_TEAM ?? "").trim();
|
|
3388
|
+
let host = null;
|
|
3389
|
+
try { host = issuer ? new URL(issuer).host : null; } catch { host = null; }
|
|
3390
|
+
if (!host && team) host = `${team}.cloudflareaccess.com`;
|
|
3391
|
+
return host ? `This ${BRAND.name} portal signs people in through ${host}` : null;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3367
3394
|
/**
|
|
3368
3395
|
* The sign-in refusal page.
|
|
3369
3396
|
*
|
|
@@ -3384,6 +3411,12 @@ async function readJsonBody(req, limitBytes = 131072) {
|
|
|
3384
3411
|
*/
|
|
3385
3412
|
export function denialPage(status, message) {
|
|
3386
3413
|
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
3414
|
+
// WHICH INSTANCE THIS IS. Reached through a forwarded port, this page read as a local demo refusing its own
|
|
3415
|
+
// user (measured 2026-09-11, a VS Code Remote-SSH forward on WSL); named, a person can tell it is not
|
|
3416
|
+
// theirs. Named by the organisation when one is set, otherwise by the sign-in service the browser is sent
|
|
3417
|
+
// to, which is public by nature: never by this server's own hostname. No advice about ports: the people
|
|
3418
|
+
// who normally see this page are a hosted instance's own clients, with nothing local to move.
|
|
3419
|
+
const who = instanceIdentity();
|
|
3387
3420
|
// The upstream message names the address and the reason. That is useful to the person reading it and
|
|
3388
3421
|
// to whoever they forward it to — but it is a DIAGNOSTIC, so it sits below the fold of the sentence
|
|
3389
3422
|
// that actually tells them what to do.
|
|
@@ -3424,6 +3457,7 @@ ${DOOR_THEME_INIT}
|
|
|
3424
3457
|
<a class="btn primary" href="/portal/sign-out">Sign in as someone else</a>
|
|
3425
3458
|
<a class="btn" href="/portal">Try again</a>
|
|
3426
3459
|
</div>
|
|
3460
|
+
${who ? `<p style="margin-top:20px;font-size:14px">${esc(who)}.</p>` : ""}
|
|
3427
3461
|
<code>${esc(message)}</code>
|
|
3428
3462
|
</div></body></html>`;
|
|
3429
3463
|
}
|
|
@@ -3473,7 +3507,8 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&", "<"
|
|
|
3473
3507
|
//
|
|
3474
3508
|
// The dark ground moved with that change: the block this page used to carry had guessed #17150f/#ece5d8,
|
|
3475
3509
|
// and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
|
|
3476
|
-
export function loginPage({ email, error = null, signedIn = false }) {
|
|
3510
|
+
export function loginPage({ email, error = null, signedIn = false, discarded = false, resetCommand = null }) {
|
|
3511
|
+
const reset = resetCommand || `${bareInvocation("passphrase")} --reset`;
|
|
3477
3512
|
const title = signedIn ? "Signed in" : "Sign in";
|
|
3478
3513
|
const lockup = `<span class="lockup"><span class="mark">${bracketMark(20, "currentColor", PALETTE.crimson)}</span>`
|
|
3479
3514
|
+ `<span class="word">${escHtml(BRAND.name.toLowerCase())}</span></span>`;
|
|
@@ -3517,16 +3552,24 @@ ${signedIn
|
|
|
3517
3552
|
<div><a href="/portal">Go to the portal</a></div>
|
|
3518
3553
|
<form method="post" action="/portal/logout"><button type="submit">Sign out</button></form>`
|
|
3519
3554
|
: `<p>${escHtml(BRAND.name)} portal, as <span class="who">${escHtml(email)}</span>.</p>
|
|
3555
|
+
${discarded ? `<p class="hint">A session this portal did not start, from another ${escHtml(BRAND.name)} on this address or an expired one, was set aside. Sign in below.</p>` : ""}
|
|
3520
3556
|
${error ? `<p class="err">${escHtml(error)}</p>` : ""}
|
|
3521
3557
|
<form method="post" action="/portal/login">
|
|
3522
3558
|
<label for="passphrase">Passphrase</label>
|
|
3523
|
-
<input id="passphrase" name="passphrase" type="password" autocomplete="
|
|
3559
|
+
<input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
|
|
3524
3560
|
<button type="submit">Sign in</button>
|
|
3525
3561
|
</form>
|
|
3526
|
-
<p class="hint">Lost the passphrase? Run <code>${escHtml(
|
|
3562
|
+
<p class="hint">Lost the passphrase? Run <code>${escHtml(reset)}</code> on the machine
|
|
3527
3563
|
running this portal. It mints a new one and prints it once.</p>`}
|
|
3528
3564
|
</div></body></html>`;
|
|
3529
3565
|
}
|
|
3566
|
+
// THE FIELD DOES NOT INVITE THE BROWSER'S SAVED PASSWORDS. Every local install and demo answers on
|
|
3567
|
+
// 127.0.0.1, so a browser that saved one install's passphrase fills it into the next install's page, and
|
|
3568
|
+
// the person is told a passphrase they never typed is wrong (read from the handler, 2026-09-11: the POST
|
|
3569
|
+
// compares only what the browser sent). `new-password` is the value browsers honour on a password field;
|
|
3570
|
+
// `off` is ignored there. The refusal says to check the field, for a browser that fills it regardless.
|
|
3571
|
+
const WRONG_PASSPHRASE = "That passphrase is not correct. If your browser filled the field in, clear it and type the passphrase yourself.";
|
|
3572
|
+
|
|
3530
3573
|
// The cookie the local session rides in. HttpOnly (no script needs it and nothing in the SPA reads it),
|
|
3531
3574
|
// SameSite=Strict (there is no cross-site flow to preserve — every portal navigation is same-origin),
|
|
3532
3575
|
// Path=/ so a report under /portal/report/… carries it too.
|
|
@@ -3608,6 +3651,9 @@ class LocalAuthError extends Error {
|
|
|
3608
3651
|
* cases the right answer is "sign in again", not "resolve this name through the roster and see what it
|
|
3609
3652
|
* gets" — which is what accepting the token's own `sub` would do.
|
|
3610
3653
|
*/
|
|
3654
|
+
/** Whether this request carries a session cookie at all, readable or not. */
|
|
3655
|
+
const hasSessionCookie = (req) => !!parseCookies(req.headers?.cookie)[SESSION_COOKIE];
|
|
3656
|
+
|
|
3611
3657
|
function localSessionIdentity(req, localAuth) {
|
|
3612
3658
|
const token = parseCookies(req.headers?.cookie)[SESSION_COOKIE];
|
|
3613
3659
|
if (!token) return null;
|
|
@@ -3788,17 +3834,25 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
3788
3834
|
res.writeHead(302, { location: to, "cache-control": "no-store", "content-length": 0, ...extra });
|
|
3789
3835
|
return res.end();
|
|
3790
3836
|
};
|
|
3837
|
+
// A SESSION COOKIE THIS PORTAL CANNOT READ IS CLEARED, AND SAID. Cookies ignore ports, so a browser
|
|
3838
|
+
// that visited another Clearotron on this address (an earlier demo, another instance) brings that
|
|
3839
|
+
// cookie here, where it verifies as nothing. Cleared, the next sign-in starts clean; and the page
|
|
3840
|
+
// says an old session was set aside, rather than leave a person to guess what changed.
|
|
3841
|
+
const who = localSessionIdentity(req, localAuth);
|
|
3842
|
+
const stale = !who && hasSessionCookie(req);
|
|
3843
|
+
const setAside = stale ? { "set-cookie": clearSessionCookie({ secure }) } : {};
|
|
3791
3844
|
if (url.pathname === "/portal/login" && req.method === "GET")
|
|
3792
|
-
return page(200, loginPage({ email: localAuth.email, signedIn: !!
|
|
3845
|
+
return page(200, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, signedIn: !!who, discarded: stale }), setAside);
|
|
3793
3846
|
if (url.pathname === "/portal/login" && req.method === "POST") {
|
|
3794
3847
|
// Keyed on the peer address, counted by localAuth.attempts (portal-local-auth.mjs:
|
|
3795
3848
|
// makeAttemptLimiter — a fixed window, NOT the 120/min token bucket the API routes share).
|
|
3796
3849
|
// On a loopback install every attempt keys to 127.0.0.1, so the window is global; that is the
|
|
3797
3850
|
// right answer for one user on one machine and is written down in the limiter's own comment.
|
|
3798
3851
|
if (!localAuth.attempts.take(req.socket?.remoteAddress ?? "unknown"))
|
|
3799
|
-
return page(429, loginPage({ email: localAuth.email, error: "Too many attempts. Wait a few minutes and try again." }));
|
|
3852
|
+
return page(429, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "Too many attempts. Wait a few minutes and try again." }));
|
|
3800
3853
|
let form;
|
|
3801
|
-
|
|
3854
|
+
// A form that could not be read says nothing about the passphrase, so it is not answered as one.
|
|
3855
|
+
try { form = await readFormBody(req); } catch { return page(400, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "The sign-in form could not be read. Reload this page and try again." }), setAside); }
|
|
3802
3856
|
let record;
|
|
3803
3857
|
try {
|
|
3804
3858
|
record = localAuth.credential();
|
|
@@ -3806,14 +3860,19 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
3806
3860
|
// A credential file that has gone corrupt since boot. The operator gets the reason on
|
|
3807
3861
|
// stderr; the browser gets a sentence with no filesystem in it.
|
|
3808
3862
|
log(`local sign-in unavailable: ${String(e?.message ?? e)}`);
|
|
3809
|
-
return page(500, loginPage({ email: localAuth.email, error: "Sign-in is not available. The terminal that started the portal says why." }));
|
|
3863
|
+
return page(500, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "Sign-in is not available. The terminal that started the portal says why." }));
|
|
3864
|
+
}
|
|
3865
|
+
// A REFUSAL FOR ANY REASON BUT THE PASSPHRASE SAYS ITS REASON. "That passphrase is not correct."
|
|
3866
|
+
// sent a person to reset a passphrase that was never wrong. An install with no credential has no
|
|
3867
|
+
// passphrase to be right or wrong about, so saying so gives a caller nothing to guess against.
|
|
3868
|
+
if (!record) {
|
|
3869
|
+
log("local sign-in attempted but no credential is configured — stop the portal and start it again to mint one");
|
|
3870
|
+
return page(401, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "No passphrase is set for this install yet. Stop the portal and start it again to create one." }), setAside);
|
|
3810
3871
|
}
|
|
3811
|
-
|
|
3812
|
-
//
|
|
3813
|
-
// unparseable form all answer "That passphrase is not correct." at 401: anything more
|
|
3814
|
-
// specific tells an unauthenticated caller which half of the credential they got right.
|
|
3872
|
+
// ONE SENTENCE FOR A WRONG PASSPHRASE, and nothing more specific: which half of a credential was
|
|
3873
|
+
// right is exactly what an unauthenticated caller must not learn.
|
|
3815
3874
|
if (!checkPassphrase(record, form.get("passphrase") ?? ""))
|
|
3816
|
-
return page(401, loginPage({ email: localAuth.email,
|
|
3875
|
+
return page(401, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: WRONG_PASSPHRASE }), setAside);
|
|
3817
3876
|
const token = mintSession({ email: localAuth.email, secret: localAuth.secret, ttlSec: localAuth.ttlSec });
|
|
3818
3877
|
return redirect("/portal", { "set-cookie": setSessionCookie(token, { secure, maxAgeSec: localAuth.ttlSec }) });
|
|
3819
3878
|
}
|
|
@@ -4777,6 +4836,9 @@ const PORT = PORT_CHOICE.port;
|
|
|
4777
4836
|
// than a stale copy held in memory.
|
|
4778
4837
|
credential: () => readLocalCredential(credentialPath),
|
|
4779
4838
|
attempts: makeAttemptLimiter({ max: 10, windowMs: 5 * 60 * 1000 }),
|
|
4839
|
+
// The sign-in page's recovery line. It resets the file this portal reads, and names no path on this
|
|
4840
|
+
// machine, because the page is read before anyone has signed in (signInResetCommand says how).
|
|
4841
|
+
resetCommand: signInResetCommand({ credentialPath, npxVersion: inNpxCache() ? ownVersion() : null }),
|
|
4780
4842
|
};
|
|
4781
4843
|
})();
|
|
4782
4844
|
|
|
@@ -420,17 +420,36 @@ export function makeUpstream({ callUpstream, callRecipes = null, recipesOff = nu
|
|
|
420
420
|
const draft = {};
|
|
421
421
|
for (const k of CREATABLE_FIELDS) if (body[k] !== undefined) draft[k] = body[k];
|
|
422
422
|
const r = await call("POST", "/profiles", draft, principal);
|
|
423
|
+
// A STORE THAT CANNOT RECORD THE COMPANY is answered with its path and the git commands that fix it.
|
|
424
|
+
// That sentence is for whoever runs the installation. A manager who holds an organisation cannot act
|
|
425
|
+
// on it and must not be shown the server's paths, so they are told what to do instead.
|
|
426
|
+
if (r.status === 409 && String(r.json?.code ?? "").startsWith("store_") && !seesEverything(principal))
|
|
427
|
+
return { status: 409, json: { code: r.json.code, error: STORE_REFUSAL_FOR_MANAGERS } };
|
|
428
|
+
// AND A CREATE THE STORE COULD NOT UNDO answers 500 with git's last word and the file it left behind,
|
|
429
|
+
// either of which can name a server path. Same reader, same rule.
|
|
430
|
+
if (r.status === 500 && !seesEverything(principal))
|
|
431
|
+
return { status: 500, json: { ...(r.json?.key ? { key: r.json.key } : {}), error: STORE_FAULT_FOR_MANAGERS } };
|
|
423
432
|
if (r.status !== 201 || org == null || !fileCompany) return r;
|
|
424
433
|
const key = r.json?.key ?? r.json?.profile?.key ?? draft.key;
|
|
425
434
|
try { await fileCompany({ tenant: org, account: key }); }
|
|
426
435
|
catch (e) {
|
|
427
|
-
|
|
436
|
+
// The cause is the grants file's own error, which names its path: said to staff only.
|
|
437
|
+
const cause = seesEverything(principal) ? ` (${String(e?.message ?? e).slice(0, 200)})` : "";
|
|
438
|
+
return { status: 500, json: { error: `The company was created, but it could not be filed under its organisation${cause}. A person with access to everything can see it and file it.`, key } };
|
|
428
439
|
}
|
|
429
440
|
return { ...r, json: { ...r.json, tenant: org } };
|
|
430
441
|
},
|
|
431
442
|
};
|
|
432
443
|
}
|
|
433
444
|
|
|
445
|
+
/** The store refusal as a manager who does not run the installation reads it: no path, no command. */
|
|
446
|
+
export const STORE_REFUSAL_FOR_MANAGERS = "No company was created: this installation cannot save new companies yet, "
|
|
447
|
+
+ "and nothing was changed. Ask whoever runs it to fix the company store.";
|
|
448
|
+
|
|
449
|
+
/** A create the store could not undo, as that manager reads it: what happened and who acts, no path. */
|
|
450
|
+
export const STORE_FAULT_FOR_MANAGERS = "The company could not be recorded, and part of it may still be on the server. "
|
|
451
|
+
+ "Tell whoever runs this installation before trying again.";
|
|
452
|
+
|
|
434
453
|
/**
|
|
435
454
|
* What a browser may state when creating a company.
|
|
436
455
|
*
|
package/driver/search-policy.mjs
CHANGED
|
@@ -1078,11 +1078,16 @@ export function loadRecipes({ dir = process.env.CLEAROTRON_RECIPES_DIR || null,
|
|
|
1078
1078
|
const recipes = new Map();
|
|
1079
1079
|
retiredBaseSkips.length = 0;
|
|
1080
1080
|
let customers = [];
|
|
1081
|
+
// ABSENT IS EMPTY; UNREADABLE IS AN ERROR. Only a store that is not there is a store with nothing in it.
|
|
1082
|
+
// One that is there and refuses to be read (permissions, a failing disk) used to come back empty too, and
|
|
1083
|
+
// every door then told its reader the company had no saved searches, while the doors that say "could not
|
|
1084
|
+
// be read" never fired, because they fire on a throw.
|
|
1085
|
+
const absent = (e) => e?.code === "ENOENT" || e?.code === "ENOTDIR";
|
|
1081
1086
|
try { customers = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); }
|
|
1082
|
-
catch { recipeCache = { dir, recipes }; return recipes; } // no recipe store ⇒ empty (not an error)
|
|
1087
|
+
catch (e) { if (!absent(e)) throw e; recipeCache = { dir, recipes }; return recipes; } // no recipe store ⇒ empty (not an error)
|
|
1083
1088
|
for (const customer of customers) {
|
|
1084
1089
|
let files = [];
|
|
1085
|
-
try { files = readdirSync(join(dir, customer)).filter((f) => f.endsWith(".json")); } catch { continue; }
|
|
1090
|
+
try { files = readdirSync(join(dir, customer)).filter((f) => f.endsWith(".json")); } catch (e) { if (!absent(e)) throw e; continue; }
|
|
1086
1091
|
for (const f of files) {
|
|
1087
1092
|
const slug = f.replace(/\.json$/, "");
|
|
1088
1093
|
const r = JSON.parse(readFileSync(join(dir, customer, f), "utf8")); // parse error = loud (config bug)
|