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
package/driver/portal-access.mjs
CHANGED
|
@@ -1,99 +1,166 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
-
// portal-access.mjs — identity → principal for the unified portal. The INNER
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
//
|
|
10
|
-
import {
|
|
3
|
+
// portal-access.mjs — identity → principal for the unified portal. The INNER authorization boundary:
|
|
4
|
+
// the sign-in door proves WHO; this module decides WHAT THEY SEE AND MAY DO. Pure decisions over the
|
|
5
|
+
// grants object — fail-closed at every edge: an unmapped identity gets NO principal (403 at the door), a
|
|
6
|
+
// request outside the person's access resolves to 404 semantics (never 403 — existence must not leak),
|
|
7
|
+
// and the grants substrate is the SAME file the connector reads (`CLEAROTRON_ACCESS_FILE`), resolved by
|
|
8
|
+
// the SAME function (`resolvePerson`, shared/scope.mjs), so the two doors cannot disagree about a person.
|
|
9
|
+
// Shape and semantics: INSTALL.md §8; examples/grants.example.json.
|
|
10
|
+
import { resolvePerson } from "../shared/scope.mjs";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* makePrincipal({ email, grants }) → the person, or null when the address has no access anywhere.
|
|
14
|
+
*
|
|
15
|
+
* { email, everything, permissions: { run, manage }, access, accounts, organisations, genericOrgs, accountOrgs }
|
|
16
|
+
*
|
|
17
|
+
* There is no role. What a person may SEE is their access; what they may DO is two switches, asked by
|
|
18
|
+
* name through `seesEverything`, `mayRun` and `mayManage` below and never through a role word. Nothing
|
|
19
|
+
* about the part of an address after its `@` admits anyone: the staff-by-domain branch is deleted, and an
|
|
20
|
+
* address is admitted by its own entry in the grants file.
|
|
21
|
+
*
|
|
22
|
+
* `generic` is never in `accounts`. It is not a company: each organisation has its own, it is addressed as
|
|
23
|
+
* the pair (`account=generic`, `tenant=<organisation>`), and `genericOrgOf` decides it.
|
|
24
|
+
*/
|
|
25
|
+
export function makePrincipal({ email, grants = null }) {
|
|
26
|
+
return resolvePerson(email, grants);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Access to the top of the tree: every organisation, every company, every person. */
|
|
30
|
+
export const seesEverything = (p) => p?.everything === true;
|
|
31
|
+
/** Run clearances: start and stop them, inside the person's access. */
|
|
32
|
+
export const mayRun = (p) => p?.permissions?.run === true;
|
|
33
|
+
/** Manage: add people, add companies, change settings, inside the person's access. */
|
|
34
|
+
export const mayManage = (p) => p?.permissions?.manage === true;
|
|
35
|
+
|
|
36
|
+
/** An organisation's display name: its `name` in the grants file, else its key. */
|
|
37
|
+
export function organisationName(grants, key) {
|
|
38
|
+
const n = grants?.tenants?.[key]?.name;
|
|
39
|
+
return typeof n === "string" && n.trim() ? n.trim() : key;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An access point as the portal shows it: names attached, keys kept. `everything` carries no key. */
|
|
43
|
+
export function namedPoint(p, grants, companyNames = {}) {
|
|
44
|
+
if (p.kind === "everything") return { kind: "everything" };
|
|
45
|
+
if (p.kind === "organisation") return { kind: "organisation", key: p.key, name: organisationName(grants, p.key) };
|
|
46
|
+
return { kind: "company", key: p.key, name: companyNames[p.key] ?? p.key, org: p.org };
|
|
47
|
+
}
|
|
16
48
|
|
|
17
49
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Staff wins over an (accidental) grants row; a client row with a tenant-wide "*" grant is honored
|
|
23
|
-
* but the role stays client (no staff surfaces).
|
|
50
|
+
* What `/portal/api/me` says about a person's reach and switches — the fields the screens read, so no
|
|
51
|
+
* screen derives a visibility rule of its own. `organisations` is every organisation the person sees
|
|
52
|
+
* anything in (a company's heading needs its organisation's name); `genericOrgs` is the ones whose
|
|
53
|
+
* Generic they see.
|
|
24
54
|
*/
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
export function principalView(principal, grants, companyNames = {}) {
|
|
56
|
+
return {
|
|
57
|
+
permissions: { run: mayRun(principal), manage: mayManage(principal) },
|
|
58
|
+
access: (principal.access ?? []).map((p) => namedPoint(p, grants, companyNames)),
|
|
59
|
+
organisations: (principal.organisations ?? []).map((key) => ({ key, name: organisationName(grants, key) })),
|
|
60
|
+
accountOrgs: { ...(principal.accountOrgs ?? {}) },
|
|
61
|
+
genericOrgs: [...(principal.genericOrgs ?? [])],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* May this person read a run, given who it belongs to? The ONE answer for every run-scoped route: the
|
|
67
|
+
* listing, the report, the summary, the feedback form. `owner` is the run's company key (`generic` when it
|
|
68
|
+
* had none); `organisation` is the organisation a Generic run was filed under, null for one filed before
|
|
69
|
+
* organisations existed.
|
|
70
|
+
*
|
|
71
|
+
* A company's run: the company must be inside the person's access. A Generic run: the person must see
|
|
72
|
+
* that organisation's Generic — and an unfiled one is visible only to a person who sees everything, which
|
|
73
|
+
* is exactly who could read it before.
|
|
74
|
+
*/
|
|
75
|
+
export function mayReadRun(principal, { owner, organisation = null }) {
|
|
76
|
+
if (!principal) return false;
|
|
77
|
+
if (owner !== "generic") return principal.accounts === "*" || (Array.isArray(principal.accounts) && principal.accounts.includes(owner));
|
|
78
|
+
if (seesEverything(principal)) return true;
|
|
79
|
+
return organisation != null && Array.isArray(principal.genericOrgs) && principal.genericOrgs.includes(organisation);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Does everything `other` holds sit inside `viewer`'s reach? Switches belong to the person, not to an
|
|
84
|
+
* access point, so a manager may set someone's switches only when that person's whole access is inside
|
|
85
|
+
* the manager's own — otherwise changing them would change what the person may do somewhere the manager
|
|
86
|
+
* cannot see.
|
|
87
|
+
*/
|
|
88
|
+
export function reachCovers(viewer, other) {
|
|
89
|
+
if (seesEverything(viewer)) return true;
|
|
90
|
+
if (!other || seesEverything(other)) return false;
|
|
91
|
+
const orgs = viewer?.genericOrgs ?? [];
|
|
92
|
+
const companies = Array.isArray(viewer?.accounts) ? viewer.accounts : [];
|
|
93
|
+
return (other.access ?? []).every((p) => p.kind === "organisation" ? orgs.includes(p.key)
|
|
94
|
+
: p.kind === "company" ? orgs.includes(p.org) || companies.includes(p.key) : false);
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
export class PortalDeny extends Error {
|
|
56
98
|
constructor(status, message) { super(message); this.name = "PortalDeny"; this.status = status; }
|
|
57
99
|
}
|
|
58
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Which organisation's Generic a request means — the organisation key, or null.
|
|
103
|
+
*
|
|
104
|
+
* named it must be one whose Generic this person sees (`genericOrgs`), else 404;
|
|
105
|
+
* unnamed the one organisation whose Generic they see, when there is exactly one;
|
|
106
|
+
* unnamed, for a person who sees everything and several organisations (or none): null — Generic filed
|
|
107
|
+
* under no organisation, which is how every Generic run was filed before organisations
|
|
108
|
+
* existed, and only a person who sees everything sees those runs;
|
|
109
|
+
* otherwise 404 when they see no Generic at all, 400 naming the field when they see several.
|
|
110
|
+
*/
|
|
111
|
+
export function genericOrgOf(principal, tenant = null) {
|
|
112
|
+
const t = tenant == null || String(tenant).trim() === "" ? null : String(tenant).trim();
|
|
113
|
+
const orgs = Array.isArray(principal?.genericOrgs) ? principal.genericOrgs : [];
|
|
114
|
+
if (t != null) {
|
|
115
|
+
if (orgs.includes(t)) return t;
|
|
116
|
+
throw new PortalDeny(404, "not found");
|
|
117
|
+
}
|
|
118
|
+
if (orgs.length === 1) return orgs[0];
|
|
119
|
+
if (seesEverything(principal)) return null;
|
|
120
|
+
if (!orgs.length) throw new PortalDeny(404, "not found");
|
|
121
|
+
throw new PortalDeny(400, "name an organisation (?tenant=) — Generic belongs to an organisation, and this login sees several");
|
|
122
|
+
}
|
|
123
|
+
|
|
59
124
|
/**
|
|
60
125
|
* The ONE chokepoint every account-scoped route passes. Resolves the EFFECTIVE account for a request:
|
|
61
|
-
* -
|
|
62
|
-
*
|
|
63
|
-
* -
|
|
64
|
-
* existence never leaks
|
|
65
|
-
*
|
|
66
|
-
*
|
|
126
|
+
* - a person who sees everything: any account — but an account-scoped route must still NAME one (no
|
|
127
|
+
* accidental install-wide writes); unnamed resolves to null and the caller decides (list-all views);
|
|
128
|
+
* - anyone else: the named account must be inside their access — a foreign account is a 404, never a
|
|
129
|
+
* 403, because existence never leaks; unnamed defaults to their only company, or is a 400 when there
|
|
130
|
+
* are several or none;
|
|
131
|
+
* - `generic` is the pair, and `genericOrgOf` decides it here rather than per route.
|
|
132
|
+
*
|
|
133
|
+
* The gates — `everything` for install-wide surfaces, `manage`, `run` — each refuse with 404: the surface
|
|
134
|
+
* does not exist for this person, and a refusal that told "you may not" apart from "there is nothing
|
|
135
|
+
* here" would tell a stranger which endpoints exist.
|
|
136
|
+
*
|
|
137
|
+
* ORDERING GENERIC follows the rule for seeing it: a person who holds the organisation whole, and holds
|
|
138
|
+
* Run. Spending against it is bounded as a company's is — every organisation's Generic lane carries the
|
|
139
|
+
* daily cap (ruling 2026-09-10), counted by the runner in the lane of the organisation the job is
|
|
140
|
+
* filed under, which `genericOrgOf` has just decided.
|
|
67
141
|
*/
|
|
68
|
-
export function assertPrincipal(principal, {
|
|
142
|
+
export function assertPrincipal(principal, { account = null, tenant = null, door = false,
|
|
143
|
+
everything = false, manage = false, run = false, ...rest } = {}) {
|
|
144
|
+
if ("staffOnly" in rest) throw new TypeError("assertPrincipal: `staffOnly` is gone — gate on `everything`, `manage` or `run`");
|
|
69
145
|
if (!principal) throw new PortalDeny(403, "no portal access for this identity");
|
|
70
|
-
if (
|
|
146
|
+
if (everything && !seesEverything(principal)) throw new PortalDeny(404, "not found");
|
|
147
|
+
if (manage && !mayManage(principal)) throw new PortalDeny(404, "not found");
|
|
148
|
+
if (run && !mayRun(principal)) throw new PortalDeny(404, "not found");
|
|
71
149
|
// door mode: the caller only needs "may this identity enter" — NEVER resolve an account (a
|
|
72
|
-
// multi-account
|
|
150
|
+
// multi-account person must not 404 off the front door; review 2026-07-18)
|
|
73
151
|
if (door) return null;
|
|
74
152
|
if (account == null) {
|
|
75
|
-
if (principal.role === "staff") return null; // staff without acting-for: caller decides (list-all views)
|
|
76
|
-
if (Array.isArray(principal.accounts) && principal.accounts.length === 1) return principal.accounts[0];
|
|
77
153
|
if (principal.accounts === "*") return null;
|
|
78
|
-
|
|
154
|
+
if (Array.isArray(principal.accounts) && principal.accounts.length === 1) return principal.accounts[0];
|
|
155
|
+
throw new PortalDeny(400, principal.accounts?.length
|
|
156
|
+
? "name an account (?account=) — this login covers several"
|
|
157
|
+
: "name an account (?account=) — this login holds no company of its own, only its organisation's Generic");
|
|
79
158
|
}
|
|
80
159
|
const a = String(account).trim().toLowerCase();
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// resolves to the literal "*" returns above it, untouched, and the wildcard test below then admits
|
|
86
|
-
// `generic` like any other account. Three routes (the runs listing and the two report routes) carried
|
|
87
|
-
// their own `role !== "staff"` check and closed the hole for themselves; POST /portal/api/run and
|
|
88
|
-
// /run/plan never did. So the one path that spends money was the one path with no guard — against the
|
|
89
|
-
// one account that is EXEMPT from the daily run cap (runner.mjs: `generic` is the neutral no-customer
|
|
90
|
-
// profile and stays uncapped). Uncapped spend, reachable by a grant shape, is the worst combination
|
|
91
|
-
// in this file.
|
|
92
|
-
//
|
|
93
|
-
// Refusing at the chokepoint fixes every account-scoped route at once, including the ones nobody has
|
|
94
|
-
// written yet, which is the property the per-route checks could never have. Those three stay as they
|
|
95
|
-
// are: they are cheap, and defence in depth on a spend boundary is not duplication.
|
|
96
|
-
if (a === "generic") throw new PortalDeny(404, "not found");
|
|
160
|
+
if (a === "generic") {
|
|
161
|
+
genericOrgOf(principal, tenant);
|
|
162
|
+
return a;
|
|
163
|
+
}
|
|
97
164
|
if (principal.accounts === "*" || (Array.isArray(principal.accounts) && principal.accounts.includes(a))) return a;
|
|
98
165
|
throw new PortalDeny(404, "not found");
|
|
99
166
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ── THE FLAG PART ───────────────────────────────────────────────────────────────────────────────────
|
|
11
11
|
//
|
|
12
12
|
// This RENDERS the snapshot, NOT process.env, and that is still the rule — but the reason written here
|
|
13
|
-
// until
|
|
13
|
+
// until it was measurably out of date, so it is restated rather than repeated. It said
|
|
14
14
|
// "portal-service's unit deliberately carries no environment file". It does:
|
|
15
15
|
// `driver/systemd/clearotron-portal.service` carries `EnvironmentFile=%h/.env`, and sets
|
|
16
16
|
// `CLEAROTRON_NO_ENV_FILE=1` precisely because systemd has already supplied it.
|
|
@@ -42,10 +42,11 @@
|
|
|
42
42
|
// so rather than implying it has the whole picture.
|
|
43
43
|
|
|
44
44
|
import { statSync, openSync, readSync, closeSync } from "node:fs";
|
|
45
|
+
import { namedPoint } from "./portal-access.mjs";
|
|
45
46
|
|
|
46
47
|
import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "./flag-snapshot.mjs";
|
|
47
|
-
// `isStale` is deliberately NOT imported any more: the age banner is retired (
|
|
48
|
-
//
|
|
48
|
+
// `isStale` is deliberately NOT imported any more: the age banner is retired (ruling,
|
|
49
|
+
// 2026-09-05). The function stays exported for other readers; this page no longer asks how old a
|
|
49
50
|
// reading is, because the question it was standing in for — does this still describe the box — now has
|
|
50
51
|
// a direct answer in `lastRun.disagrees`.
|
|
51
52
|
import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
|
|
@@ -110,7 +111,7 @@ function postureView(snap) {
|
|
|
110
111
|
/**
|
|
111
112
|
* The configuration view.
|
|
112
113
|
*
|
|
113
|
-
* THE ANSWER IS THE LIVE CONFIGURATION, ALWAYS —
|
|
114
|
+
* THE ANSWER IS THE LIVE CONFIGURATION, ALWAYS — ruling 2026-09-05:
|
|
114
115
|
* "the global configuration page shows LIVE configuration, always. No run-time snapshot as the source of
|
|
115
116
|
* truth — I don't see why it needs to take an old snapshot." Age banners go with it.
|
|
116
117
|
*
|
|
@@ -269,87 +270,75 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
/**
|
|
272
|
-
*
|
|
273
|
+
* The People page: who has access to what, narrowed to what the viewer may see, and where an enrolment
|
|
274
|
+
* is half done.
|
|
273
275
|
*
|
|
274
|
-
*
|
|
276
|
+
* `grants` is the parsed grants file and `viewer` the signed-in principal. A person is listed when at
|
|
277
|
+
* least one of their access points sits inside the viewer's own, and only those points are shown — a
|
|
278
|
+
* manager of one organisation sees a colleague's access to it and never that colleague's access
|
|
279
|
+
* elsewhere. A person who sees everything sees everyone. The narrowing is here, on the server, because
|
|
280
|
+
* a filter in the browser is a boundary drawn in markup.
|
|
275
281
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* strangers may hold an administrator's view of their instance and has no next step at all: the value
|
|
279
|
-
* is in an environment variable, in one of two files depending on how the instance is run, and neither
|
|
280
|
-
* is named anywhere on the screen. The one outside reader who met this reported it as a back door,
|
|
281
|
-
* twice, which is the correct thing to do with an access rule you cannot trace.
|
|
282
|
-
*
|
|
283
|
-
* PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
|
|
284
|
-
* what this process read, so the answer describes the process actually serving the page rather than
|
|
285
|
-
* being composed from a path that some other process would have read — the distinction that module
|
|
286
|
-
* exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
|
|
287
|
-
* `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
|
|
288
|
-
* the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
|
|
282
|
+
* `companies` maps each company key the profile store holds to its name. `localSignIn` says this install
|
|
283
|
+
* cannot hold a second person at all, which is what disables Add.
|
|
289
284
|
*/
|
|
290
|
-
export function
|
|
291
|
-
unitEnvFile = null, cliEnvFile = null } = {}) {
|
|
292
|
-
if (!String(value ?? "").trim()) return null;
|
|
293
|
-
const reason = envLoad?.reason ?? null;
|
|
294
|
-
const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
|
|
295
|
-
if (reason === "read" && applied.includes(name))
|
|
296
|
-
return { name, where: `read from ${envLoad.path}` };
|
|
297
|
-
if (reason === "service-managed")
|
|
298
|
-
return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
|
|
299
|
-
if (reason === "opted-out")
|
|
300
|
-
return { name, where: cliEnvFile
|
|
301
|
-
? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
|
|
302
|
-
: "handed to this service by the command that started it" };
|
|
303
|
-
return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* The enrolment view: who is granted what, and where an enrolment is half done.
|
|
308
|
-
*
|
|
309
|
-
* `grants` is the parsed grants file. `staffDomains` are admitted by domain rather than by grant, so
|
|
310
|
-
* they are reported separately — a staff member absent from the grants file is normal, not a fault,
|
|
311
|
-
* and listing them as "unenrolled" would bury the real problems.
|
|
312
|
-
*/
|
|
313
|
-
export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
|
|
285
|
+
export function accessView({ grants, viewer = null, companies = {}, grantsFile = null, localSignIn = false }) {
|
|
314
286
|
const tenants = grants?.tenants ?? {};
|
|
315
|
-
const
|
|
316
|
-
const
|
|
287
|
+
const everything = viewer?.everything === true;
|
|
288
|
+
const viewerOrgs = viewer?.genericOrgs ?? [];
|
|
289
|
+
const viewerCompanies = Array.isArray(viewer?.accounts) ? viewer.accounts : [];
|
|
290
|
+
const inside = (p) => everything || (p.kind === "organisation" ? viewerOrgs.includes(p.key)
|
|
291
|
+
: p.kind === "company" ? viewerOrgs.includes(p.org) || viewerCompanies.includes(p.key) : false);
|
|
292
|
+
const known = new Set(Object.keys(companies));
|
|
317
293
|
const unknownAccounts = new Set();
|
|
294
|
+
const rows = new Map(); // address as written → { points, dangling }
|
|
295
|
+
const row = (email) => { if (!rows.has(email)) rows.set(email, { points: [], dangling: [] }); return rows.get(email); };
|
|
318
296
|
|
|
319
297
|
for (const [tenant, t] of Object.entries(tenants)) {
|
|
320
298
|
const accounts = Array.isArray(t?.accounts) ? t.accounts : [];
|
|
321
|
-
for (const a of accounts) if (known.size && !known.has(a)) unknownAccounts.add(a);
|
|
322
|
-
|
|
299
|
+
for (const a of accounts) if (known.size && a !== "generic" && !known.has(a)) unknownAccounts.add(a);
|
|
323
300
|
for (const [email, grant] of Object.entries(t?.users ?? {})) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
accounts: resolved,
|
|
333
|
-
// A grant naming an account its own tenant does not hold. Usually a typo, and it fails as a
|
|
334
|
-
// silent 404 for that person with nothing in any log to explain it.
|
|
335
|
-
dangling,
|
|
336
|
-
wildcard: grant === "*",
|
|
337
|
-
});
|
|
301
|
+
const r = row(email);
|
|
302
|
+
if (grant === "*") { r.points.push({ kind: "organisation", key: tenant }); continue; }
|
|
303
|
+
for (const a of Array.isArray(grant) ? grant : []) {
|
|
304
|
+
// A grant naming a company its own organisation does not hold. Usually a typo; it grants
|
|
305
|
+
// nothing, and the person meets a silent 404 with nothing in any log to explain it.
|
|
306
|
+
if (accounts.includes(a)) r.points.push({ kind: "company", key: a, org: tenant });
|
|
307
|
+
else if (everything || viewerOrgs.includes(tenant)) r.dangling.push(a);
|
|
308
|
+
}
|
|
338
309
|
}
|
|
339
310
|
}
|
|
311
|
+
const people = grants?.people && typeof grants.people === "object" ? grants.people : {};
|
|
312
|
+
for (const email of Object.keys(people)) row(email);
|
|
313
|
+
|
|
314
|
+
const list = [];
|
|
315
|
+
for (const [email, r] of rows) {
|
|
316
|
+
const key = Object.keys(people).find((k) => k.toLowerCase() === email.toLowerCase());
|
|
317
|
+
const entry = (key !== undefined ? people[key] : null) ?? {};
|
|
318
|
+
const pattern = email.startsWith("*@");
|
|
319
|
+
const all = entry.everything === true && !pattern ? [{ kind: "everything" }]
|
|
320
|
+
: r.points.filter((p) => p.kind === "organisation" || !r.points.some((q) => q.kind === "organisation" && q.key === p.org));
|
|
321
|
+
const shown = everything ? all : all.filter(inside);
|
|
322
|
+
if (!everything && !shown.length) continue;
|
|
323
|
+
list.push({
|
|
324
|
+
email,
|
|
325
|
+
permissions: { run: entry.run === true, manage: entry.manage === true && !pattern },
|
|
326
|
+
access: shown.map((p) => namedPoint(p, grants, companies)),
|
|
327
|
+
dangling: r.dangling,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
340
330
|
|
|
341
331
|
return {
|
|
342
|
-
people:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
unknownAccounts: [...unknownAccounts].sort(),
|
|
332
|
+
people: list.sort((a, b) => a.email.localeCompare(b.email)),
|
|
333
|
+
// Add is offered to a manager, and never where local sign-in holds the install to one person.
|
|
334
|
+
canAdd: viewer?.permissions?.manage === true && !localSignIn,
|
|
335
|
+
localSignIn,
|
|
336
|
+
// Companies named in grants that no profile matches — the other typo direction. Install-wide, so it
|
|
337
|
+
// is shown to a person who sees everything and to nobody else.
|
|
338
|
+
unknownAccounts: everything ? [...unknownAccounts].sort() : [],
|
|
350
339
|
// Where to go to change any of this — a filename and a date, so "I want to add someone" has a
|
|
351
340
|
// visible next step instead of ending at a page that only reports. Null when it cannot be stat'd.
|
|
352
|
-
grantsFile,
|
|
341
|
+
grantsFile: everything ? grantsFile : null,
|
|
353
342
|
// Stated plainly, because this view genuinely cannot see the other half.
|
|
354
343
|
//
|
|
355
344
|
// — IT NO LONGER NAMES ONE VENDOR, and it no longer asserts an edge that may not exist. This
|
package/driver/portal-report.mjs
CHANGED
|
@@ -145,7 +145,7 @@ const CHROME_RES = [
|
|
|
145
145
|
|
|
146
146
|
// ── the engine's own scaffolding: out of the report, for EVERY reader ───────────────────────────────
|
|
147
147
|
//
|
|
148
|
-
//
|
|
148
|
+
// Ruling, 2026-07-27: "none of this should surface to anyone — only to the internal logs for
|
|
149
149
|
// analysis." Not a client cut and a staff cut; there is ONE report, and this material was never meant to
|
|
150
150
|
// be in it for anybody. It reads as machinery in a document whose whole job is a legal opinion.
|
|
151
151
|
//
|
|
@@ -525,7 +525,7 @@ const EMBED_JS = `
|
|
|
525
525
|
queued=true;
|
|
526
526
|
requestAnimationFrame(function(){queued=false;post();});
|
|
527
527
|
}
|
|
528
|
-
// WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS
|
|
528
|
+
// WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS.
|
|
529
529
|
//
|
|
530
530
|
// The command handler below answers "this report has no <verb>" for a verb the document does not
|
|
531
531
|
// define. That reply is honest and it arrives too late: the shell had already drawn a menu item, the
|
|
@@ -890,7 +890,7 @@ export function reportsOf(meta) {
|
|
|
890
890
|
* NOTHING COULD REACH IT. `meta.reports` lists the per-mark HTMLs only, so `resolveReportFile` below
|
|
891
891
|
* matches nothing for it and the portal route 404s; the pool path is not one the edge serves either
|
|
892
892
|
* (test/edge-routes.mjs — one legacy filename, and it is `report.html`). Good prose, composed on every
|
|
893
|
-
* multi-mark run, delivered to nobody.
|
|
893
|
+
* multi-mark run, delivered to nobody. Ruling 2026-08-26: the grouped page carries it.
|
|
894
894
|
*
|
|
895
895
|
* Returns PARAGRAPHS, split the way the document renderer splits them, with inline markdown left in
|
|
896
896
|
* place — the model writes markdown because every surface it feeds renders markdown, and the client
|
|
@@ -917,7 +917,7 @@ export function batchSummaryOf(dir) {
|
|
|
917
917
|
// reading to the end of the file would ship that list and this boundary is load-bearing.
|
|
918
918
|
//
|
|
919
919
|
// — IT USED TO TERMINATE ON ANY HEADING, /^#{1,6}\s/, AND THAT SILENTLY TRUNCATED THE PAGE.
|
|
920
|
-
// The writer now emits sub-headers INSIDE the summary (
|
|
920
|
+
// The writer now emits sub-headers INSIDE the summary (ruling 2026-08-31, "keep the length, add
|
|
921
921
|
// the structure"). Measured on a ten-line structured summary before the fix: the section ended at the
|
|
922
922
|
// first `## <MARK>` and the grouped page — the report's entry point — rendered ONE sentence, with
|
|
923
923
|
// every following mark dropped and nothing anywhere reporting a loss. Depth cannot mark this boundary
|