// --------------------------------------------------------------------------- // settings / permsModel.ts — wave 15, contract C-PERM (ruling R9). // // The permission editor's PURE HALF: the wire shapes, the parse, the draft the // admin is editing, and the body the PUT sends. No React, no fetch, so the // rules that decide who can see what are testable under node without a browser // or a server (verify_login's `_test` harness runs them). // // ⛔ NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a // WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the // server, and it re-validates all of this. An editor that produced a malformed // record would be refused — the point of the care here is that it never gets // that far, and that the admin is never shown a state the server does not hold. // // THREE RULES THAT LOOK LIKE STYLE AND ARE NOT: // // 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and // saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode // it prevents: half a filter tree, PUT against a field list nobody could // read. Fail-closed is not "deny everything" here — it is "never write a // restriction you could not show the admin". // 2. **An unknown field TYPE lands on `text`**, it does not disappear and it // is not passed through. Passing it through would hand the condition // builder an operator set for a type it does not have; dropping the field // would take away an admin's ability to HIDE a column just because they // cannot filter on it. `text` is the house fallback the viz layer already // uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`). // 3. **A module the server no longer declares is DROPPED by the whole-record // replace — and the editor says so out loud** (`orphanModules`). A silent // drop of somebody's permanent filter is a permission change nobody made. // --------------------------------------------------------------------------- import type { Field, FieldType, FilterTree } from "../customer-grid/types"; // --- who may reach the editor at all ---------------------------------------- /** The modal's rail entries. Declared HERE, beside the rule that gates them, so * the component and the gate read one definition of the vocabulary rather than * two that can drift by one member. `SettingsModal` re-exports it. */ export type SettingsSection = | "account" | "scope" | "users" /** * ⭐ WAVE 33 (owner item 10) — MANAGE AGENT: a bot per Slack channel, walled by * the same engine that walls a person. The owner placed it explicitly: *"under * a module called 'Manage agent' which should be under 'Manage user'"* — so it * is the rail entry immediately after `users`, not a tab inside it and not a * room somewhere else. Admin-only, and listed in `reachableSection` below for * the same reason `users` is: every control in it 403s a member. */ | "agents" // Wave 18 (C7): the tenant's credential store and its data-source status board. | "keychains" | "connectors" /* ⛔⛔ `"statements"` LEFT THIS UNION (wave 35 · T38, owner item 14 / ruling R10). * * The statement sender is an AGENT now: `Agents -> Monthly statements`, where a scheduled run * ASSEMBLES a batch and parks it, and a person releases it with a click. It was here since * EXIT-6, ported off `app.py`. * * ⚠ THE SEND DOOR DID NOT MOVE AND DID NOT WEAKEN. `routes_statements` still holds it, still * behind `admin_gate` + the Royal-Imports tenant gate, still calling * `collections_send.queue_statement` whose SAFE_MODE allow-list lives in the DATA LAYER. What * left is one of two front doors onto it, not a capability. * * ⛔ AND A STORED PREFERENCE NAMING IT DEGRADES RATHER THAN BREAKING — see `RETIRED_SECTIONS` * below. Dropping a union member is a TYPE change; the values people have already saved are * data, and refusing one would strand somebody on a blank pane forever (D-65's lesson: a * refused stored key locks the door you need in order to fix it). */ /** * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT * console, visible only to a `platform_admin` account. * * ⛔ DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an * oversight. Every other admin room is gated on `admin`, a boolean the shell * already holds on the user record. `platform_admin` is a different predicate * that arrives ASYNCHRONOUSLY on the settings payload — and R3 is explicit * that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a * function whose only input is `admin` would either grant the section to every * tenant admin or deny it to the one account that has it. It is gated where * the flag actually lives, at the render site in `SettingsModal`. */ | "padmin"; /** * ⭐⭐ WAVE 35 · T38 — SECTIONS THAT ONCE EXISTED AND NO LONGER DO. * * ⛔ A LIST RATHER THAN A DELETION, because a stored value outlives the type that described it. * `SettingsSection` is compile-time; the section somebody last had open is DATA, and a person who * was in Statements when this shipped must land somewhere real. Without this they would restore * into a section no branch renders — a blank modal, which reads as a broken product rather than as * a room that moved. * * ⚠ THE ENTRY STAYS EVEN AFTER NOBODY COULD PLAUSIBLY HOLD IT. It costs one string and it is the * only record, in code, that the value was ever legal — deleting it is how the next reader * "cleans up" a degrade path and re-opens the blank pane. */ export const RETIRED_SECTIONS: readonly string[] = ["statements"]; /** * A non-admin never LANDS on the users pane — and therefore never reaches the * permission editor, which lives inside it. * * ⛔ THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*` * route to a member regardless, and `/perms` is one of them. What this prevents * is a member deep-linked (or restored) into a room whose every control would * 403 — an empty frame that reads as a broken product rather than as a closed * door. It is a named function instead of an inline ternary for one reason: an * inline ternary cannot have a negative control, and "the pane a non-admin gets * bounced out of" is exactly the kind of rule that gets refactored away by * someone who does not know it is load-bearing. * * ⭐ WAVE 35 · T38 — it is ALSO where a RETIRED section is degraded, and the two rules live in one * function on purpose: both answer "this section is not reachable, so where does this person * actually land", and splitting them would give the caller two chances to forget one. */ export function reachableSection( /* ⚠ WIDER THAN `SettingsSection` ON PURPOSE. The caller passes a value that may have been STORED under an older build, so typing this parameter to the current union would make the retired case unrepresentable — and therefore unhandleable — at exactly the callsite whose job is to handle it. */ section: SettingsSection | string, admin: boolean, ): SettingsSection { // ⛔ FIRST, before the admin rule: a retired section has no admin question to answer, and // `adminOnly` below would read `false` for it and hand back a section nothing renders. if (RETIRED_SECTIONS.includes(String(section))) return "account"; // ⭐⭐ WAVE 32 · R4 — `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason. // // Wave 18 (C7) put them here because "every control inside them would 403 a member" — which was // TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal // split lands on ALL connections … business-wide is admin-only"*, i.e. a member now genuinely // owns something in these rooms — their own personal connections. // // ⛔ THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain` // dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` / // `_may_touch`): a member sees the business-wide entries plus their own, may create only a // personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had // this list changed alone, a member would land in a room whose every control 403s — the empty // frame this function's own header calls "a broken product rather than a closed door". const adminOnly = section === "users" || // ⭐ Wave 33 (owner item 10) — Manage agent is admin-only for exactly the // reason `users` is: every control in it 403s a member, so a member deep- // linked there lands in a room whose every button fails. ⛔ It is NOT enough // that the routes refuse — this list is what stops the EMPTY FRAME, and a new // admin room added to the rail without a line here is the defect this // function's header describes. section === "agents"; // ⚠ The cast is safe BECAUSE of the retired-section guard at the top: every value reaching here // is either a current member of the union or has already been degraded to "account". return adminOnly && !admin ? "account" : (section as SettingsSection); } // --- the wire (C-PERM) ------------------------------------------------------ /** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */ export interface PermsEntry { /** May this account open the module at all. `false` ⇒ `may_open` denies. */ access: boolean; /** The PERMANENT filter, AND-ed under everything the user does. `null` = none. * ⚠ The whole tree — `{conj?, nodes}` — never a bare node list: `[A, B]` under * `or` means something entirely different from `[A, B]` under `and`, and the * loss is invisible in every payload (C-PERM amendment 2). */ filter: FilterTree | null; /** Field keys this account never receives. Server-stripped from every wire. */ hiddenFields: string[]; /** * ⭐⭐ W38-T19 — MAY THIS ACCOUNT BUILD AND RECEIVE **METRIC** COLUMNS on this database? * `false` empties the measure offer at every grid door, which takes the Metric kind off the * field picker AND fail-closes a create with `measure_not_offered`. * * ⛔ OPTIONAL, AND THE CHOICE WAS **FORCED** RATHER THAN PREFERRED. Required is the loud * option and it is the one this file would otherwise take: `tsc` would then name every * `PermsEntry`-shaped literal that forgot the key. But `web/verify_login.py` compiles * `src/shell/_test/shell.test.ts` in the same `npx tsc` call as this file's own suite, and * that file passes a bare `{access, filter, hiddenFields}` literal straight into * `moduleSummary(entry: PermsEntry | undefined)`. A required key reds `web_login` at COMPILE * time ("tsc emitted nothing") in a file W38-T19's fence does not contain, with no legal * repair. Optional is what the fence permits. * * ⚠ SO THE DEFAULT IS CENTRALISED INSTEAD, IN EXACTLY TWO PLACES, and both spell it the same * way: `parseEntry` reads the wire (`metrics: r.metrics !== false`) and `toPutBody` writes it * (`metrics: e.metrics !== false`). Absence GRANTS at both ends, matching * `perm_scope.may_metrics` and `_clean_perms` on the server, so a record written before this * key existed reads as unrestricted rather than as a silent mass revocation. Every literal * that omits the key therefore means the one thing it could safely mean. */ metrics?: boolean; } export type PermsRecord = Record; /** * ⭐⭐ W40-T16 (owner instruction 13 / contract C3) — ONE FIELD OF THE PERMISSIONING LIST: a * `Field` plus the one thing only this list knows, which is whether the row stands for a bound * MEASURE rather than for a stored column. * * ⛔ THE FLAG CANNOT BE CALLED `metric`, AND THAT IS A COLLISION RATHER THAN A PREFERENCE. * `customer-grid/types.ts::Field` already declares `metric?: {source?, measure, window, agg?}` — * an OBJECT, the definition of a Metric COLUMN. C3's wire flag is a BOOLEAN meaning "this * pseudo-field stands for a measure". Two types under one name on one interface do not compile, * and `types.ts` belongs to another fence, so the translation happens HERE at `parseField` — * the boundary whose entire job is translating the wire into what this editor renders. * * ⚠ OPTIONAL, WHICH IS LOAD-BEARING RATHER THAN CAUTIOUS. A plain `Field[]` stays assignable to * `PermsField[]` only while the added key is optional, and the grid toolbar's own hide panel * passes exactly that. A required flag would red the build in a file this ticket may not repair. */ export interface PermsField extends Field { /** C3's `metric: true`. `=== true`, so ABSENCE MEANS NO — the exact opposite of `filterable`, * where absence means yes. That asymmetry is why the two are parsed in different tickets. */ isMetric?: boolean; /** W41-T09 / R8 — why this column is not offered to the permission-filter picker, when it is * not. Present only alongside `filterable: false`; absent means the column is offered and there * is nothing to explain. Render it beside the omission: a rule that cannot say its own name is * indistinguishable from a bug, and five live `product_data` columns leave the picker on the * day this arms. */ filterableReason?: string; } /** * ⭐⭐ W40-T16 — HOW A METRIC ROW READS. Owner instruction 13, verbatim: *"one row per metric, * named 'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the * permissioning is limited to"*. * * ⚠ THE SEPARATOR IS AN ASCII HYPHEN-MINUS AND THAT IS DELIBERATE. CLAUDE.md rule 2 bans the EM * dash (U+2014) and the EN dash (U+2013) from anything that reaches a screen. `-` is neither, * and it is the character the instruction itself is written with. * * ⛔ THE GUARD IS THE WHOLE REASON THIS IS A FUNCTION. `_module_fields` is free to label its * `measure_`-namespaced pseudo-fields fully, so the word may already be on the wire; prefixing * one that carries it ships "Metric - Metric - Revenue". A label that already begins with the * word is therefore taken AS IT STANDS. * * ⚠ IT TAKES THE RESOLVED NAME, NOT THE FIELD, and that is forced rather than tidy. The ONE name * resolver is `customer-grid/types.ts::fieldLabel` (wave 30 R5); `types.ts` imports `./windows` * at RUNTIME, and `verify_login.py` runs THIS module's compiled artifact under bare node. A value * import from there would drag that graph into the harness for the sake of one string, so the * caller resolves the name with the house resolver and this function decides only the prefix. */ export function metricLabel(name: string): string { return name.toLowerCase().startsWith("metric") ? name : `Metric - ${name}`; } /** * ⭐⭐ W40-T17 (owner instruction 15 / contract C3, amendment AM-2) — WHICH FIELDS THE * PERMISSION EDITOR'S HIDE LIST IS ABOUT. Owner, verbatim: *"stop displaying 'Shared with me' / * 'Shared with everyone' fields under Hide Fields - permission on pre-set Fields only."* * * ⛔ AM-2 IS WHY THIS IS A SEPARATE VOCABULARY RATHER THAN A NARROWING OF THE PANEL'S SECTIONS. * One payload carries two memberships because instructions 15 and 16 pull `_module_fields` in * opposite directions: *"THE HIDE PANEL TAKES `metric || !custom`, the Filter builder takes * everything with `filterable`."* Two consumers, two readings, one wire. A single list narrowed * to satisfy both would satisfy neither. * * ⛔ THE `metric ||` HALF IS LOAD-BEARING AND IT IS NOT SYMMETRY. A measure column created on an * Odoo grid is stored `source: "odoo", custom: true, derived: true` (`core/grid_events.py:1374`), * so `!custom` alone would delete every per-metric checkbox W40-T16 shipped — the control owner * instruction 13 asked for, gone, with every gate on it still green. A row that stands for a * measure is in this list BECAUSE it is a measure, whatever stratum it was minted in. * * ⚠ IT DOES NOT READ `shared`, AND THAT IS DELIBERATE. `shared` decides which SECTION the grid's * panel files a row under; `custom` decides whether the column is the database's own or somebody's * addition to it, which is the question instruction 15 asks. A shared PRE-SET column (a route * order is one: `shared: true` with no `custom`) is still the database's own column and stays. */ export function presetHideFields(fields: readonly PermsField[]): PermsField[] { return fields.filter((f) => f.isMetric || !f.custom); } /** * ⭐⭐ W40-T18 (owner instruction 16) — THE STORED CONDITION WHOSE COLUMN IS GONE. * * Owner, verbatim: *"If the field is deleted, its permission filter goes with it."* The half * that lives on the client is what the admin SEES in the meantime, and today it is a lie of * omission: `filter-kit/ops.ts::withCurrentField` appends the unresolved key as its own picker * row with `label: current` and no type mark, so a rule left over from a deleted column renders * as `custom_1723489` sitting in the sentence looking like a field name. * * ⛔ THE CONDITION IS NOT DROPPED, AND THAT IS THE RULE RATHER THAN A CHOICE. This panel edits * LIVE permission records at `settings/ModulePermsList`, and a rule that vanishes on render is a * rule the admin never decided to delete — the same law `orphanModules` states one layer up * (rule 3 in this file's header). It is MARKED and left removable instead. * * ⚠ THREE EXCLUSIONS, EACH LOAD-BEARING: * - a key the module still declares but no longer OFFERS (`filterable: false`) is NOT this. It * resolves through `fieldByKey`, keeps its own label, and `withCurrentField` appends it on * purpose so narrowing the picker never orphans history. * - `COHORT_FIELD` / `VIEW_FIELD` are leaves, not columns; they are passed IN rather than * imported, because reading them from `customer-grid/types` would be a VALUE import that * drags `./windows` into the bare-node harness `verify_login.py` runs this module under. * - AN EMPTY FIELD MAP MEANS "not answered yet", NEVER "all deleted". A schema-less module and * a payload still in flight both present as zero fields, and without `size` this would accuse * every condition in the record at once. */ export function isDeletedFieldRef( colId: string, fields: { has(key: string): boolean; size: number }, measures: { has(key: string): boolean }, leafKeys: readonly string[] ): boolean { if (colId === "" || fields.size === 0) return false; if (leafKeys.includes(colId)) return false; return !fields.has(colId) && !measures.has(colId); } /** * What that row READS as at rest. One word, and the width is why. * * ⚠ THE ARITHMETIC, so the choice is arguable rather than a preference: `.cg-cond-field` is * `flex: 0 1 136px`, and `96px` once the condition sits inside a group; index.css derives the * readable text width as `clientWidth - padding - border - caret - mark`, leaving roughly 82px * at the wide end, where "Days since order" (91.0px at 12.5px Inter) already runs over. * * ⚠ IT WOULD NOT CLIP, THOUGH, AND THE DISTINCTION MATTERS. `ops.ts`'s "about 15 characters" * note was written about a NATIVE `