clearotron 0.2.4 → 0.3.0-beta.1
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 +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// POST /portal/login rather than in the SPA (CI greps the built bundle for internal
|
|
14
14
|
// POST /portal/logout variable names, and a login screen has to explain configuration).
|
|
15
15
|
// Absent on a Cloudflare-fronted instance, where the edge is the door.
|
|
16
|
-
// GET /portal/api/me — {
|
|
16
|
+
// GET /portal/api/me — { email, permissions, access, organisations, accounts, … }
|
|
17
17
|
// GET /portal/api/searches[?account=] — registry levels + the account's saved searches
|
|
18
18
|
// POST /portal/api/run/plan — the CONFIRMATION GATE: validate + resolve + honest summary +
|
|
19
19
|
// short-TTL HMAC confirmationToken. Nothing spends yet.
|
|
@@ -41,14 +41,46 @@ import { clientFailureNote } from "../shared/client-failure-note.mjs"; // —
|
|
|
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
43
|
import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The ops credential for one trigger call: the boot token's cap, re-taken against the roster now.
|
|
47
|
+
*
|
|
48
|
+
* Separated from the call site and exported because the service's `trigger` is injected in tests, so
|
|
49
|
+
* logic left inside it can only ever be asserted by reading the source — and the case that matters is
|
|
50
|
+
* the one where something fails.
|
|
51
|
+
*
|
|
52
|
+
* BOTH FALLBACKS HAND BACK THE BOOT TOKEN, never an uncapped one. An empty roster is the single case
|
|
53
|
+
* the boot mint reads as "do not cap", so re-deriving it here would turn an unreadable store into a
|
|
54
|
+
* token good for every account. A mint that throws — an unset secret — lands the same way. The failure
|
|
55
|
+
* direction is the whole point: a stale cap refuses the newest company, which is the bug being fixed;
|
|
56
|
+
* a widened cap admits every company, which is a larger and quieter one.
|
|
57
|
+
*/
|
|
58
|
+
export function opsTokenFor({ bootToken, roster, mint }) {
|
|
59
|
+
if (!Array.isArray(roster) || !roster.length) return bootToken;
|
|
60
|
+
try {
|
|
61
|
+
return mint({ scope: "ops", sub: "portal", verbs: ["start_run", "stop_run"],
|
|
62
|
+
accounts: roster, ttlSec: 300 });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// THE FALLBACK DIRECTION IS RIGHT AND ITS SILENCE WAS NOT. Handing back the boot credential is
|
|
65
|
+
// correct — it is narrower than the one that failed to mint, never wider — but a bare catch here
|
|
66
|
+
// restores the exact refusal this lane exists to remove, for a company created after boot, with
|
|
67
|
+
// nothing anywhere naming the cause. An unset signing secret and an unreadable store both land here
|
|
68
|
+
// and both look like the feature simply not working.
|
|
69
|
+
console.error(`[portal] could not re-mint the engine credential (${String(e?.message ?? e)}) — `
|
|
70
|
+
+ `falling back to the one minted at boot, which does not cover companies created since`);
|
|
71
|
+
return bootToken;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// — the portal became an ISSUANCE PATH here, deliberately and by ruling.
|
|
45
76
|
// A comment further down this file said "the portal cannot mint from here … this process deliberately
|
|
46
77
|
// holds no engine/MCP secrets — issuance is one path on purpose". MEASURED 2026-08-31: that wall is not
|
|
47
78
|
// built. `bin/start.mjs` generates TRADEMARK_MCP_TOKEN_SECRET into `~/.env`, the portal unit loads
|
|
48
79
|
// `EnvironmentFile=%h/.env`, and `childEnv` passes the same value to the portal child — so this process
|
|
49
80
|
// has held the signing secret on both start paths for as long as both have existed. The comment has been
|
|
50
81
|
// corrected in place rather than left to be trusted.
|
|
51
|
-
import { mintToken,
|
|
82
|
+
import { mintToken, loadGrants, resolvePerson } from "../shared/scope.mjs";
|
|
83
|
+
import { withPerson, withCompany } from "../shared/grants-edit.mjs";
|
|
52
84
|
import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
|
|
53
85
|
import { fileURLToPath } from "node:url";
|
|
54
86
|
|
|
@@ -73,7 +105,8 @@ const READ_OFF_NOTE = "Reading a brief is not available on this instance — set
|
|
|
73
105
|
import { basename, dirname, join, resolve as pathResolve } from "node:path";
|
|
74
106
|
import { driverDir } from "../shared/driver-dir.mjs"; //
|
|
75
107
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
76
|
-
import { makePrincipal, assertPrincipal,
|
|
108
|
+
import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers, principalView, seesEverything, mayRun,
|
|
109
|
+
PortalDeny } from "./portal-access.mjs";
|
|
77
110
|
// — the THIRD identity source (after the CF Access edge and, until, the deleted bypass). It
|
|
78
111
|
// decides nothing about access: it turns a passphrase into an email string, which then goes through
|
|
79
112
|
// makePrincipal and assertPrincipal exactly as a Cloudflare-verified address does.
|
|
@@ -92,7 +125,7 @@ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor
|
|
|
92
125
|
import { isDemo, demoPostureLine } from "./demo-posture.mjs";
|
|
93
126
|
import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
|
|
94
127
|
import { makeUpstream } from "./portal-upstream.mjs";
|
|
95
|
-
import { flagView, accessView, observedView, authView
|
|
128
|
+
import { flagView, accessView, observedView, authView } from "./portal-config-view.mjs";
|
|
96
129
|
import { livePosture } from "./flag-snapshot.mjs"; // — for the capture-vs-box comparison only, never for a value
|
|
97
130
|
import { familiesView, groupRuns, ungroupRuns } from "./portal-families.mjs";
|
|
98
131
|
import { validateJob } from "./enqueue-schema.mjs";
|
|
@@ -108,12 +141,12 @@ import { engineCommit } from "./engine-build.mjs"; // — the S
|
|
|
108
141
|
import { engineCommitDate, engineProvenance } from "./engine-build.mjs";
|
|
109
142
|
import { classifySkillsStore } from "./skills-store-provenance.mjs";
|
|
110
143
|
import { makeStaticHandler, reportCsp, docCsp } from "./portal-static.mjs";
|
|
111
|
-
import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
|
|
144
|
+
import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
|
|
112
145
|
import { readReport, reportsOf, resolveReportFile, batchSummaryOf } from "./portal-report.mjs";
|
|
113
146
|
import { readArchivedSet, updateArchived } from "./publish/archive-tags.mjs";
|
|
114
147
|
import { readAcks, setAck, withAcks, ACKNOWLEDGEABLE } from "./portal-acks.mjs";
|
|
115
148
|
import { MAX_BRIEF, makeReadBudget } from "./compose-read.mjs";
|
|
116
|
-
import { BRAND, PALETTE, FONT_LINK, FAVICON_LINK, bracketMark, DOOR_ROOT, DOOR_ROOT_DARK, DOOR_THEME_INIT } from "../shared/brand.mjs";
|
|
149
|
+
import { BRAND, ORGANISATION_NAME, PALETTE, FONT_LINK, FAVICON_LINK, bracketMark, DOOR_ROOT, DOOR_ROOT_DARK, DOOR_THEME_INIT } from "../shared/brand.mjs";
|
|
117
150
|
import { envFrom, pinEnv } from "../shared/env-aliases.mjs"; // — a refusal names the name in force
|
|
118
151
|
import { accessAudience, audienceLabel } from "../shared/access-audience.mjs"; // — F54; jose-free on purpose
|
|
119
152
|
import { resolveNumericSetting } from "./numeric-setting.mjs"; // — the same table the engine enforces, without the throw a rendering surface must not take
|
|
@@ -221,14 +254,21 @@ const productNameOf = (product) => (typeof product === "string" ? reportIdentity
|
|
|
221
254
|
// already fetch one key at a time, so it widens no boundary; it just spares the browser one request per
|
|
222
255
|
// brand owner against a 120/min limit. It is NOT a wildcard: an empty array matches nothing, which is
|
|
223
256
|
// the safe direction, and `null` (every account) stays reachable only from scanAllRuns.
|
|
224
|
-
export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, includeRetired = false,
|
|
257
|
+
export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, generic = null, includeRetired = false,
|
|
225
258
|
// — THE QUEUES THE RUNNER ACTUALLY DRAINS, which is not the same set as the
|
|
226
259
|
// directories under `workspaceRoot`. Defaults to [] so a caller that passes none keeps exactly the
|
|
227
260
|
// behaviour it had; the service passes `config.queueDirs`, the same getter the allowance counter reads.
|
|
228
261
|
queueDirs = [] }) {
|
|
229
262
|
const out = [];
|
|
230
263
|
const only = Array.isArray(account) ? new Set(account) : null;
|
|
231
|
-
|
|
264
|
+
// GENERIC IS MATCHED BY ORGANISATION when the caller says which. `generic` is `{ all: true }` or
|
|
265
|
+
// `{ orgs, unfiled }`: a Generic run matches when it was filed under one of `orgs`, or was filed under
|
|
266
|
+
// none and `unfiled` asks for those too. Without it, `account` alone decides, exactly as before.
|
|
267
|
+
const mine = (owner, organisation = null) => {
|
|
268
|
+
if (owner === "generic" && generic) return generic.all === true
|
|
269
|
+
|| (organisation != null ? (generic.orgs ?? []).includes(organisation) : generic.unfiled === true);
|
|
270
|
+
return only ? only.has(owner) : account === null || owner === account;
|
|
271
|
+
};
|
|
232
272
|
// RETIRED RUNS ARE NOT LISTED HERE — unless the caller is the staff retired view, and asks.
|
|
233
273
|
//
|
|
234
274
|
// `pool-admin archive` has always written this sidecar, and until now the ONLY reader was the old
|
|
@@ -261,7 +301,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
261
301
|
try {
|
|
262
302
|
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
263
303
|
const owner = meta.customerKey || "generic";
|
|
264
|
-
if (!mine(owner)) continue;
|
|
304
|
+
if (!mine(owner, meta.organisation ?? null)) continue;
|
|
265
305
|
// Tagged by runId — the same key pool-admin writes, which is the pool DIRECTORY name. Checking
|
|
266
306
|
// both guards the one case where they differ: a meta whose runId was rewritten by a republish.
|
|
267
307
|
const isRetired = retired.has(meta.runId ?? name) || retired.has(name);
|
|
@@ -281,7 +321,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
281
321
|
const hasReport = docs.length > 0;
|
|
282
322
|
const { bands, toneFor } = ladderOf(meta);
|
|
283
323
|
const band = meta.overall ?? meta.verdict ?? null;
|
|
284
|
-
out.push({ runId: meta.runId ?? name, account: owner,
|
|
324
|
+
out.push({ runId: meta.runId ?? name, account: owner, ...(owner === "generic" ? { organisation: meta.organisation ?? null } : {}),
|
|
285
325
|
title: meta.title ?? meta.matter ?? name, kind: meta.kind ?? "clearance",
|
|
286
326
|
// THE MARK, separate from the report's headline.
|
|
287
327
|
//
|
|
@@ -382,7 +422,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
382
422
|
const s = JSON.parse(readFileSync(join(dir, "status.json"), "utf8"));
|
|
383
423
|
const p = JSON.parse(readFileSync(driverDir(dir, "profile.json"), "utf8"));
|
|
384
424
|
const owner = p.profileKey ?? p.key ?? "generic";
|
|
385
|
-
if (!mine(owner)) return;
|
|
425
|
+
if (!mine(owner, p.organisation ?? null)) return;
|
|
386
426
|
if (s.state === "delivered") return; // the pool row is the delivered face
|
|
387
427
|
// ── — RETIREMENT REACHES A RUN THAT NEVER PUBLISHED ──────────────────
|
|
388
428
|
//
|
|
@@ -419,7 +459,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
419
459
|
// zombie face this state exists to end. pausedKind "operator" tells the UI which words to use.
|
|
420
460
|
const paused = s.state === "postponed" || s.state === "recovering" || s.state === "parked-for-human";
|
|
421
461
|
out.push({ ...(liveRetired ? { retired: true } : {}),
|
|
422
|
-
runId: s.runId, account: owner, title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
|
|
462
|
+
runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
|
|
423
463
|
markName: typeof s.markName === "string" ? s.markName : null,
|
|
424
464
|
// The project, straight off the frozen sidecar this branch already reads as `p`. A LIVE run needs
|
|
425
465
|
// no publish stamp and no back-fill — the sidecar is right there, and freezeProfile has written
|
|
@@ -444,6 +484,11 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
444
484
|
// preserved by writeRunStatus's spread-merge, replaced by the terminal when the honour check
|
|
445
485
|
// fires. The UI derives "Stopping…" from this beside a non-terminal state.
|
|
446
486
|
stopRequestedAt: typeof s.stopRequestedAt === "string" ? s.stopRequestedAt : null,
|
|
487
|
+
// — whether a stop can still prevent delivery. FALSE only once a lane has written it, at the
|
|
488
|
+
// moment it commits to publishing. Absent means the run has not reached that point, which is
|
|
489
|
+
// the truthful reading on this build: both lanes write it immediately after their last cancel
|
|
490
|
+
// read. The screen shows its promise on true and withdraws it on false.
|
|
491
|
+
stoppable: s.stoppable === false ? false : true,
|
|
447
492
|
// A failed run with no reason on screen is a run the user has to phone somebody about. Both
|
|
448
493
|
// fields are already written by every failure path (pipeline-knockout.mjs, the driver's
|
|
449
494
|
// writeRunStatus); the listing simply used to drop them.
|
|
@@ -533,12 +578,12 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
|
|
|
533
578
|
try {
|
|
534
579
|
const j = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
535
580
|
const owner = j.profileKey ?? "generic";
|
|
536
|
-
if (!mine(owner)) continue;
|
|
581
|
+
if (!mine(owner, j.tenant ?? null)) continue;
|
|
537
582
|
// ALL the names, spelled the way the run's own status.json and its delivered meta.json spell
|
|
538
583
|
// them — this took `marks[0].name` and named ONE mark of N, so a client who had just ordered a
|
|
539
584
|
// three-name knockout screen saw a single name on the row that tells them it went in.
|
|
540
585
|
const mark = batchMarkName(j.marks, typeof j.markName === "string" && j.markName ? j.markName : undefined);
|
|
541
|
-
queuedRows.push({ lane: dir, laneIdx, row: { runId: j.id ?? f.replace(/\.json$/, ""), account: owner, title: mark ?? (j.id ?? "Queued"),
|
|
586
|
+
queuedRows.push({ lane: dir, laneIdx, row: { runId: j.id ?? f.replace(/\.json$/, ""), account: owner, ...(owner === "generic" ? { organisation: j.tenant ?? null } : {}), title: mark ?? (j.id ?? "Queued"),
|
|
542
587
|
// THE JOB'S OWN PIPELINE, not the literal "clearance". The row already carried
|
|
543
588
|
// `product: "knockout-search"` and still called itself a clearance, so it was the one row in
|
|
544
589
|
// the listing that contradicted its own product field — and Result.tsx gates the names line on
|
|
@@ -665,10 +710,10 @@ export const concurrentRunsCap = () => {
|
|
|
665
710
|
};
|
|
666
711
|
|
|
667
712
|
function forRole(runs, principal) {
|
|
668
|
-
//
|
|
713
|
+
// A person who sees everything reads failure reasons verbatim; everyone else gets the plain note. That redaction is the ONLY
|
|
669
714
|
// role-shaping left: the held-run suppression is retired (one report, spec 2026-07-30 §5 — a run you
|
|
670
715
|
// have rights to is always listed; the machine-QC record lives on the audit workbook).
|
|
671
|
-
if (principal
|
|
716
|
+
if (seesEverything(principal)) return runs;
|
|
672
717
|
return runs.map((r) => {
|
|
673
718
|
let out = r;
|
|
674
719
|
// — the redaction must take `reasonDetail` with it. `reason` is replaced by a fixed note for a
|
|
@@ -799,6 +844,8 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
|
|
|
799
844
|
carries: Object.freeze([
|
|
800
845
|
"id", "profileKey", "forwarder", "forwarderEmail",
|
|
801
846
|
"markName", "marks", "classes", "goods", "ref", "projectKey",
|
|
847
|
+
// which organisation's Generic, resolved by jobFor from the verified principal — see `stamped`
|
|
848
|
+
"tenant",
|
|
802
849
|
"jurisdictions", "platforms", "geography",
|
|
803
850
|
"product", "recipeKey", "nativeLanguage", "caseLaw", "searchLevel", "deliveryRoute",
|
|
804
851
|
"upfrontInstructions", "commercialFlexibility", "priorUse", "campaignShape", "deadline",
|
|
@@ -809,7 +856,7 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
|
|
|
809
856
|
]),
|
|
810
857
|
// The subset of `carries` whose value is the DOOR's, not the requester's. A body value for one of
|
|
811
858
|
// these is ignored on purpose, so the guard drives them with a lie and requires the lie to lose.
|
|
812
|
-
stamped: Object.freeze(["id", "profileKey", "forwarder", "forwarderEmail", "clientPrincipal"]),
|
|
859
|
+
stamped: Object.freeze(["id", "profileKey", "forwarder", "forwarderEmail", "clientPrincipal", "tenant"]),
|
|
813
860
|
notCarried: Object.freeze({
|
|
814
861
|
// — A CLIENT MAY NEVER DECLARE A RUN A DEMO. The banner it produces says the report
|
|
815
862
|
// is fiction, and a field the requester controls that can mark their own report fiction — or, arriving
|
|
@@ -820,7 +867,7 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
|
|
|
820
867
|
registerFixtures: "a run that reads canned register payloads instead of calling a register. A CLIENT may "
|
|
821
868
|
+ "never ask for that: the result would be fiction wearing a real report's clothes, which is the failure "
|
|
822
869
|
+ "the demo marker exists to prevent one level up. Refused by omission here, and only ever set by a job "
|
|
823
|
-
+ "file somebody wrote deliberately
|
|
870
|
+
+ "file somebody wrote deliberately.",
|
|
824
871
|
promptParts: "the requester's declaration that the prose rides as SIDECAR files (<base>.brief.md, …), "
|
|
825
872
|
+ "which exists because the hand-emitting email-loop agent can only `write` files. The portal composer "
|
|
826
873
|
+ "sends structured fields and writes no sidecars, so a job it built is never in that shape — carrying "
|
|
@@ -1028,12 +1075,16 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
|
|
|
1028
1075
|
|
|
1029
1076
|
export function makePortalService({
|
|
1030
1077
|
poolRoot, workspaceRoot, recipesDir = undefined, secret,
|
|
1031
|
-
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
// this constructor is
|
|
1035
|
-
|
|
1036
|
-
|
|
1078
|
+
grants = null,
|
|
1079
|
+
// WHETHER THIS INSTALL SIGNS PEOPLE IN LOCALLY — one address and one passphrase, which cannot hold a
|
|
1080
|
+
// second person. The People page reads it to disable Add and say why. Injected, like every fact about
|
|
1081
|
+
// the process, because this constructor is pure over its inputs.
|
|
1082
|
+
localSignIn = false,
|
|
1083
|
+
// How the People page and company creation write the grants file. INJECTED so the service stays pure
|
|
1084
|
+
// over its inputs and an arm can watch the write; boot hands it an atomic write to
|
|
1085
|
+
// CLEAROTRON_ACCESS_FILE. Null means this service cannot write the file, and the routes that would
|
|
1086
|
+
// need to say so rather than pretend.
|
|
1087
|
+
writeGrants = null,
|
|
1037
1088
|
// The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
|
|
1038
1089
|
// and the quota pre-check read their ledger beside these, so they count what the wall counts (:
|
|
1039
1090
|
// they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
|
|
@@ -1164,7 +1215,7 @@ export function makePortalService({
|
|
|
1164
1215
|
//
|
|
1165
1216
|
// The owner, on his own install with a partial register: "i cannot press the button for Global
|
|
1166
1217
|
// clearotron search. Why. it doesnt appear disabled, no message etc — but i cant select it." The
|
|
1167
|
-
// product is orderable now (
|
|
1218
|
+
// product is orderable now (ruling on that issue), and what the register does not reach
|
|
1168
1219
|
// is a SENTENCE on a live row rather than the reason a dead one cannot be pressed.
|
|
1169
1220
|
//
|
|
1170
1221
|
// A SEPARATE FIELD, because the two say opposite things to the screen: `unavailableNote`
|
|
@@ -1253,7 +1304,7 @@ export function makePortalService({
|
|
|
1253
1304
|
// reported back as conflicts. On this door the applicant is already known: the account resolved it,
|
|
1254
1305
|
// and the profile behind it is staff-curated. Letting a body re-state it would let a client widen or
|
|
1255
1306
|
// silence their own exclusion, which is a rating-authority change wearing a scope field's clothes.
|
|
1256
|
-
const jobFor = ({ principal, account, body }) => {
|
|
1307
|
+
const jobFor = ({ principal, account, tenant = null, body }) => {
|
|
1257
1308
|
const marks = Array.isArray(body.marks)
|
|
1258
1309
|
? body.marks.map((m) => (typeof m === "string" ? { name: m } : m)).filter((m) => m?.name && String(m.name).trim())
|
|
1259
1310
|
: undefined;
|
|
@@ -1261,6 +1312,11 @@ export function makePortalService({
|
|
|
1261
1312
|
const job = {
|
|
1262
1313
|
id: `portal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1263
1314
|
profileKey: account,
|
|
1315
|
+
// WHICH ORGANISATION'S GENERIC — the pair's other half, on a Generic run only, and resolved from the
|
|
1316
|
+
// principal rather than read off the body: `genericOrgOf` has already refused an organisation this
|
|
1317
|
+
// person does not see. Absent means filed under none, which only a person who sees everything can
|
|
1318
|
+
// order (portal-access.mjs).
|
|
1319
|
+
...(account === "generic" && genericOrgOf(principal, tenant) != null ? { tenant: genericOrgOf(principal, tenant) } : {}),
|
|
1264
1320
|
forwarder: "portal", forwarderEmail: principal.email,
|
|
1265
1321
|
markName: body.markName != null ? String(body.markName) : marks?.[0]?.name,
|
|
1266
1322
|
marks, classes: Array.isArray(body.classes) ? body.classes.map(Number).filter(Number.isFinite) : undefined,
|
|
@@ -1376,7 +1432,8 @@ export function makePortalService({
|
|
|
1376
1432
|
}
|
|
1377
1433
|
|
|
1378
1434
|
async function route(method, path, identity, body = {}, query = {}) {
|
|
1379
|
-
const
|
|
1435
|
+
const grantsHere = grantsNow();
|
|
1436
|
+
const principal = makePrincipal({ email: identity?.email, grants: grantsHere });
|
|
1380
1437
|
const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["portal", ...]
|
|
1381
1438
|
if (parts[0] !== "portal") return { status: 404, json: { error: "not_found" } };
|
|
1382
1439
|
try {
|
|
@@ -1395,18 +1452,35 @@ export function makePortalService({
|
|
|
1395
1452
|
// Cordillera's customer list on the one route every signed-in identity can reach — the exact
|
|
1396
1453
|
// leak /profiles is never proxied for (portal-upstream: "a client reaching it would learn the
|
|
1397
1454
|
// customer base"). `"*"` identities get {} and keep using the roster, which is staff-gated.
|
|
1455
|
+
//
|
|
1456
|
+
// `accountFacts` rides in the SAME loop, under the SAME scoping decision, for the same reason
|
|
1457
|
+
// the names do: the pick panel shows industry, marketplace count and territories per company,
|
|
1458
|
+
// and a client with several grants meets that panel. One loop, so there is one answer to
|
|
1459
|
+
// "which accounts may this identity learn about" rather than two that can drift apart. A `"*"`
|
|
1460
|
+
// identity gets {} here exactly as it does for names and keeps reading the staff-only roster.
|
|
1398
1461
|
let accountNames = {};
|
|
1399
|
-
|
|
1462
|
+
let accountFacts = {};
|
|
1463
|
+
// GENERIC IS NAMED TOO when the person sees an organisation's Generic. It is not a company, so it is
|
|
1464
|
+
// never in `accounts`, and without a name here the switcher printed the raw key at everyone below
|
|
1465
|
+
// everything. An organisation with no company yet is the case with an empty `accounts`, so the
|
|
1466
|
+
// guard is on the names to look up, not on the company list.
|
|
1467
|
+
const named = Array.isArray(principal.accounts)
|
|
1468
|
+
? [...principal.accounts, ...(principal.genericOrgs?.length ? ["generic"] : [])] : [];
|
|
1469
|
+
if (named.length) {
|
|
1400
1470
|
try {
|
|
1471
|
+
const { companyFactsOf } = await import("./profiles.mjs");
|
|
1401
1472
|
const profiles = await loadProfilesImpl();
|
|
1402
|
-
for (const key of
|
|
1403
|
-
const
|
|
1473
|
+
for (const key of named) {
|
|
1474
|
+
const profile = profiles.get(key);
|
|
1475
|
+
const name = profile?.name;
|
|
1404
1476
|
if (typeof name === "string" && name) accountNames[key] = name;
|
|
1477
|
+
if (profile) accountFacts[key] = companyFactsOf(profile);
|
|
1405
1478
|
}
|
|
1406
1479
|
} catch {
|
|
1407
1480
|
// A name is a nicety; the door is not. An unreadable profile store must not lock a user
|
|
1408
1481
|
// out of the portal, so this degrades to the keys the UI already falls back to.
|
|
1409
1482
|
accountNames = {};
|
|
1483
|
+
accountFacts = {};
|
|
1410
1484
|
}
|
|
1411
1485
|
}
|
|
1412
1486
|
// HOW MANY RUNS THIS DEPLOYMENT EXECUTES AT ONCE. Deployment-wide, not account-scoped, which is
|
|
@@ -1434,8 +1508,9 @@ export function makePortalService({
|
|
|
1434
1508
|
// READ ONCE. The payload names it and the program reading below is gated on it; two calls to
|
|
1435
1509
|
// `flagView` here would be two reads of the same file that could disagree with each other.
|
|
1436
1510
|
const meEngineMode = flagView(poolRoot).engineMode;
|
|
1437
|
-
return { status: 200, json: {
|
|
1438
|
-
|
|
1511
|
+
return { status: 200, json: { email: principal.email, ...principalView(principal, grantsHere, accountNames),
|
|
1512
|
+
accounts: principal.accounts, accountNames, accountFacts,
|
|
1513
|
+
concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode,
|
|
1439
1514
|
// WHETHER THE PROGRAM IS ON THIS BOX WHILE THE ENGINE CANNOT SEE IT — true, false, or null
|
|
1440
1515
|
// for "this could not be checked". The screen above renders one of three remedies from it,
|
|
1441
1516
|
// and they are different remedies: install the CLI, restart the service that cannot see it,
|
|
@@ -1451,7 +1526,7 @@ export function makePortalService({
|
|
|
1451
1526
|
// — a button that always fails must not render as available. The reason is
|
|
1452
1527
|
// operator-shaped and staff-only; a client reads the generic sentence the button carries.
|
|
1453
1528
|
controls: { stop: { available: stopControl.available !== false,
|
|
1454
|
-
reason: principal
|
|
1529
|
+
reason: seesEverything(principal) ? (stopControl.reason ?? null) : null } } } };
|
|
1455
1530
|
}
|
|
1456
1531
|
// /portal/api/about — the AGPL §13 source offer ()
|
|
1457
1532
|
//
|
|
@@ -1471,7 +1546,7 @@ export function makePortalService({
|
|
|
1471
1546
|
// /portal/api/searches
|
|
1472
1547
|
if (parts[1] === "api" && parts[2] === "searches" && method === "GET") {
|
|
1473
1548
|
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
1474
|
-
if (!account) return { status: 400, json: { error: "name an account (?account=)
|
|
1549
|
+
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
1475
1550
|
return { status: 200, json: { account, ...searchesFor(account) } };
|
|
1476
1551
|
}
|
|
1477
1552
|
// /portal/api/compose/read — turn a pasted brief into a filled-in composer.
|
|
@@ -1616,7 +1691,7 @@ export function makePortalService({
|
|
|
1616
1691
|
* later. The two can disagree by a run under concurrency; the wall is the one that decides, and
|
|
1617
1692
|
* it refuses by CLARIFYING rather than dropping, so nothing is ever lost to the gap.
|
|
1618
1693
|
*
|
|
1619
|
-
*
|
|
1694
|
+
* A person who sees everything is never checked; the cap binds everyone else, decided here from the principal.
|
|
1620
1695
|
*/
|
|
1621
1696
|
// WHICH PRODUCT IS THIS, AND WHERE WOULD IT POINT — asked ONCE, and everything the plan says
|
|
1622
1697
|
// derives from that one answer: the availability gate, the name, the scope shown at review, and the
|
|
@@ -1639,12 +1714,17 @@ export function makePortalService({
|
|
|
1639
1714
|
try { return quoteForJob({ job, profile, searchPolicy: resolved }); } catch { return null; }
|
|
1640
1715
|
};
|
|
1641
1716
|
|
|
1642
|
-
const quotaRefusal = async (account) => {
|
|
1643
|
-
if (principal
|
|
1717
|
+
const quotaRefusal = async (account, organisation = null) => {
|
|
1718
|
+
if (seesEverything(principal) || !upstream) return null;
|
|
1644
1719
|
let caps = null, capsRead = false;
|
|
1645
1720
|
try {
|
|
1646
|
-
|
|
1647
|
-
|
|
1721
|
+
// Generic's caps are the neutral profile's — one file, every organisation's lane — so they are
|
|
1722
|
+
// read from the store rather than through the settings wall, which answers per company.
|
|
1723
|
+
if (account === "generic") { caps = (await loadProfilesImpl()).get("generic")?.runCaps ?? null; capsRead = true; }
|
|
1724
|
+
else {
|
|
1725
|
+
const r = await upstream.getProfile(principal, account);
|
|
1726
|
+
if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
|
|
1727
|
+
}
|
|
1648
1728
|
} catch { return null; } // cannot read the cap ⇒ do not invent one; the wall still holds
|
|
1649
1729
|
// The same DEFAULT the wall applies (runner.mjs DEFAULT_CLIENT_DAILY_RUNS): a profile READ
|
|
1650
1730
|
// successfully with no dailyRuns is capped, not uncapped. Mirrored rather than imported because
|
|
@@ -1654,7 +1734,7 @@ export function makePortalService({
|
|
|
1654
1734
|
if (!capsRead) return null;
|
|
1655
1735
|
const limit = Number.isInteger(caps?.dailyRuns) ? caps.dailyRuns : DEFAULT_CLIENT_DAILY_RUNS;
|
|
1656
1736
|
if (!Number.isInteger(limit)) return null;
|
|
1657
|
-
const used = accountUsage({ queueDirs: queueDirs(), account });
|
|
1737
|
+
const used = accountUsage({ queueDirs: queueDirs(), account, organisation });
|
|
1658
1738
|
// A COUNT WE COULD NOT TAKE IS NOT A ZERO. `complete: false` means no ledger was reachable, and
|
|
1659
1739
|
// 0 would then mean "nothing recorded" and "nothing readable" at once — the exact confusion that
|
|
1660
1740
|
// made this refusal unreachable for as long as the path was wrong. Same call the branch
|
|
@@ -1680,12 +1760,13 @@ export function makePortalService({
|
|
|
1680
1760
|
|
|
1681
1761
|
// /portal/api/run/plan — the confirmation gate (no spend)
|
|
1682
1762
|
if (parts[1] === "api" && parts[2] === "run" && parts[3] === "plan" && method === "POST") {
|
|
1683
|
-
const
|
|
1684
|
-
|
|
1685
|
-
|
|
1763
|
+
const tenant = body.tenant ?? query.tenant ?? null;
|
|
1764
|
+
const account = assertPrincipal(principal, { account: body.account ?? query.account ?? null, tenant, run: true });
|
|
1765
|
+
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
1766
|
+
const job = jobFor({ principal, account, tenant, body });
|
|
1686
1767
|
const gates = planGates(job);
|
|
1687
1768
|
if (gates.fail) return gates.fail;
|
|
1688
|
-
const overQuota = await quotaRefusal(account);
|
|
1769
|
+
const overQuota = await quotaRefusal(account, job.tenant ?? null);
|
|
1689
1770
|
if (overQuota) return overQuota;
|
|
1690
1771
|
// ONE resolution, and the name, the stage, the turnaround and the effort all come off it.
|
|
1691
1772
|
const { profile: planProfile, resolved: planResolved, scope: planScope } = resolveFor(job);
|
|
@@ -1731,14 +1812,14 @@ export function makePortalService({
|
|
|
1731
1812
|
}
|
|
1732
1813
|
// /portal/api/run — verify + trigger (the ONLY spend path)
|
|
1733
1814
|
if (parts[1] === "api" && parts[2] === "run" && parts.length === 3 && method === "POST") {
|
|
1734
|
-
const account = assertPrincipal(principal, { account: body.account ?? null });
|
|
1815
|
+
const account = assertPrincipal(principal, { account: body.account ?? null, tenant: body.tenant ?? null, run: true });
|
|
1735
1816
|
if (!account) return { status: 400, json: { error: "name an account" } };
|
|
1736
|
-
const job = jobFor({ principal, account, body });
|
|
1817
|
+
const job = jobFor({ principal, account, tenant: body.tenant ?? null, body });
|
|
1737
1818
|
const gates = planGates(job); // re-gated: the token is necessary, never sufficient
|
|
1738
1819
|
if (gates.fail) return gates.fail;
|
|
1739
1820
|
// re-checked for the same reason the gates are: a token minted while the account still had
|
|
1740
1821
|
// allowance must not spend it after another tab has used the last one.
|
|
1741
|
-
const overQuota = await quotaRefusal(account);
|
|
1822
|
+
const overQuota = await quotaRefusal(account, job.tenant ?? null);
|
|
1742
1823
|
if (overQuota) return overQuota;
|
|
1743
1824
|
sweepJtis();
|
|
1744
1825
|
const bad = verifyConfirmation({ secret, token: body.confirmationToken, account, email: principal.email,
|
|
@@ -1747,7 +1828,7 @@ export function makePortalService({
|
|
|
1747
1828
|
const selector = selectorOf(body);
|
|
1748
1829
|
// ── — A DEMO WALKS THE REAL FLOW AND LANDS ON A FINISHED RUN ─────────
|
|
1749
1830
|
//
|
|
1750
|
-
//
|
|
1831
|
+
// Ruling, 2026-08-31, revising his own ruling of an hour earlier: "i think its OK for
|
|
1751
1832
|
// someone to be able to press New Clearance in demo mode and see it work and get the static
|
|
1752
1833
|
// results, right?" — so the form is real, the plan is real, the confirmation is real, and the
|
|
1753
1834
|
// only thing that is not real is the dispatch.
|
|
@@ -1828,10 +1909,10 @@ export function makePortalService({
|
|
|
1828
1909
|
r = await trigger({
|
|
1829
1910
|
...job,
|
|
1830
1911
|
profileKey: account,
|
|
1831
|
-
// The daily-allowance stamp. Set ONLY here,
|
|
1832
|
-
// this is the one place in the system where that
|
|
1833
|
-
//
|
|
1834
|
-
...(principal
|
|
1912
|
+
// The daily-allowance stamp. Set ONLY here, on everyone but a person who sees everything —
|
|
1913
|
+
// this is the one place in the system where that is decided. See checkRunCaps for why the
|
|
1914
|
+
// polarity is positive-only and why every inferred alternative fails unsafely.
|
|
1915
|
+
...(!seesEverything(principal) ? { clientPrincipal: true } : {}),
|
|
1835
1916
|
...(demoRunFlag ? { demoRun: true } : {}),
|
|
1836
1917
|
});
|
|
1837
1918
|
} catch (e) {
|
|
@@ -1842,7 +1923,7 @@ export function makePortalService({
|
|
|
1842
1923
|
// for operators and name infrastructure: the unwired-trigger case reads "PORTAL_MCP_URL /
|
|
1843
1924
|
// PORTAL_OPS_TOKEN unset", which is an internal variable name rendered in a client's browser.
|
|
1844
1925
|
// Staff get it verbatim because they are the ones who can act on it; a client gets the fact.
|
|
1845
|
-
const staff = principal
|
|
1926
|
+
const staff = seesEverything(principal);
|
|
1846
1927
|
// One cause is worth distinguishing even for staff: an instance with no engine attached is not
|
|
1847
1928
|
// a failure, it is an instance that was never finished. Saying "could not be queued" invites
|
|
1848
1929
|
// someone to retry, re-read logs and file a bug against a working system.
|
|
@@ -1867,7 +1948,7 @@ export function makePortalService({
|
|
|
1867
1948
|
//
|
|
1868
1949
|
// /portal/api/run/<runId>/stop — end a run that has already started.
|
|
1869
1950
|
if (parts[1] === "api" && parts[2] === "run" && parts[3] && parts[4] === "stop" && method === "POST") {
|
|
1870
|
-
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
1951
|
+
const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
|
|
1871
1952
|
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
1872
1953
|
const runId = decodeURIComponent(parts[3]);
|
|
1873
1954
|
// OWNERSHIP FIRST, and off the run list rather than off the request: the engine's own account
|
|
@@ -1880,7 +1961,7 @@ export function makePortalService({
|
|
|
1880
1961
|
return { status: 409, json: { ok: false, error: "This run has already finished.", state: mine.state } };
|
|
1881
1962
|
// ── — WHICH STOP THE READER ASKED FOR ────────────────────────────────
|
|
1882
1963
|
//
|
|
1883
|
-
//
|
|
1964
|
+
// Ruling, on his second encounter with the same wait: "a stop is a stop — maybe it should
|
|
1884
1965
|
// be a 'stop immediately or at next boundary to preserve data' kind of question when you press
|
|
1885
1966
|
// it." The driver half landed the mode; this carries the reader's answer to it.
|
|
1886
1967
|
//
|
|
@@ -1903,7 +1984,7 @@ export function makePortalService({
|
|
|
1903
1984
|
// for operators while telling the user only that it did not happen.
|
|
1904
1985
|
const unwired = /not wired|verb|scope/i.test(detail);
|
|
1905
1986
|
return { status: 502, json: { ok: false, unwired,
|
|
1906
|
-
error: principal
|
|
1987
|
+
error: seesEverything(principal) ? detail : `The run could not be stopped just now. It is still running, and ${BRAND.name} can see what happened.` } };
|
|
1907
1988
|
}
|
|
1908
1989
|
// ── — WHICH STOP IS ACTUALLY IN PROGRESS, AND NOTHING ELSE ──────────
|
|
1909
1990
|
//
|
|
@@ -1933,7 +2014,7 @@ export function makePortalService({
|
|
|
1933
2014
|
// A knockout over several names has no combined document, so its assessment — the one piece of
|
|
1934
2015
|
// prose that reads the names against each other — is written to `report.md` and, until this route,
|
|
1935
2016
|
// reached nobody: `meta.reports` lists the per-mark HTMLs only, and the pool path is not one the
|
|
1936
|
-
// edge serves.
|
|
2017
|
+
// edge serves. Ruling 2026-08-26: the grouped page carries it.
|
|
1937
2018
|
//
|
|
1938
2019
|
// ITS OWN ROUTE RATHER THAN A FIELD ON THE RUN ROW. The row is what /portal/api/runs returns for
|
|
1939
2020
|
// every run the caller owns, and it is fetched by every screen that lists runs — where this prose
|
|
@@ -1951,8 +2032,7 @@ export function makePortalService({
|
|
|
1951
2032
|
const dir = join(poolRoot, runId);
|
|
1952
2033
|
let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
1953
2034
|
const owner = meta.customerKey || "generic";
|
|
1954
|
-
if (
|
|
1955
|
-
try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2035
|
+
if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
|
|
1956
2036
|
// ANSWERED FOR EVERY RUN THAT HAS ONE, grouped or not. A gate to grouped runs only stood here
|
|
1957
2037
|
// and was wrong in the way this file keeps guarding against: a single-document run HAS this
|
|
1958
2038
|
// prose — `report.md` is written on every run — so 404 would have said "there is none" about
|
|
@@ -1975,7 +2055,7 @@ export function makePortalService({
|
|
|
1975
2055
|
// /portal/api/queue/<id>/cancel — drop a job that has not started. No spend has happened, so
|
|
1976
2056
|
// there is nothing to account for and no row is left behind.
|
|
1977
2057
|
if (parts[1] === "api" && parts[2] === "queue" && parts[3] && parts[4] === "cancel" && method === "POST") {
|
|
1978
|
-
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
2058
|
+
const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
|
|
1979
2059
|
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
1980
2060
|
const id = decodeURIComponent(parts[3]);
|
|
1981
2061
|
const mine = scanAccountRuns({ poolRoot, workspaceRoot, account, queueDirs: queueDirs() }).find((r) => r.runId === id && r.state === "queued");
|
|
@@ -1987,7 +2067,7 @@ export function makePortalService({
|
|
|
1987
2067
|
const detail = String(e?.message ?? e);
|
|
1988
2068
|
audit({ event: "queue-cancel", by: principal.email, account, id, ok: false, error: detail });
|
|
1989
2069
|
return { status: 502, json: { ok: false, unwired: /not wired|verb|scope/i.test(detail),
|
|
1990
|
-
error: principal
|
|
2070
|
+
error: seesEverything(principal) ? detail : "It could not be cancelled just now. Nothing has been charged." } };
|
|
1991
2071
|
}
|
|
1992
2072
|
audit({ event: "queue-cancel", by: principal.email, account, id, ok: Boolean(r?.ok), action: r?.action ?? null });
|
|
1993
2073
|
// ALREADY-CLAIMED IS A RACE, NOT AN ERROR. The runner picked it up between the click and the
|
|
@@ -2006,7 +2086,7 @@ export function makePortalService({
|
|
|
2006
2086
|
// for no gain. The tenancy wall is the same one every other route uses — the caller's resolved
|
|
2007
2087
|
// account — applied per id.
|
|
2008
2088
|
if (parts[1] === "api" && parts[2] === "queue" && parts[3] === "order" && method === "POST") {
|
|
2009
|
-
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
2089
|
+
const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
|
|
2010
2090
|
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
2011
2091
|
const asked = Array.isArray(body?.order) ? body.order.filter((s) => typeof s === "string" && s) : null;
|
|
2012
2092
|
if (!asked) return { status: 400, json: { error: "send { order: [id, …] }" } };
|
|
@@ -2083,6 +2163,22 @@ export function makePortalService({
|
|
|
2083
2163
|
}
|
|
2084
2164
|
return r;
|
|
2085
2165
|
}
|
|
2166
|
+
// /portal/api/config/companies — CREATE. No account segment, because there is no account yet.
|
|
2167
|
+
//
|
|
2168
|
+
// It does not hang off `profile` for a reason worth stating: every route under that word acts on
|
|
2169
|
+
// ONE company, resolved from the caller, and this one is the act of there not being one. Reusing
|
|
2170
|
+
// the noun would put "the company you are in" and "the company you are making" behind the same
|
|
2171
|
+
// path, which is the conflation the pick panel exists to remove.
|
|
2172
|
+
//
|
|
2173
|
+
// The audit row is written upstream by the create route itself, in the same commit as the file,
|
|
2174
|
+
// so there is none here. An audit call copied from its neighbours would also be wrong twice: it
|
|
2175
|
+
// would test `status === 200` against a 201, and file nothing.
|
|
2176
|
+
if (parts[3] === "companies") {
|
|
2177
|
+
if (parts.length === 4 && method === "POST") return await upstream.createCompany(principal, body);
|
|
2178
|
+
// 404 for a wrong verb, as the profile branch above explains at length: 405 would make this
|
|
2179
|
+
// endpoint distinguishable from one that does not exist.
|
|
2180
|
+
return { status: 404, json: { error: "not_found" } };
|
|
2181
|
+
}
|
|
2086
2182
|
// /portal/api/config/projects[/:project[/{validate,save}]]
|
|
2087
2183
|
if (parts[3] === "projects") {
|
|
2088
2184
|
if (parts.length === 4 && method === "GET") return await upstream.listProjects(principal, acct);
|
|
@@ -2119,16 +2215,21 @@ export function makePortalService({
|
|
|
2119
2215
|
// per-identity: a staff member acting for three clients has three different answers, and one of
|
|
2120
2216
|
// them is never "yours". Cheap enough to fetch beside the composer and the run list.
|
|
2121
2217
|
if (parts[1] === "api" && parts[2] === "usage" && method === "GET") {
|
|
2122
|
-
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
2123
|
-
if (!account) return { status: 400, json: { error: "name an account (?account=)
|
|
2218
|
+
const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null });
|
|
2219
|
+
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
2220
|
+
// Generic is counted per organisation — the lane the wall caps (runner.mjs checkRunCaps).
|
|
2221
|
+
const organisation = account === "generic" ? genericOrgOf(principal, query.tenant ?? null) : null;
|
|
2124
2222
|
// Caps live on the customer profile and are code-owned (never client-editable). A deployment
|
|
2125
2223
|
// with no config surface still answers, with counts and no caps — "we cannot tell you your
|
|
2126
2224
|
// limit" is a better answer than a fabricated one.
|
|
2127
2225
|
let caps = null, capsRead = false;
|
|
2128
2226
|
if (upstream) {
|
|
2129
2227
|
try {
|
|
2130
|
-
|
|
2131
|
-
|
|
2228
|
+
if (account === "generic") { caps = (await loadProfilesImpl()).get("generic")?.runCaps ?? null; capsRead = true; }
|
|
2229
|
+
else {
|
|
2230
|
+
const r = await upstream.getProfile(principal, account);
|
|
2231
|
+
if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
|
|
2232
|
+
}
|
|
2132
2233
|
} catch { /* a settings surface fault must not take the counter down */ }
|
|
2133
2234
|
}
|
|
2134
2235
|
// `complete` rides out with the counts (see usage-ledger.mjs): false means no ledger was read and
|
|
@@ -2138,17 +2239,17 @@ export function makePortalService({
|
|
|
2138
2239
|
// `basis` is dropped here on purpose. What a client is owed is "we could not count", which
|
|
2139
2240
|
// `complete` says; WHICH kind of nothing it was is an operator's diagnosis of our deployment and
|
|
2140
2241
|
// belongs in the audit trail, where the quota pre-check puts it.
|
|
2141
|
-
const { basis: _basis, ...used } = accountUsage({ queueDirs: queueDirs(), account });
|
|
2142
|
-
return { status: 200, json: { account, ...used,
|
|
2242
|
+
const { basis: _basis, ...used } = accountUsage({ queueDirs: queueDirs(), account, organisation });
|
|
2243
|
+
return { status: 200, json: { account, ...(account === "generic" ? { tenant: organisation } : {}), ...used,
|
|
2143
2244
|
// The EFFECTIVE daily allowance: the profile's value, else the default the wall applies — a
|
|
2144
2245
|
// client reading "—" while the wall enforces 2 is the same lie as a wrong count. But a profile
|
|
2145
2246
|
// we could NOT read still reports null: "we cannot tell you your limit" is a different
|
|
2146
2247
|
// statement from "your limit is 2", and only one of them is honest when settings are down.
|
|
2147
2248
|
dailyRuns: Number.isInteger(caps?.dailyRuns) ? caps.dailyRuns : (capsRead ? DEFAULT_CLIENT_DAILY_RUNS : null),
|
|
2148
2249
|
monthlyRuns: caps?.monthlyRuns ?? null, maxQueued: caps?.maxQueued ?? null,
|
|
2149
|
-
//
|
|
2150
|
-
// to someone it does not bind. See checkRunCaps for why
|
|
2151
|
-
capped: principal
|
|
2250
|
+
// A person who sees everything is not capped, and the UI needs to know that to avoid showing an
|
|
2251
|
+
// allowance to someone it does not bind. See checkRunCaps for why it is decided here and nowhere else.
|
|
2252
|
+
capped: !seesEverything(principal) } };
|
|
2152
2253
|
}
|
|
2153
2254
|
|
|
2154
2255
|
// /portal/api/mcp-access — the connection details for driving the engine from your own assistant.
|
|
@@ -2198,7 +2299,7 @@ export function makePortalService({
|
|
|
2198
2299
|
//
|
|
2199
2300
|
// COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
|
|
2200
2301
|
// path, so the three surfaces stating this route cannot drift apart even if someone tries.
|
|
2201
|
-
const stdio = principal
|
|
2302
|
+
const stdio = seesEverything(principal)
|
|
2202
2303
|
? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null })
|
|
2203
2304
|
: null;
|
|
2204
2305
|
|
|
@@ -2292,10 +2393,9 @@ export function makePortalService({
|
|
|
2292
2393
|
if (!url) return { status: 409, json: { error: "no_connector" } };
|
|
2293
2394
|
const identity = principal.email ?? null;
|
|
2294
2395
|
if (!identity) return { status: 403, json: { error: "no_identity" } };
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
}
|
|
2396
|
+
// Enrolled means a principal exists: the door check above has already refused an address with no
|
|
2397
|
+
// access anywhere. Asking for a company list here instead read a person whose only reach is their
|
|
2398
|
+
// organisation's Generic as not enrolled.
|
|
2299
2399
|
let key;
|
|
2300
2400
|
try { key = mintToken({ scope: "account", sub: identity, ttlSec: 90 * 24 * 3600 }); }
|
|
2301
2401
|
catch { return { status: 503, json: { error: "cannot_issue" } }; }
|
|
@@ -2346,13 +2446,13 @@ export function makePortalService({
|
|
|
2346
2446
|
// reader put down". Every branch below goes through it — a route that stamped only the scope=mine
|
|
2347
2447
|
// path would leave the same run acknowledged on one screen and not on another.
|
|
2348
2448
|
const mine = (runs) => withAcks(runs, readAcks(poolRoot, principal?.email));
|
|
2349
|
-
// "All brand owners".
|
|
2350
|
-
// to assertPrincipal —
|
|
2351
|
-
// even by accident.
|
|
2449
|
+
// "All brand owners". ONLY FOR A PERSON WHO SEES EVERYTHING, and it is a distinct code path rather
|
|
2450
|
+
// than a wildcard passed to assertPrincipal — nobody else may reach a branch that skips the account
|
|
2451
|
+
// resolution, even by accident. Anyone else who asks for it gets the same 404 as any account not theirs.
|
|
2352
2452
|
if (query.account === "*") {
|
|
2353
|
-
// THE DOOR FIRST. This branch read `principal
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
2453
|
+
// THE DOOR FIRST. This branch once read a field off `principal` directly, and `principal` is null
|
|
2454
|
+
// for an identity with no access anywhere — the exact caller this route most needs to refuse.
|
|
2455
|
+
// Reading a field off null threw a TypeError, which is not a PortalDeny, so the
|
|
2356
2456
|
// catch at the bottom of route() rethrew it and the handler answered 500 "internal". An
|
|
2357
2457
|
// unenrolled prober therefore got a SERVER ERROR from the one route that lists every customer,
|
|
2358
2458
|
// while every other surface refused them cleanly — and a 500 is both the wrong answer and a
|
|
@@ -2363,7 +2463,7 @@ export function makePortalService({
|
|
|
2363
2463
|
// account (portal-access.mjs), so the staff-only rule below is untouched: a client still gets
|
|
2364
2464
|
// the plain 404 on the line after this one.
|
|
2365
2465
|
assertPrincipal(principal, { door: true });
|
|
2366
|
-
if (principal
|
|
2466
|
+
if (!seesEverything(principal)) return { status: 404, json: { error: "not_found" } };
|
|
2367
2467
|
// One pass over the pool, not one request per account. The alternative — the browser fanning
|
|
2368
2468
|
// out across the roster — would spend a roster-sized chunk of the 120/min rate limit on every
|
|
2369
2469
|
// poll, which is exactly what the limiter is there to stop.
|
|
@@ -2387,7 +2487,7 @@ export function makePortalService({
|
|
|
2387
2487
|
// has to ask who is looking, which is the whole point — there is no staff layout.
|
|
2388
2488
|
if (parts[1] === "api" && parts[2] === "runs" && method === "GET" && query.scope === "mine") {
|
|
2389
2489
|
assertPrincipal(principal, { door: true });
|
|
2390
|
-
if (principal
|
|
2490
|
+
if (seesEverything(principal)) {
|
|
2391
2491
|
return { status: 200, json: { account: "*", runs: mine(forRole(scanAllRuns({ poolRoot, workspaceRoot, queueDirs: queueDirs() }), principal)) } };
|
|
2392
2492
|
}
|
|
2393
2493
|
// Resolved from `principal.accounts`, NEVER from the query — this is the same set
|
|
@@ -2396,17 +2496,25 @@ export function makePortalService({
|
|
|
2396
2496
|
// `accounts === "*"` is the grants-file-absent posture (enforcement OFF). That is a
|
|
2397
2497
|
// SENTINEL, NOT A LIST: expanding it here would turn a missing config file into a
|
|
2398
2498
|
// cross-tenant read, so it falls through to the same refusal as holding nothing.
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2499
|
+
// Their companies, and the Generic of each organisation they hold whole.
|
|
2500
|
+
if (Array.isArray(principal.accounts) && (principal.accounts.length || principal.genericOrgs?.length)) {
|
|
2501
|
+
return { status: 200, json: { account: "*", runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account: principal.accounts,
|
|
2502
|
+
generic: { orgs: principal.genericOrgs ?? [] }, queueDirs: queueDirs() }), principal)) } };
|
|
2402
2503
|
}
|
|
2403
2504
|
return { status: 404, json: { error: "not_found" } };
|
|
2404
2505
|
}
|
|
2405
|
-
const account = assertPrincipal(principal, { account: query.account ?? null });
|
|
2506
|
+
const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null });
|
|
2406
2507
|
if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
|
|
2407
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
|
|
2508
|
+
// GENERIC IS LISTED PER ORGANISATION. assertPrincipal has already refused an organisation whose
|
|
2509
|
+
// Generic this person does not see. A named or implied organisation lists its own Generic runs,
|
|
2510
|
+
// and a person who sees everything also gets the ones filed under none — on a one-organisation
|
|
2511
|
+
// install those are that organisation's. Unnamed, with several organisations, a person who sees
|
|
2512
|
+
// everything lists every Generic run, which is what they saw before organisations existed.
|
|
2513
|
+
if (account === "generic") {
|
|
2514
|
+
const org = genericOrgOf(principal, query.tenant ?? null);
|
|
2515
|
+
const generic = org == null ? { all: true } : { orgs: [org], unfiled: seesEverything(principal) };
|
|
2516
|
+
return { status: 200, json: { account, tenant: org, runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account: [], generic, queueDirs: queueDirs() }), principal)) } };
|
|
2517
|
+
}
|
|
2410
2518
|
return { status: 200, json: { account, runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account, queueDirs: queueDirs() }), principal)) } };
|
|
2411
2519
|
}
|
|
2412
2520
|
// ── /portal/api/ack — "I have seen that one" ─────────────────────────────────────────────
|
|
@@ -2467,8 +2575,7 @@ export function makePortalService({
|
|
|
2467
2575
|
const dir = join(poolRoot, runId);
|
|
2468
2576
|
let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2469
2577
|
const owner = meta.customerKey || "generic";
|
|
2470
|
-
if (
|
|
2471
|
-
try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2578
|
+
if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
|
|
2472
2579
|
// NOT staff-only any more. This route used to 404 a client on role alone — "the workbook is the
|
|
2473
2580
|
// working paper behind the opinion, not the opinion" — with the note that widening it was a
|
|
2474
2581
|
// disclosure decision and not one to make in passing. The owner made it on 2026-07-27, in the same
|
|
@@ -2504,8 +2611,8 @@ export function makePortalService({
|
|
|
2504
2611
|
const dir = join(poolRoot, runId);
|
|
2505
2612
|
let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2506
2613
|
const owner = meta.customerKey || "generic";
|
|
2507
|
-
|
|
2508
|
-
|
|
2614
|
+
// Foreign is 404, never 403, and a Generic run is its organisation's — the same question the connector asks (LEAK-#9 alignment).
|
|
2615
|
+
if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
|
|
2509
2616
|
// ONE REPORT PER MARK: `/portal/report/<runId>/` serves a run that has one document, and
|
|
2510
2617
|
// `/portal/report/<runId>/<slug>/` serves one name's document out of a batch. A batch has no
|
|
2511
2618
|
// run-level document at all — resolveReportFile returns null and this 404s — because serving mark
|
|
@@ -2533,7 +2640,7 @@ export function makePortalService({
|
|
|
2533
2640
|
// The injection stays where it is (portal-report.mjs's FEEDBACK_CSS/FEEDBACK_JS, still exercised
|
|
2534
2641
|
// by portal-report.test.mjs's `feedback: true` arms). The owner ruled disable, not delete, so
|
|
2535
2642
|
// re-enabling the document half is this argument coming back.
|
|
2536
|
-
return { status: 200, html: readReport(dir, { log: auditLog, staff: principal
|
|
2643
|
+
return { status: 200, html: readReport(dir, { log: auditLog, staff: seesEverything(principal), poolRoot, file: reportFile }) };
|
|
2537
2644
|
}
|
|
2538
2645
|
// POST /portal/api/feedback — a lawyer flags one finding on a delivered report.
|
|
2539
2646
|
//
|
|
@@ -2573,10 +2680,9 @@ export function makePortalService({
|
|
|
2573
2680
|
const dir = join(poolRoot, runId);
|
|
2574
2681
|
let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2575
2682
|
// Ownership, exactly as GET /portal/report/<id> checks it — foreign is 404, never 403, and
|
|
2576
|
-
//
|
|
2683
|
+
// a Generic run is its organisation's. A reader who cannot READ the report cannot flag it either.
|
|
2577
2684
|
const owner = meta.customerKey || "generic";
|
|
2578
|
-
if (
|
|
2579
|
-
try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
|
|
2685
|
+
if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
|
|
2580
2686
|
|
|
2581
2687
|
const verdict = typeof body?.verdict === "string" ? body.verdict : "";
|
|
2582
2688
|
if (!VERDICTS.has(verdict))
|
|
@@ -2700,9 +2806,11 @@ export function makePortalService({
|
|
|
2700
2806
|
});
|
|
2701
2807
|
return { status: 201, json: { id: rec.id } };
|
|
2702
2808
|
}
|
|
2703
|
-
// /portal/admin/* —
|
|
2809
|
+
// /portal/admin/* — the install-wide surfaces, for a person who sees everything, and People, for a
|
|
2810
|
+
// person with Manage. Everyone else gets 404: the surface does not exist for them.
|
|
2704
2811
|
if (parts[1] === "admin") {
|
|
2705
|
-
assertPrincipal(principal,
|
|
2812
|
+
assertPrincipal(principal, parts[2] === "access" || parts[2] === "people"
|
|
2813
|
+
? { door: true, manage: true } : { door: true, everything: true });
|
|
2706
2814
|
if (parts[2] === "roster" && method === "GET") {
|
|
2707
2815
|
const profiles = await loadProfilesImpl();
|
|
2708
2816
|
// `generic` IS in the roster — this route is staff-only (asserted above), and untagged runs
|
|
@@ -2710,7 +2818,13 @@ export function makePortalService({
|
|
|
2710
2818
|
// by typing ?account=generic but invisible in the switcher, which is how they stop being
|
|
2711
2819
|
// looked at. The client-facing boundary is unchanged: /portal/api/runs and the report route
|
|
2712
2820
|
// both 404 `generic` for a non-staff principal.
|
|
2713
|
-
|
|
2821
|
+
// THE FACTS RIDE WITH THE NAME. The pick panel tells companies apart by industry, marketplace
|
|
2822
|
+
// count and territories, and it renders for staff from here and for a client from /me. Shaping
|
|
2823
|
+
// both through `companyFactsOf` is what stops the same company reading two ways depending on
|
|
2824
|
+
// who signed in. Dynamic import deliberately: `profiles.mjs` captures the store directory at
|
|
2825
|
+
// MODULE LOAD (see this file's note above), so it is never pulled in at our own load time.
|
|
2826
|
+
const { companyFactsOf } = await import("./profiles.mjs");
|
|
2827
|
+
return { status: 200, json: { customers: [...profiles.values()].map((p) => ({ key: p.key, name: p.name, ...companyFactsOf(p) })) } };
|
|
2714
2828
|
}
|
|
2715
2829
|
// /portal/admin/config — what this deployment actually has switched on. Read from the SNAPSHOT,
|
|
2716
2830
|
// never from process.env: this process has no engine environment, so asking its own env would
|
|
@@ -2727,7 +2841,7 @@ export function makePortalService({
|
|
|
2727
2841
|
// below, so it is the authoritative source. Fed from process.env at the seam rather than
|
|
2728
2842
|
// inside authView, so the one process entitled to answer is visibly the one reading.
|
|
2729
2843
|
//
|
|
2730
|
-
// AND THE ENVIRONMENT IS READ ONCE MORE, FOR ONE PURPOSE
|
|
2844
|
+
// AND THE ENVIRONMENT IS READ ONCE MORE, FOR ONE PURPOSE: to say whether
|
|
2731
2845
|
// the capture still DESCRIBES this box, never to supply a value. A deployment being
|
|
2732
2846
|
// configured runs nothing, so its capture cannot age into a warning while its contents drift
|
|
2733
2847
|
// — the disagreement is the signal the age never was. Degraded rather than fatal: a page that
|
|
@@ -2871,7 +2985,7 @@ export function makePortalService({
|
|
|
2871
2985
|
// /portal/admin/access — who is granted what, and where an enrolment is half done.
|
|
2872
2986
|
if (parts[2] === "access" && method === "GET") {
|
|
2873
2987
|
const { loadProfiles } = await import("./profiles.mjs");
|
|
2874
|
-
const
|
|
2988
|
+
const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
|
|
2875
2989
|
// Where to go to change this. The stat is done HERE rather than inside accessView because
|
|
2876
2990
|
// that function is pure and its tests call it with no filesystem at all; giving it IO would
|
|
2877
2991
|
// cost that for a filename and a date.
|
|
@@ -2884,7 +2998,46 @@ export function makePortalService({
|
|
|
2884
2998
|
const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
|
|
2885
2999
|
if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
|
|
2886
3000
|
} catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
|
|
2887
|
-
return { status: 200, json: accessView({ grants:
|
|
3001
|
+
return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn }) };
|
|
3002
|
+
}
|
|
3003
|
+
// /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
|
|
3004
|
+
//
|
|
3005
|
+
// THE ADDER'S OWN REACH BOUNDS WHAT THEY GIVE. A point outside it is a 404, exactly as asking to
|
|
3006
|
+
// see it would be. Switches belong to the person, not to a point, so they are set only when the
|
|
3007
|
+
// person's whole access sits inside the adder's; otherwise the points are added, the switches stay
|
|
3008
|
+
// as they were, and the answer says which happened. Nobody sets their own switches, and nobody
|
|
3009
|
+
// gives Run without holding it.
|
|
3010
|
+
if (parts[2] === "people" && parts.length === 3 && method === "POST") {
|
|
3011
|
+
if (localSignIn) return { status: 409, json: { error: "local_sign_in" } };
|
|
3012
|
+
if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
|
|
3013
|
+
const email = String(body?.email ?? "").trim().toLowerCase();
|
|
3014
|
+
if (!email || email.indexOf("@") <= 0 || email.indexOf("@") !== email.lastIndexOf("@"))
|
|
3015
|
+
return { status: 400, json: { error: "Enter one email address." } };
|
|
3016
|
+
const points = [];
|
|
3017
|
+
for (const a of Array.isArray(body?.access) ? body.access : []) {
|
|
3018
|
+
const key = typeof a?.key === "string" ? a.key : "";
|
|
3019
|
+
if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
|
|
3020
|
+
else if (a?.kind === "company" && principal.accountOrgs?.[key]) points.push({ tenant: principal.accountOrgs[key], account: key });
|
|
3021
|
+
else return { status: 404, json: { error: "not_found" } };
|
|
3022
|
+
}
|
|
3023
|
+
if (!points.length) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
|
|
3024
|
+
const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true };
|
|
3025
|
+
if (want.run && !mayRun(principal)) return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
|
|
3026
|
+
const existing = resolvePerson(email, grantsHere);
|
|
3027
|
+
const setSwitches = email !== principal.email && (!existing || reachCovers(principal, existing));
|
|
3028
|
+
let next;
|
|
3029
|
+
try { next = withPerson(grantsHere, { email, points, switches: want, setSwitches }); }
|
|
3030
|
+
catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
|
|
3031
|
+
try { await writeGrants(next); }
|
|
3032
|
+
catch (e) {
|
|
3033
|
+
audit({ event: "person-add", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
|
|
3034
|
+
return { status: 500, json: { error: "The guest list could not be written, so nobody was added." } };
|
|
3035
|
+
}
|
|
3036
|
+
audit({ event: "person-add", by: principal.email, person: email, points: points.length, switchesApplied: setSwitches, ok: true, status: 201 });
|
|
3037
|
+
const { loadProfiles } = await import("./profiles.mjs");
|
|
3038
|
+
const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
|
|
3039
|
+
const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email === email) ?? null;
|
|
3040
|
+
return { status: 201, json: { person, switchesApplied: setSwitches } };
|
|
2888
3041
|
}
|
|
2889
3042
|
// /portal/admin/observed — who has actually USED this instance lately, from the audit log.
|
|
2890
3043
|
//
|
|
@@ -2988,7 +3141,7 @@ export function makePortalService({
|
|
|
2988
3141
|
* "a comment asserting a wall that is not built is worse than no comment: it is the reason nobody goes
|
|
2989
3142
|
* to look". Nobody went to look, for the same reason.
|
|
2990
3143
|
*
|
|
2991
|
-
* The portal is now an issuance path ON PURPOSE — `/portal/api/connect-key`,
|
|
3144
|
+
* The portal is now an issuance path ON PURPOSE — `/portal/api/connect-key`, ruling 2026-08-31 —
|
|
2992
3145
|
* minting an account key for the CALLER and for nobody else. What it still cannot do is mint for another
|
|
2993
3146
|
* identity: `sub` comes from the authenticated principal and never from the request. What it CAN also do
|
|
2994
3147
|
* is read the credential it was
|
|
@@ -3037,7 +3190,7 @@ export function bundleFreshnessCached(present, { now = Date.now(), ttl = BUNDLE_
|
|
|
3037
3190
|
/**
|
|
3038
3191
|
* How to re-mint the trigger token WITH an accounts cap, and without changing anything else about it.
|
|
3039
3192
|
*
|
|
3040
|
-
* ──
|
|
3193
|
+
* ── THE RE-MINT COMMAND ──────────────────────────────────────────────────────────────────────────
|
|
3041
3194
|
*
|
|
3042
3195
|
* This used to be a fixed string in the warning itself: `--sub portal --verbs start_run,stop_run`. An
|
|
3043
3196
|
* operator whose token carries a third verb, or a different subject, was told to re-mint as something
|
|
@@ -3528,7 +3681,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
3528
3681
|
// Null when unset or unrecognised, which is an absence and reads as one. No inference from the
|
|
3529
3682
|
// account or the path: a guessed box is worse than an unknown one, because it answers wrongly
|
|
3530
3683
|
// instead of leaving the question open.
|
|
3531
|
-
// ── IS THE BUNDLE THE ONE ITS SOURCES WOULD BUILD?
|
|
3684
|
+
// ── IS THE BUNDLE THE ONE ITS SOURCES WOULD BUILD? ──────────────────────────────────────────
|
|
3532
3685
|
//
|
|
3533
3686
|
// `ui` had two states, present and absent, and no third for *present and older than the sources
|
|
3534
3687
|
// it was built from*. On the source route `git pull` can never update `portal-ui/dist` — it is
|
|
@@ -3872,7 +4025,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
3872
4025
|
// guards — a boot that is going to refuse for a missing grants file must not write a passphrase
|
|
3873
4026
|
// first and tell somebody to keep it.
|
|
3874
4027
|
if (!LOCAL_USER) {
|
|
3875
|
-
log("FATAL: PORTAL_AUTH_MODE=local names no user — set PORTAL_LOCAL_USER to the one email address that signs in here, and
|
|
4028
|
+
log("FATAL: PORTAL_AUTH_MODE=local names no user — set PORTAL_LOCAL_USER to the one email address that signs in here, and give that same address an entry in the grants file (" + "CLEAROTRON_ACCESS_FILE" + "). Refusing to start.");
|
|
3876
4029
|
process.exit(1);
|
|
3877
4030
|
}
|
|
3878
4031
|
if (!LOCAL_USER.includes("@") || LOCAL_USER.indexOf("@") !== LOCAL_USER.lastIndexOf("@")) {
|
|
@@ -3939,12 +4092,11 @@ const PORT = PORT_CHOICE.port;
|
|
|
3939
4092
|
// the difference is the point.
|
|
3940
4093
|
//
|
|
3941
4094
|
// shared/scope.mjs, first line of accountsForEmail: `if (!grants) return "*"`. With CLEAROTRON_ACCESS_FILE
|
|
3942
|
-
// unset,
|
|
3943
|
-
//
|
|
3944
|
-
//
|
|
3945
|
-
//
|
|
3946
|
-
//
|
|
3947
|
-
// only outward sign is that the account picker offers names its owner has never heard of.
|
|
4095
|
+
// unset, that flat view answers "every customer" for every identity. The portal no longer reads it that
|
|
4096
|
+
// way — `makePrincipal` resolves nobody without a grants file — but other readers of the same function
|
|
4097
|
+
// did, and a portal running with no file would be an install nobody can enter and nothing explains.
|
|
4098
|
+
// Before the access model this was a silent read-all across the whole book of business, and the guard
|
|
4099
|
+
// is kept for the reason it was written: the absence of the one file that decides must stop the start.
|
|
3948
4100
|
//
|
|
3949
4101
|
// UNCONDITIONAL, unlike the MCP's. There the guard sits inside the auth-disabled branch, because with
|
|
3950
4102
|
// auth ON a caller has proven a firm email domain and read-all is what firm staff are supposed to
|
|
@@ -3953,12 +4105,8 @@ const PORT = PORT_CHOICE.port;
|
|
|
3953
4105
|
// "auth is ON" says nothing whatever about whether the caller may see everything. In both modes the
|
|
3954
4106
|
// grants file is the only thing that decides, so in both modes it is required.
|
|
3955
4107
|
//
|
|
3956
|
-
//
|
|
3957
|
-
//
|
|
3958
|
-
// green while the read-all is live. It answers "could anybody sign in?", not "is anybody bounded?".
|
|
3959
|
-
//
|
|
3960
|
-
// An empty roster is a legitimate answer: a file containing only {"tenants":{}} admits staff by domain
|
|
3961
|
-
// and grants no client anything, which is the correct starting state for a fresh instance. What is not
|
|
4108
|
+
// An empty roster is a legitimate answer: a file containing only {"tenants":{}} admits nobody yet, which
|
|
4109
|
+
// is where a fresh instance starts before setup writes its first person. What is not
|
|
3962
4110
|
// legitimate is having no file, because that is indistinguishable from "not configured yet" and means
|
|
3963
4111
|
// the opposite of what it looks like.
|
|
3964
4112
|
if (!envFrom(process.env, "CLEAROTRON_ACCESS_FILE")) {
|
|
@@ -3975,16 +4123,12 @@ const PORT = PORT_CHOICE.port;
|
|
|
3975
4123
|
// throws when set-but-unreadable), never a silent fallback.
|
|
3976
4124
|
try { loadGrants({}); } catch (e) { log(`FATAL: grants file unreadable: ${e.message}`); process.exit(1); }
|
|
3977
4125
|
const grants = () => loadGrants({});
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
const staffRule = staffRuleSource({
|
|
3985
|
-
value: process.env.PORTAL_STAFF_DOMAINS, envLoad: loaded,
|
|
3986
|
-
unitEnvFile: unitEnvPath(), cliEnvFile: envLocalPath(),
|
|
3987
|
-
});
|
|
4126
|
+
// How the People page and company creation write the grants file: atomically, so the per-request
|
|
4127
|
+
// reader sees the old file or the new one and never half of either.
|
|
4128
|
+
const writeGrants = async (g) => {
|
|
4129
|
+
const { atomicWrite } = await import("./progress.mjs");
|
|
4130
|
+
atomicWrite(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"), `${JSON.stringify(g, null, 2)}\n`);
|
|
4131
|
+
};
|
|
3988
4132
|
|
|
3989
4133
|
const { config } = await import("./driver.config.mjs");
|
|
3990
4134
|
const { appendFileSync: append } = await import("node:fs");
|
|
@@ -4059,7 +4203,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4059
4203
|
+ "`clearotron start` derives PORTAL_MCP_URL from its resolved ports; a box that launches this "
|
|
4060
4204
|
+ "service directly must set it to the engine door's own ORIGIN (no /mcp — the client appends it).");
|
|
4061
4205
|
}
|
|
4062
|
-
// ── AND WHETHER THE DOOR WILL TAKE IT
|
|
4206
|
+
// ── AND WHETHER THE DOOR WILL TAKE IT ────────────────────────────────────────────────────────────
|
|
4063
4207
|
//
|
|
4064
4208
|
// Every line in this block describes the PORTAL'S HALF — the token, its verbs, its accounts, its
|
|
4065
4209
|
// expiry — and not one of them asks whether the door it is pointed at will accept a caller shaped
|
|
@@ -4104,7 +4248,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4104
4248
|
// than judged — with the mechanism, so a reader knows what to look at. Claiming more than was
|
|
4105
4249
|
// measured is how a check starts refusing deployments that work.
|
|
4106
4250
|
if (lane.state === "fail") log(`WARNING: trigger lane — ${lane.message}`);
|
|
4107
|
-
// NOT `WARNING
|
|
4251
|
+
// NOT `WARNING:`. A connection nothing answered at boot is a startup race far
|
|
4108
4252
|
// more often than an outage — the units carry no ordering, so the portal routinely binds first —
|
|
4109
4253
|
// and the old text stated an outage in the present tense with a 502 attached to it. It printed on
|
|
4110
4254
|
// every reboot, on boxes that were fine. Reserving `WARNING:` for what did answer wrongly is what
|
|
@@ -4123,13 +4267,29 @@ const PORT = PORT_CHOICE.port;
|
|
|
4123
4267
|
}
|
|
4124
4268
|
}
|
|
4125
4269
|
|
|
4270
|
+
// ── A RETIRED SETTING STILL SET IS SAID HERE, BESIDE THE OTHER GAPS ─────────────────────────────────
|
|
4271
|
+
//
|
|
4272
|
+
// Who may use the portal used to follow PORTAL_STAFF_DOMAINS: everyone at a listed email domain was
|
|
4273
|
+
// staff. That rule is deleted and nothing configures from the setting, so a deployment that still sets
|
|
4274
|
+
// it starts normally and admits nobody by domain — and the setting is the first place an operator looks
|
|
4275
|
+
// when people cannot sign in. Named in this table and looked up, never read as a setting, because it
|
|
4276
|
+
// decides nothing any more.
|
|
4277
|
+
const RETIRED_PORTAL_SETTINGS = {
|
|
4278
|
+
PORTAL_STAFF_DOMAINS: "who may use the portal is each person's own entry in the grants file "
|
|
4279
|
+
+ "(CLEAROTRON_ACCESS_FILE), with \"everything\": true under \"people\" for anyone who should see every "
|
|
4280
|
+
+ "company. No email domain admits anyone.",
|
|
4281
|
+
};
|
|
4282
|
+
for (const [name, now] of Object.entries(RETIRED_PORTAL_SETTINGS)) {
|
|
4283
|
+
if (process.env[name]) log(`WARNING: ${name} is set and ignored — ${now}`);
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4126
4286
|
if (OPS_TOKEN) {
|
|
4127
4287
|
const expiry = posture.expiresAt ? ` expires=${posture.expiresAt.slice(0, 10)} (${posture.daysLeft}d)` : " expires=UNKNOWN";
|
|
4128
4288
|
// PREFIXED WHEN THE LANE IS DEAD, so this line cannot be read on its own as evidence of a working
|
|
4129
4289
|
// lane — a journal is skimmed by grepping one phrase, and the phrase people grep is this one.
|
|
4130
4290
|
log(`trigger lane${laneWired ? "" : " (NOT WIRED — see above)"}: ops token sub=${posture.sub ?? "-"} verbs=${posture.verbs?.join(",") ?? "(full ops)"} accounts=${posture.accounts?.join(",") ?? "UNCAPPED (every account)"}${expiry}${posture.readable ? "" : " — token payload unreadable, posture unknown"}`);
|
|
4131
4291
|
if (!posture.accountCapped) {
|
|
4132
|
-
// ── THE PRINTED COMMAND IS DERIVED FROM THE TOKEN IN HAND
|
|
4292
|
+
// ── THE PRINTED COMMAND IS DERIVED FROM THE TOKEN IN HAND ──────────────────────────────────────
|
|
4133
4293
|
//
|
|
4134
4294
|
// It used to be a fixed string: `--sub portal --verbs start_run,stop_run`. An operator whose
|
|
4135
4295
|
// token carries a third verb, or a different subject, is told to re-mint as something NARROWER
|
|
@@ -4200,13 +4360,48 @@ const PORT = PORT_CHOICE.port;
|
|
|
4200
4360
|
}
|
|
4201
4361
|
}
|
|
4202
4362
|
const { mcpToolCall } = await import("./portal-mcp-client.mjs");
|
|
4363
|
+
// ── the trigger credential is stamped WHEN A SEARCH STARTS, not when the portal booted ────────────
|
|
4364
|
+
//
|
|
4365
|
+
// `bin/start.mjs` mints PORTAL_OPS_TOKEN capped to the roster as it stood at boot. That cap is a real
|
|
4366
|
+
// second wall and it stays. What was wrong is its AGE: a company created afterwards is not in it, so
|
|
4367
|
+
// the portal offered the clearance and the engine door refused it —
|
|
4368
|
+
//
|
|
4369
|
+
// FORBIDDEN (start_run): your grant [generic] does not include account "<key>"
|
|
4370
|
+
//
|
|
4371
|
+
// — which is the first thing a person meets after being told to set a company up. `brandowner add`
|
|
4372
|
+
// detects it and prints NOT YET STARTABLE with a remedy, deliberately reporting rather than
|
|
4373
|
+
// re-minting, because a create COMMAND holding the signing secret would be a larger surprise than the
|
|
4374
|
+
// bug. That boundary is about the command, and it is untouched: this process is not a create command,
|
|
4375
|
+
// it already holds the secret, and it already mints account credentials with it for the connector.
|
|
4376
|
+
//
|
|
4377
|
+
// So the cap is re-taken against the roster as it stands, for the length of one call.
|
|
4378
|
+
//
|
|
4379
|
+
// FALLS BACK TO THE BOOT TOKEN, never to an uncapped one. If the secret is unreadable or the store
|
|
4380
|
+
// cannot be listed, this returns exactly what it returned before — a stale cap that refuses a new
|
|
4381
|
+
// company at the door — rather than widening the wall to get the call through. The failure direction
|
|
4382
|
+
// matters more than the feature: "the newest company cannot search yet" is the bug being fixed, and
|
|
4383
|
+
// "any account can be started" is not a worse version of it, it is a different and larger one.
|
|
4384
|
+
const currentOpsToken = async () => {
|
|
4385
|
+
let roster = [];
|
|
4386
|
+
try {
|
|
4387
|
+
const { loadProfiles } = await import("./profiles.mjs");
|
|
4388
|
+
roster = [...loadProfiles({ force: true }).keys()];
|
|
4389
|
+
} catch (e) {
|
|
4390
|
+
// Same rule one layer out: the roster could not be read at all, so there is nothing to re-mint
|
|
4391
|
+
// against. Narrow and said out loud, rather than narrow and silent.
|
|
4392
|
+
console.error(`[portal] could not read the company roster to re-mint the engine credential `
|
|
4393
|
+
+ `(${String(e?.message ?? e)}) — falling back to the credential minted at boot`);
|
|
4394
|
+
return OPS_TOKEN;
|
|
4395
|
+
}
|
|
4396
|
+
return opsTokenFor({ bootToken: OPS_TOKEN, roster, mint: mintToken });
|
|
4397
|
+
};
|
|
4203
4398
|
const trigger = async (args) => {
|
|
4204
4399
|
if (!MCP_URL || !OPS_TOKEN) throw new Error("PORTAL_MCP_URL / PORTAL_OPS_TOKEN unset — the trigger lane is not wired on this instance");
|
|
4205
|
-
return mcpToolCall({ url: MCP_URL, token:
|
|
4400
|
+
return mcpToolCall({ url: MCP_URL, token: await currentOpsToken(), tool: "start_run", args });
|
|
4206
4401
|
};
|
|
4207
4402
|
const stopRun = async (args) => {
|
|
4208
4403
|
if (!MCP_URL || !OPS_TOKEN) throw new Error("PORTAL_MCP_URL / PORTAL_OPS_TOKEN unset — the stop lane is not wired on this instance");
|
|
4209
|
-
return mcpToolCall({ url: MCP_URL, token:
|
|
4404
|
+
return mcpToolCall({ url: MCP_URL, token: await currentOpsToken(), tool: "stop_run", args });
|
|
4210
4405
|
};
|
|
4211
4406
|
|
|
4212
4407
|
// The config surface. profile-service is constructed IN-PROCESS rather than called over HTTP: it
|
|
@@ -4331,6 +4526,11 @@ const PORT = PORT_CHOICE.port;
|
|
|
4331
4526
|
// directory. The engine refuses that fallback for the same reason; so does this.
|
|
4332
4527
|
const recipesDir = process.env.CLEAROTRON_RECIPES_DIR || "";
|
|
4333
4528
|
let callRecipes = null;
|
|
4529
|
+
// WHY THE FEATURE IS OFF, kept rather than only logged. The boot log already carried the exact
|
|
4530
|
+
// diagnosis and the screen carried "try again shortly" — a permanent, already-understood
|
|
4531
|
+
// configuration state rendered as a transient fault, with advice that can never work. The reason
|
|
4532
|
+
// travels with the refusal now so the surface can say what the log knows.
|
|
4533
|
+
let recipesOff = null;
|
|
4334
4534
|
// — the recipe store's own reachability, decided BEFORE the branch so an unreachable one turns
|
|
4335
4535
|
// saved searches off rather than throwing. A throw here would land in the catch below and take the
|
|
4336
4536
|
// PROFILE surface down with it, and the two stores are configured independently: `recipeRepoRoot`
|
|
@@ -4345,6 +4545,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4345
4545
|
log(`saved searches OFF — ${storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: recipeReach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: recipeReach.repo })} `
|
|
4346
4546
|
+ `The repo root came from ${recipeResolved.from} (tried ${recipeResolved.tried.join(", ")} in that order). `
|
|
4347
4547
|
+ "Routes answer 404 rather than accepting a save that would orphan its file.");
|
|
4548
|
+
recipesOff = { code: "store_outside_repo", detail: storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: recipeReach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: recipeReach.repo }) };
|
|
4348
4549
|
} else if (recipesDir) {
|
|
4349
4550
|
const { makeRecipeService } = await import("./recipe-service.mjs");
|
|
4350
4551
|
const recipeRepoRoot = recipeResolved.root;
|
|
@@ -4357,11 +4558,17 @@ const PORT = PORT_CHOICE.port;
|
|
|
4357
4558
|
log(`saved searches ON — store=${recipesDir} repo=${recipeRepoRoot}`);
|
|
4358
4559
|
} else {
|
|
4359
4560
|
log("saved searches OFF — CLEAROTRON_RECIPES_DIR unset, so /portal/api/config/searches answers 404");
|
|
4561
|
+
recipesOff = { code: "not_configured", detail: "CLEAROTRON_RECIPES_DIR is not set on this deployment, so there is no store for saved searches." };
|
|
4360
4562
|
}
|
|
4361
4563
|
|
|
4362
4564
|
return makeUpstream({
|
|
4363
4565
|
callUpstream: (method, path, body, identity) => profiles.route(method, path, { email: identity?.email }, body ?? {}),
|
|
4364
4566
|
callRecipes,
|
|
4567
|
+
recipesOff,
|
|
4568
|
+
// A company created from the portal is filed under its organisation in the same breath, so the
|
|
4569
|
+
// person who made it can use it: access is decided by the grants file, and the create writes only
|
|
4570
|
+
// the company's own file, which decides nothing about who sees it.
|
|
4571
|
+
fileCompany: async ({ tenant, account }) => writeGrants(withCompany(loadGrants({}), { tenant, account })),
|
|
4365
4572
|
});
|
|
4366
4573
|
} catch (e) {
|
|
4367
4574
|
// A settings surface that cannot start must not take the whole portal down — clearances and
|
|
@@ -4426,7 +4633,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4426
4633
|
const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
|
|
4427
4634
|
// Re-read per request (a getter that rescans), so a workspace created after boot is counted.
|
|
4428
4635
|
queueDirs: () => config.queueDirs,
|
|
4429
|
-
secret,
|
|
4636
|
+
secret, grants, localSignIn: LOCAL_MODE, writeGrants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
|
|
4430
4637
|
// — the ONLY place the environment is read for this. `bin/start.mjs` is the
|
|
4431
4638
|
// only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
|
|
4432
4639
|
// environment through, so a stray `.env` can neither put a live install into demo mode nor take a
|
|
@@ -4515,8 +4722,8 @@ const PORT = PORT_CHOICE.port;
|
|
|
4515
4722
|
// exactly as it would for a Cloudflare-verified stranger. Warned at boot because the symptom
|
|
4516
4723
|
// otherwise arrives as "I signed in and got refused", which reads as a broken login.
|
|
4517
4724
|
try {
|
|
4518
|
-
if (!makePrincipal({ email: LOCAL_USER, grants: grants()
|
|
4519
|
-
log(`WARNING: ${LOCAL_USER} can sign in but holds no portal access — it
|
|
4725
|
+
if (!makePrincipal({ email: LOCAL_USER, grants: grants() }))
|
|
4726
|
+
log(`WARNING: ${LOCAL_USER} can sign in but holds no portal access — it has no entry in the grants file (CLEAROTRON_ACCESS_FILE), so every page will refuse it at the door. Give it one.`);
|
|
4520
4727
|
} catch (e) {
|
|
4521
4728
|
// A WARNING must never be the thing that takes the service down. The grants file was already
|
|
4522
4729
|
// read successfully by the mandatory guard above; anything that fails here is a race with
|
|
@@ -4542,7 +4749,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4542
4749
|
// on the route: on a PACKAGED install the bundle ships inside the tarball, so absent means a broken
|
|
4543
4750
|
// package; on a SOURCE checkout it has simply never been built, which is the ordinary first-run
|
|
4544
4751
|
// state and why the message names the build command. The freshness verdict twenty lines below
|
|
4545
|
-
//
|
|
4752
|
+
// has always had this right.
|
|
4546
4753
|
//
|
|
4547
4754
|
// Not fatal on purpose: the API is independently useful (the MCP face, the connector, a debugging
|
|
4548
4755
|
// curl), and taking the whole service down over a UI asset would turn a cosmetic failure into an
|