| // --------------------------------------------------------------------------- | |
| // 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<string, PermsEntry>; | |
| /** | |
| * ββ 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 `<select>`, which truncates mid-glyph. This control is a | |
| * button whose `.cg-fsel-label` carries `text-overflow: ellipsis`, so a longer label degrades to | |
| * "Deleted coluβ¦" rather than breaking. One word is therefore a craft call, not a constraint: | |
| * the slot reads COMPLETE at every nesting depth, which is what `ops.ts` means by "the label | |
| * gives way, never the control". | |
| * | |
| * β SO THE RESTING ROW SAYS ONE WORD, IN AMBER, AND NOTHING MORE. The sentence below and the | |
| * raw key both live on the picker's LIST, which the admin sees on opening the control to act. | |
| * At rest the key is in the button's accessible name only. State it that way; do not claim the | |
| * row explains itself standing still. | |
| * | |
| * β NO EM DASH AND NO EN DASH (CLAUDE.md rule 2) β both of these reach a screen. | |
| */ | |
| export const DELETED_FIELD_LABEL = "Deleted"; | |
| /** The sentence across the top of the picker list (`FieldSelectButton`'s `title`, which paints | |
| * `.cg-pop-title`). One line, per DESIGN.md "never over-explain". */ | |
| export const DELETED_FIELD_NOTE = "This column no longer exists."; | |
| /** The three members of `customer-grid/FieldSelect.tsx::FieldSelectItem` this rewrite touches. | |
| * RESTATED rather than imported: that file is a `.tsx`, and `verify_login.py` compiles this | |
| * module with no `--jsx`, so even a type-only import from it would put a JSX file in the | |
| * harness's compilation. Every other member of the real interface rides through untouched | |
| * because the rewrite spreads the row it replaces. */ | |
| export interface DeletableFieldRow { | |
| key: string; | |
| label: string; | |
| hint?: string; | |
| } | |
| /** | |
| * Re-label the one picker row `withCurrentField` synthesised for a key nothing resolves. | |
| * | |
| * β THE RAW KEY IS NOT THROWN AWAY, it MOVES: out of the label, where it impersonated a field | |
| * name, and into `hint` β the muted second line `FieldSelect` already paints under a row's | |
| * label. Support can still read it, and it reads as evidence rather than as a column. | |
| */ | |
| export function withDeletedFieldLabel<T extends DeletableFieldRow>( | |
| rows: readonly T[], | |
| colId: string | |
| ): T[] { | |
| return rows.map((row) => | |
| row.key === colId ? { ...row, label: DELETED_FIELD_LABEL, hint: colId } : row | |
| ); | |
| } | |
| /** A module the editor can draw a full rule for: it came with a field list. */ | |
| export interface PermsModule { | |
| key: string; | |
| label: string; | |
| /** Empty β no readable schema β access toggle only (rule 1 above). | |
| * β W40-T16 β `PermsField[]`, so C3's per-metric rows survive the parse into what the hide | |
| * panel renders. Every consumer that wanted `Field[]` still compiles: the added key is | |
| * optional, so the two array types are assignable in both directions. */ | |
| fields: PermsField[]; | |
| /** An app surface (Assistant, Agents): access is the whole rule, by construction and | |
| * not by a missing schema, so the row can say so instead of apologising. */ | |
| surface?: boolean; | |
| /* | |
| * ββ WAVE 36 (W36-T22 / CONTRACT C2 / OWNER RULING R6) β `enforced` IS DELETED | |
| * FROM THIS SHAPE, NOT DEFAULTED TO `true`. | |
| * | |
| * It answered "does a rule stored here actually get applied?", and until wave 36 | |
| * the answer genuinely differed per row: `perm_scope` walled the registry topics | |
| * and `routes_tables` gated a `ut_*` database on `user_tables.may_open` alone, so | |
| * a wall saved against one would have been INERT β this editor saying DENY while | |
| * the table routes kept serving, with nothing anywhere saying so. | |
| * | |
| * W36-T21 armed it. `perm_scope.scoped_table` is the ONE door to any database's | |
| * rows; every `ut_*` read applies the row filter and the hidden-field closure, | |
| * and a route that cannot apply them REFUSES rather than serving the lot. So the | |
| * flag is now constantly true β and a flag that is always true is a lie with a | |
| * green gate behind it, which is why C2 says delete rather than default. | |
| * | |
| * β THE APOLOGY GOES WITH IT. `ModulePermsList`'s "Not set here" branch, this | |
| * model's unenforced filtering in `toPutBody` / `copyBlocked` / `accessSummary`, | |
| * and `routes_admin`'s `unenforced_module` refusal were four spellings of one | |
| * fact. Owner item 11: *"EVERY database should be able to be toggleable by admin. | |
| * I'm only seeing 'Not set here'."* | |
| */ | |
| } | |
| export interface PermsPayload { | |
| /** In server order β the order the sections render in. */ | |
| modules: PermsModule[]; | |
| entries: PermsRecord; | |
| /** Keys in `perms` that the server no longer declares. A whole-record replace | |
| * drops them; the editor states that before the admin saves. */ | |
| orphanModules: string[]; | |
| /** C-PERM amendment 4's migration marker. ABSENT means this record still runs | |
| * under the LEGACY wall (`bus`/`agent` query scope + the `modules` grant), so | |
| * the editor must not present empty perms as "no access" β that would be a | |
| * confident lie about an account that can currently see everything. */ | |
| migrated: boolean; | |
| /** This account is an ADMIN, and admins bypass `perms` entirely. Every rule | |
| * the editor can draw for them is inert β which it has to say out loud. */ | |
| isAdmin: boolean; | |
| } | |
| // --- parsing ---------------------------------------------------------------- | |
| /** Every `FieldType`, as a runtime set. | |
| * | |
| * β The `Record<FieldType, true>` is the point, not the Set: it is a | |
| * COMPILE-TIME exhaustiveness check. Add a type to the union in | |
| * `customer-grid/types.ts` and this file stops compiling until it is listed | |
| * here β which is how a whitelist stays honest across a tree boundary S3 does | |
| * not own. A hand-maintained array would silently narrow instead. */ | |
| const FIELD_TYPE_TABLE: Record<FieldType, true> = { | |
| text: true, status: true, currency: true, int: true, date: true, pct: true, | |
| select: true, user: true, multiselect: true, checkbox: true, phone: true, | |
| email: true, url: true, rating: true, created_time: true, formula: true, | |
| automation: true, | |
| // Wave-22 C7 (added by C, the same one-key edit the alarm demands). | |
| metric: true, | |
| // Wave-19 R7 (added by session A β see the dated amendment in the split doc). This ONE key is | |
| // the whole edit: the exhaustiveness alarm above did exactly what it promises, and the fix it | |
| // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of | |
| // this file. | |
| image: true, | |
| // Wave-23 C7 (added by session D β `settings/**` is frozen this wave and this ONE key is the | |
| // exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union | |
| // and this listing cannot land in two different changes. Posted in D's mailbox for C.) | |
| json: true, | |
| // 2026-08-07 β the relational pair, listed for the reason the alarm above states and for no | |
| // other: the exhaustiveness check is a COMPILE error, so the union and this listing cannot | |
| // land in two separate changes. Nothing about the permissions wall treats either kind | |
| // specially β a link/rollup column is granted and hidden like any other column. | |
| link: true, rollup: true, | |
| // β Wave-27 item 13 (R13) β same one-key edit, same reason, and `settings/**` is D's fence | |
| // this wave so it is not even an exception: the alarm above is a COMPILE error, so the union | |
| // and this listing cannot land in two changes. Nothing about the permissions wall treats a | |
| // code column specially β it is granted and hidden like any other column. | |
| code: true, | |
| // ββ Wave-34 (owner ruling R13) β `ai_enrich`, the same one-key edit for the same reason the | |
| // four notes above give: the exhaustiveness alarm is a COMPILE error, so the union and this | |
| // listing cannot land in two changes. `settings/**` is in NO lane's fence this wave, which | |
| // makes it less of an exception than `json` and `code` were, not more. | |
| // β Nothing about the permissions wall treats an enrichment column specially: it is granted | |
| // and hidden like any other column. The thing that IS special about it (a human-edited cell is | |
| // never overwritten by the agent) is a WRITE law in `core.user_tables`, not a grant. | |
| ai_enrich: true, | |
| }; | |
| export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE)); | |
| /** Rule 2: whitelist, never pass through, never drop. */ | |
| export function fieldType(raw: unknown): FieldType { | |
| return typeof raw === "string" && KNOWN_FIELD_TYPES.has(raw) ? (raw as FieldType) : "text"; | |
| } | |
| function asStringArray(raw: unknown): string[] { | |
| return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : []; | |
| } | |
| /** One field of a module's schema, from the nav/schema payload shape. | |
| * `key` and `label` are the minimum that makes a row renderable β a field | |
| * without them cannot be shown OR named in a rule, so it is skipped rather | |
| * than rendered as a blank the admin might tick. */ | |
| export function parseField(raw: unknown): PermsField | null { | |
| if (!raw || typeof raw !== "object") return null; | |
| const r = raw as Record<string, unknown>; | |
| if (typeof r.key !== "string" || r.key === "") return null; | |
| const label = typeof r.label === "string" && r.label !== "" ? r.label : r.key; | |
| const options = asStringArray(r.options); | |
| return { | |
| key: r.key, | |
| label, | |
| type: fieldType(r.type), | |
| // ββ W40-T16 (owner instruction 13 / C3) β THE METRIC FLAG. | |
| // β `=== true`, so an absent key means NO. `filterable` is the opposite (absent means yes), | |
| // which is exactly why the two cannot be added in one careless sweep. | |
| ...(r.metric === true ? { isMetric: true } : {}), | |
| // ββ W40-T17 (owner instruction 15 / C3) β THE TWO MEMBERSHIP FLAGS, AND THEY COULD NOT | |
| // LAND ON THEIR OWN. `FieldsHidePanel` files a field under "Shared with me" or "Shared with | |
| // everyone" from `custom || shared`, and `customer-grid/types.ts::fieldEditMode` answers | |
| // "collaborative" whenever a definition carries no `permissions` bag β which every field on | |
| // THIS wire does, because `_module_fields` sends none. So the instant these two parse, every | |
| // custom or shared column in the manage-user room lands under "Shared with everyone": the | |
| // exact list owner instruction 15 asks us to stop drawing. The gate that stops it is | |
| // `sharedSections={false}` on the manage-user mount, and it ships in this same change. Land | |
| // either half alone and the product holds the defect between them. | |
| // β `=== true` FOR BOTH, matching the metric flag one line up and OPPOSING `filterable` | |
| // (W40-T18), where absence means yes. That asymmetry lives inside this one function on | |
| // purpose: it is the shape a careless sweep gets wrong. | |
| ...(r.custom === true ? { custom: true } : {}), | |
| ...(r.shared === true ? { shared: true } : {}), | |
| // ββ W40-T18 (owner instruction 16 / C3) β THE FILTER FLAG, AND IT IS READ BACKWARDS FROM | |
| // THE THREE ABOVE ON PURPOSE. `customer-grid/types.ts::filterableFields` is | |
| // `fields.filter((f) => f.filterable !== false)`: ABSENCE MEANS FILTERABLE. So the property | |
| // is emitted ONLY when the wire says `false` outright, and a payload that never mentions it | |
| // parses WITHOUT the key β which is what leaves every such column offered. | |
| // β `filterable: r.filterable === true` is the shape this ships broken in: every field on | |
| // today's server lacks the key, so all of them would parse `false` and the permission filter | |
| // builder would offer NOTHING AT ALL, with the parse looking symmetrical and correct. | |
| // Three flags one way, one flag the other, inside one function. Read the predicate, not the | |
| // neighbours. | |
| ...(r.filterable === false ? { filterable: false } : {}), | |
| // ββ W41-T09 β WHY THE COLUMN IS NOT OFFERED, CARRIED THROUGH THIS WHITELIST. | |
| // R8 arms "only an admin-owned field may filter a permission rule", and on tenant #0 that takes | |
| // FIVE live `product_data` columns off this picker. `_module_fields` stamps a sentence beside | |
| // `filterable: false`, but this parser is a strict whitelist: without this line the sentence is | |
| // dropped here and those five simply VANISH with nothing on screen saying why β the wave-26 | |
| // empty-option trap, and the exact defect clause (a) of that ticket exists to prevent. | |
| // β Guarded on `typeof === "string"` rather than truthiness, so an empty sentence is treated as | |
| // no sentence rather than rendering a blank explanation beside a missing row. | |
| ...(typeof r.filterableReason === "string" && r.filterableReason !== "" | |
| ? { filterableReason: r.filterableReason } | |
| : {}), | |
| // Anything not explicitly the overlay stratum is treated as source data. | |
| // Consequence in this editor: nothing here offers to EDIT a field, so the | |
| // only thing `source` drives is the "Pre-set" chip in the hide list. | |
| source: r.source === "overlay" ? "overlay" : "odoo", | |
| // Carried because `identityKey` reads it β see the rule there. | |
| ...(r.pinned === true ? { pinned: true } : {}), | |
| ...(options.length ? { options } : {}), | |
| ...(typeof r.note === "string" ? { note: r.note } : {}), | |
| ...(typeof r.description === "string" && r.description !== "" | |
| ? { note: r.description } | |
| : {}), | |
| }; | |
| } | |
| /** `fields_by_module[key]` is a BARE ARRAY of fields β S1's canonical answer | |
| * (routes_admin `get_perms`), not the nav/schema envelope the contract line | |
| * implied. The earlier tolerant branch that also read `{fields: [β¦]}` is gone: | |
| * one shape, asserted here, beats two readings of a sentence. | |
| * | |
| * The LABEL does not come from here β it rides the payload's `modules` list, | |
| * which is also what fixes the ORDER. Anything unreadable yields an EMPTY field | |
| * list, which R9 already has a defined rendering for (access toggle only). */ | |
| export function parseModule(key: string, label: string, raw: unknown, | |
| surface = false): PermsModule { | |
| const fields: PermsField[] = []; | |
| for (const f of Array.isArray(raw) ? raw : []) { | |
| const parsed = parseField(f); | |
| if (parsed) fields.push(parsed); | |
| } | |
| return { key, label: label || key, fields, ...(surface ? { surface: true } : {}) }; | |
| } | |
| /** A filter is taken WHOLE or not at all. A tree whose `nodes` is not a list is | |
| * not a narrower filter, it is an unreadable one β and this editor's job is to | |
| * never show a rule it could not also save back. */ | |
| export function parseFilter(raw: unknown): FilterTree | null { | |
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; | |
| const r = raw as Record<string, unknown>; | |
| if (!Array.isArray(r.nodes)) return null; | |
| const conj = r.conj === "or" ? "or" : r.conj === "and" ? "and" : undefined; | |
| return { | |
| ...(conj ? { conj } : {}), | |
| nodes: r.nodes as FilterTree["nodes"], | |
| }; | |
| } | |
| /** β ABSENT ENTRY β `access: false`, matching what the server will ENFORCE for a | |
| * migrated record (C-PERM amendment 4: `perms_v == 1` + no entry β DENY). The | |
| * editor showing "access on" for a module the wall denies would send an admin | |
| * to debug a permission that was never granted. | |
| * | |
| * For an UN-migrated record the same absence means the opposite β the legacy | |
| * wall still applies and may grant everything β which is why `PermsPayload` | |
| * carries `migrated` and the editor states it rather than letting this default | |
| * speak for a case it does not describe. */ | |
| export function parseEntry(raw: unknown): PermsEntry { | |
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | |
| return { access: false, filter: null, hiddenFields: [] }; | |
| } | |
| const r = raw as Record<string, unknown>; | |
| return { | |
| access: r.access === true, | |
| filter: parseFilter(r.filter), | |
| hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)), | |
| // ββ W38-T19 β `!== false`, NOT `=== true`, AND THE TWO ARE OPPOSITE HERE. `access` reads | |
| // absence as DENY because a migrated record declares every governed database, so a missing | |
| // `access` is a decision. No `metrics` key was STORABLE before this ticket, so EVERY record | |
| // in every tenant is missing one: reading absence as deny would paint the box unticked for | |
| // every account on the day this shipped, and the next save of any unrelated change would | |
| // write that revocation for real. The server's `may_metrics` and `_clean_perms` default the | |
| // same direction, so the editor shows what the wall will actually do. | |
| metrics: r.metrics !== false, | |
| }; | |
| } | |
| /** Sorted + de-duplicated, always. Dirty-detection compares payloads, so a list | |
| * whose ORDER can drift would make an untouched module read as edited. */ | |
| export function normalizeHidden(keys: readonly string[]): string[] { | |
| return [...new Set(keys)].sort(); | |
| } | |
| /** | |
| * The IDENTITY column β the one field a module may never hide. | |
| * | |
| * β WHY THIS EXISTS AT ALL. `FieldsHidePanel` takes an optional `lockedKey` and | |
| * documents that "absent = nothing is locked". Absent, a single click on | |
| * "Hide all" hides EVERY field including the row's own name β and with | |
| * amendment 5's server-side transitive closure behind it, that writes a record | |
| * whose faithful enforcement is a table of blank rows. The user could open the | |
| * module and see nothing in it, the wall doing exactly what the record said. | |
| * Nothing in C-PERM's PUT validation refuses that: it refuses UNKNOWN field | |
| * keys, and the identity column is perfectly known. | |
| * | |
| * β MIRRORED, NOT INVENTED: this is `useGridColumns.ts:201-204`'s rule verbatim | |
| * β the pinned field, else the first. The grid computes the same key to decide | |
| * which column its own Hide-fields panel locks, so an editor using a different | |
| * rule would lock a different column from the one the table protects. | |
| */ | |
| export function identityKey(fields: readonly Field[]): string { | |
| return fields.find((f) => f.pinned)?.key ?? fields[0]?.key ?? ""; | |
| } | |
| /** Every field a module MAY hide β the whole list minus its identity column. */ | |
| export function hideableKeys(fields: readonly Field[]): string[] { | |
| const locked = identityKey(fields); | |
| return fields.filter((f) => f.key !== locked).map((f) => f.key); | |
| } | |
| /** | |
| * ββ W40-T17 β THE KEYS THE PERMISSION EDITOR'S HIDE LIST ACTUALLY GOVERNS: what it LISTS | |
| * (`presetHideFields`, AM-2's `metric || !custom`) minus the one row it can never hide. | |
| * | |
| * ββ THE LOCKED KEY IS RESOLVED FROM THE **FULL** LIST, AND SWAPPING THAT IS A SILENT | |
| * MIS-LOCK. `identityKey` is "the pinned field, else THE FIRST", so | |
| * `identityKey(presetHideFields(fields))` would answer with whatever survives the narrowing | |
| * first β on any module whose identity column were ever filtered out, a perfectly ordinary | |
| * column would be treated as the one that cannot be hidden, while the real identity column | |
| * became hideable. The panel's own `lockedKey={identityKey(module.fields)}` reads the full list | |
| * for exactly this reason, and these two must agree or the row that is disabled on screen is not | |
| * the row the bulk action protects. | |
| */ | |
| export function governedFieldKeys(fields: readonly PermsField[]): string[] { | |
| const locked = identityKey(fields); | |
| return presetHideFields(fields).map((f) => f.key).filter((k) => k !== locked); | |
| } | |
| /** | |
| * ββ W40-T17 β "Hide all", SCOPED TO WHAT THE ROOM SHOWS. | |
| * | |
| * β WHY THIS IS NOT `hideableKeys(fields)` ANY MORE, AND WHY W40-T17 IS THE TICKET THAT OWES | |
| * THE CHANGE. Before the narrowing, the permission editor LISTED every field a module had, so a | |
| * whole-list replace and the list on screen named the same set. This ticket narrows the list to | |
| * the database's own pre-set columns plus its bound measures, and a bulk action that kept | |
| * replacing the WHOLE list would then write hidden keys for columns the admin cannot see in the | |
| * room they clicked in β a restriction nobody was shown, which is the one thing this file's | |
| * header forbids. | |
| * | |
| * β SO IT REPLACES A SLICE, NOT THE RECORD: the stored list minus the governed keys, plus the | |
| * governed keys. Anything the record already hid that this room does not govern is carried | |
| * through untouched, exactly as `copyTargetRecord`'s module scope carries the target's other | |
| * modules. | |
| * | |
| * β AND THE IDENTITY KEY IS STRIPPED ON THE WAY OUT even though `governedFieldKeys` already | |
| * excludes it. A record written elsewhere could name it, and a "carry the ungoverned keys | |
| * through" rule would faithfully carry THAT one through as well β re-creating the table of blank | |
| * rows this file's `identityKey` header exists to prevent, through the very control that used to | |
| * be immune to it because it replaced the list wholesale. `toPutBody` strips it again at the PUT | |
| * boundary; two layers, for the reason stated there. | |
| */ | |
| export function hideAllKeys( | |
| rec: PermsRecord, | |
| key: string, | |
| fields: readonly PermsField[], | |
| ): string[] { | |
| const governed = governedFieldKeys(fields); | |
| const governedSet = new Set(governed); | |
| const locked = identityKey(fields); | |
| const stored = rec[key]?.hiddenFields ?? []; | |
| return normalizeHidden( | |
| [...stored.filter((k) => !governedSet.has(k)), ...governed].filter((k) => k !== locked)); | |
| } | |
| /** | |
| * ββ W40-T17 β "Show all", SCOPED THE SAME WAY, AND THIS IS THE HALF THAT MATTERS MOST. | |
| * | |
| * ββ IT SUBTRACTS, IT DOES NOT CLEAR. The old spelling was `onSetHidden([])`: an empty list, | |
| * i.e. nothing is hidden on this database at all. Under a narrowed list that control REVEALS | |
| * columns the admin was never shown β it WIDENS a permission through a button whose own list did | |
| * not contain the fields it just un-hid. "Hide all" over-reaching is dishonest and fails safe; | |
| * "Show all" over-reaching fails OPEN, and a permissions screen is not allowed to get that | |
| * direction wrong. | |
| * | |
| * β IT READS THE **STORED** `hiddenFields`, NEVER `hiddenSetFor`. That union is a READ-SIDE view | |
| * that paints every metric row hidden while a legacy blanket `metrics: false` stands; feeding it | |
| * into a write would persist the blanket as explicit keys from a control that is supposed to be | |
| * clearing them. The blanket's conversion has exactly one door β `normalizeMetrics`, which | |
| * `PermsEditor` already runs in front of every hidden-field write β and adding a second here is | |
| * how two normalisers come to disagree. | |
| */ | |
| export function showAllKeys( | |
| rec: PermsRecord, | |
| key: string, | |
| fields: readonly PermsField[], | |
| ): string[] { | |
| const governedSet = new Set(governedFieldKeys(fields)); | |
| const locked = identityKey(fields); | |
| const stored = rec[key]?.hiddenFields ?? []; | |
| return normalizeHidden(stored.filter((k) => !governedSet.has(k) && k !== locked)); | |
| } | |
| /** `{perms, fields_by_module}` β everything the editor renders. Returns `null` | |
| * only for a body that is not an object at all; a body missing either half | |
| * parses to an editor with nothing to offer, which is the honest rendering of | |
| * "the server told us nothing". */ | |
| export function parsePermsPayload(body: unknown): PermsPayload | null { | |
| if (!body || typeof body !== "object") return null; | |
| const b = body as Record<string, unknown>; | |
| const byModule = | |
| b.fields_by_module && typeof b.fields_by_module === "object" | |
| ? (b.fields_by_module as Record<string, unknown>) | |
| : {}; | |
| const rawPerms = | |
| b.perms && typeof b.perms === "object" && !Array.isArray(b.perms) | |
| ? (b.perms as Record<string, unknown>) | |
| : {}; | |
| // β `modules` CARRIES THE LABELS AND THE ORDER, and `fields_by_module` carries | |
| // neither. Deriving sections from the field map alone renders every heading as | |
| // a registry KEY β "customer_data" where the product says "Customer" β and in | |
| // whatever order the JSON happens to enumerate. The field map is still the | |
| // outer bound: a module named in `modules` with no field list is the | |
| // schema-less case, not an error. | |
| // | |
| // ββ WAVE 36 (W36-T22 / C2) β no `enforced` is read, and an OLDER SERVER STILL | |
| // PARSES: a payload that carries the key is simply ignored, which is the one | |
| // direction this can be wrong in safely. Every row the server declares is a row | |
| // whose wall `routes_tables` applies (W36-T21), so there is nothing left for a | |
| // per-row flag to distinguish. | |
| const declared: Array<{ key: string; label: string; surface?: boolean }> = Array.isArray(b.modules) | |
| ? (b.modules as unknown[]).flatMap((m) => { | |
| if (!m || typeof m !== "object") return []; | |
| const r = m as Record<string, unknown>; | |
| return typeof r.key === "string" && r.key | |
| ? [{ key: r.key, label: typeof r.label === "string" ? r.label : r.key, | |
| surface: r.surface === true }] | |
| : []; | |
| }) | |
| : Object.keys(byModule).map((k) => ({ key: k, label: k })); | |
| const modules = declared.map((d) => parseModule(d.key, d.label, byModule[d.key], d.surface)); | |
| const entries: PermsRecord = {}; | |
| for (const m of modules) entries[m.key] = parseEntry(rawPerms[m.key]); | |
| return { | |
| modules, | |
| entries, | |
| orphanModules: Object.keys(rawPerms).filter((k) => !(k in entries)).sort(), | |
| migrated: typeof b.perms_v === "number" && b.perms_v >= 1, | |
| // `role == 'admin'` bypasses `perms` entirely (C-PERM amendment 4). Sent by | |
| // the route so the editor can SAY so instead of rendering stored rules that | |
| // do not apply β the one misreading of that clause that could hurt. | |
| isAdmin: b.is_admin === true, | |
| }; | |
| } | |
| // --- the draft the admin is editing ----------------------------------------- | |
| /** Immutable edits: every setter returns a NEW record, so React sees the change | |
| * and `isDirty` compares against a snapshot that no setter has mutated under | |
| * it. In-place edits are how a Save button ends up permanently greyed. */ | |
| function withEntry(rec: PermsRecord, key: string, patch: Partial<PermsEntry>): PermsRecord { | |
| const cur = rec[key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| return { ...rec, [key]: { ...cur, ...patch } }; | |
| } | |
| export function setAccess(rec: PermsRecord, key: string, access: boolean): PermsRecord { | |
| return withEntry(rec, key, { access }); | |
| } | |
| export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | null): PermsRecord { | |
| // An empty tree is NO filter, not an empty one. `{nodes: []}` matches every | |
| // row, so persisting it would mean "restricted, to everything" β a rule that | |
| // reads as a restriction in the record and is not one on screen. | |
| const empty = !filter || filter.nodes.length === 0; | |
| return withEntry(rec, key, { filter: empty ? null : filter }); | |
| } | |
| /** ββ W38-T19 β the Metrics capability toggle. A plain `withEntry` patch like every other | |
| * setter, so the draft stays immutable and `isDirty` sees the change through `toPutBody`. */ | |
| export function setMetrics(rec: PermsRecord, key: string, metrics: boolean): PermsRecord { | |
| return withEntry(rec, key, { metrics }); | |
| } | |
| export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord { | |
| return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) }); | |
| } | |
| export function toggleHidden(rec: PermsRecord, key: string, fieldKey: string): PermsRecord { | |
| const cur = rec[key]?.hiddenFields ?? []; | |
| const next = cur.includes(fieldKey) | |
| ? cur.filter((k) => k !== fieldKey) | |
| : [...cur, fieldKey]; | |
| return setHidden(rec, key, next); | |
| } | |
| export function hiddenSet(rec: PermsRecord, key: string): ReadonlySet<string> { | |
| return new Set(rec[key]?.hiddenFields ?? []); | |
| } | |
| /** | |
| * ββ W40-T16 β WHAT THE HIDE PANEL ACTUALLY DRAWS AS HIDDEN: the stored list, UNION every | |
| * metric row when a legacy blanket revocation stands. | |
| * | |
| * β THE CASE IT EXISTS FOR IS THE TICKET'S OWN NEGATIVE CONTROL: *"a user whose saved record | |
| * predates this still has their old blanket metrics setting honoured"*. `PermsEntry.metrics` is | |
| * one boolean over the whole database (W38-T19), and this ticket replaces the control that wrote | |
| * it with one checkbox per metric. A record already carrying `metrics: false` therefore has a | |
| * revocation with nothing on screen expressing it β the panel would paint every metric row as | |
| * granted while `perm_scope.may_metrics` refuses the lot. | |
| * | |
| * β A READ-SIDE UNION, NEVER A REWRITE ON LOAD. Folding the blanket into `hiddenFields` when the | |
| * record parses would edit somebody's stored rule as a side effect of an admin OPENING the page, | |
| * and the first unrelated Save would persist it. `metrics` stays on the wire, in `parseEntry` and | |
| * in `toPutBody`, exactly as W38-T19 left it; this function only decides how it READS. | |
| * | |
| * β A NEW EXPORT RATHER THAN A WIDER `hiddenSet`. `verify_login.py` compiles | |
| * `shell/_test/shell.test.ts` in the same tsc call as this file's own suite, and re-signing an | |
| * existing export reds a gate in a file this ticket may not repair. `hiddenSet` is still the base | |
| * and is called below, so there is ONE spelling of "the keys the record hides". | |
| */ | |
| export function hiddenSetFor( | |
| rec: PermsRecord, | |
| key: string, | |
| fields: readonly PermsField[], | |
| ): ReadonlySet<string> { | |
| const keys = new Set(hiddenSet(rec, key)); | |
| if (rec[key]?.metrics === false) { | |
| for (const f of fields) if (f.isMetric) keys.add(f.key); | |
| } | |
| return keys; | |
| } | |
| /** | |
| * ββ W40-T16 β THE WRITE-SIDE HALF OF `hiddenSetFor`, AND WITHOUT IT THE READ-SIDE HALF IS A | |
| * TRAP. Called at the top of every hidden-field write: a blanket `metrics: false` becomes the | |
| * EXPLICIT per-metric state instruction 13 asks for, exactly once, at the moment an admin edits. | |
| * | |
| * ββ THE DEFECT IT CLOSES, STATED AS THE STATE IT WOULD OTHERWISE LEAVE. `hiddenSetFor` unions | |
| * every metric key while `metrics === false`, so on such a record a metric row reads hidden and | |
| * CANNOT BE MADE VISIBLE AGAIN: unticking it writes the key into `hiddenFields`, the union | |
| * re-hides it, "Show all" is inert for it, and β because W40-T16 retires the blanket box and | |
| * nothing else in the UI writes `PermsEntry.metrics` β the revocation is permanent and has no | |
| * exit. That makes this ticket's own `done-when` ("checking one hides exactly that metric") | |
| * FALSE for those records. A read-side union alone is half a feature. | |
| * | |
| * β ON EDIT, NEVER ON LOAD, AND THE TWO ARE DIFFERENT IN KIND. Rewriting when the record parses | |
| * would edit somebody's stored rule as a side effect of an admin OPENING a page, and the first | |
| * unrelated Save would persist a decision nobody made. An edit is a decision: the admin is | |
| * already changing this database's hidden fields. | |
| * | |
| * β THE UNION IS WHAT MAKES IT LOSSLESS. What the blanket MEANT (every metric hidden) becomes | |
| * what the record SAYS, so effective permissions do not move by a single field at the moment of | |
| * normalisation β only their spelling does. A rewrite that dropped the metrics would silently | |
| * GRANT them; one that kept `metrics: false` would change nothing. | |
| * | |
| * β A NO-OP OTHERWISE, RETURNING `rec` ITSELF rather than a copy. Every setter here is immutable | |
| * so that `isDirty` compares against an unmutated snapshot; a fresh object on every keystroke of | |
| * an ordinary edit would be churn with no meaning, and reference identity is what lets a caller | |
| * compose this in front of any write without paying for it. | |
| */ | |
| export function normalizeMetrics( | |
| rec: PermsRecord, | |
| key: string, | |
| fields: readonly PermsField[], | |
| ): PermsRecord { | |
| const cur = rec[key]; | |
| if (!cur || cur.metrics !== false) return rec; | |
| const metricKeys = fields.filter((f) => f.isMetric).map((f) => f.key); | |
| // ββ THE EMPTY CASE IS THE ONE THE PARAGRAPH ABOVE RULES OUT, AND IT IS REACHABLE TODAY. | |
| // "THE UNION IS WHAT MAKES IT LOSSLESS ... a rewrite that DROPPED the metrics would silently | |
| // GRANT them" is exactly what happens when `metricKeys` is empty: `metrics` flips to `true`, | |
| // nothing is added to `hiddenFields`, and an explicit revocation an admin once made is gone. | |
| // | |
| // Not hypothetical. `customer_data` binds NO measures at all: `model/topics/odoo_customers.yml` | |
| // declares no `measures:` key, so `routes_admin::_metric_fields('customer_data')` returns `[]` | |
| // and every field arrives `isMetric: false`. Any legacy `customer_data` record carrying | |
| // `metrics: false` therefore had its revocation erased by the admin's next unrelated | |
| // hidden-field edit, with `isDirty` lighting up Save on a change nobody made. | |
| // | |
| // β AND RETURNING EARLY REINTRODUCES NOTHING. The trap this function exists to close is | |
| // `hiddenSetFor` unioning metric keys into the hidden set so a metric ROW cannot be un-hidden. | |
| // With no metric fields there is no such row, so there is nothing to be trapped by and nothing | |
| // to normalise. Found by wave 40 QA; the mechanism itself is correct wherever metrics exist. | |
| if (!metricKeys.length) return rec; | |
| return withEntry(rec, key, { | |
| metrics: true, | |
| hiddenFields: normalizeHidden([...cur.hiddenFields, ...metricKeys]), | |
| }); | |
| } | |
| export function filterOf(rec: PermsRecord, key: string): FilterTree { | |
| // The panel takes a tree, never null β an absent filter is an EMPTY tree to | |
| // edit, which is what "add your first condition" has to render against. | |
| return rec[key]?.filter ?? { nodes: [] }; | |
| } | |
| // --- what gets sent --------------------------------------------------------- | |
| /** | |
| * The PUT body. Whole-record replace, so this emits an entry for EVERY module | |
| * the server declared β including the ones the admin never touched, because a | |
| * missing key in a replace is a deletion, not a no-op. | |
| * | |
| * β A module with no readable schema is emitted `{access, filter: null, | |
| * hiddenFields: []}` (R9). Its access toggle is real and is honoured; what is | |
| * refused is inventing a restriction against a field list nobody could read. | |
| * | |
| * ββ WAVE 36 (W36-T22 / C2) β EVERY DECLARED MODULE IS EMITTED. The wave-33 rule | |
| * here was "an unenforced module is not emitted at all", because | |
| * `routes_admin::_clean_perms` refused the key with `unenforced_module` and | |
| * emitting it would have 400'd the whole save. That refusal is deleted: W36-T21 | |
| * armed the wall over every database, so a rule stored against a `ut_*` one is | |
| * applied by the same code that applies `customer_data`'s. Withholding it now | |
| * would be the opposite defect β an admin edits a database, presses Save, and the | |
| * editor silently drops it. | |
| */ | |
| export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: PermsRecord } { | |
| const perms: PermsRecord = {}; | |
| for (const m of payload.modules) { | |
| const e = rec[m.key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| // β The identity column is stripped at the BOUNDARY as well as withheld | |
| // from the UI. The panel never offers it, but a record written before this | |
| // rule β or by anything else β could still name it, and this editor is the | |
| // last place that record passes through before the wall enforces it | |
| // faithfully. Two layers for the same reason C-PERM validates at PUT time | |
| // AND `permits()` re-checks: records go stale in ways forms cannot. | |
| const locked = identityKey(m.fields); | |
| perms[m.key] = m.fields.length | |
| ? { | |
| access: e.access, | |
| filter: e.filter, | |
| hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)), | |
| // ββ W38-T19 β ALWAYS EMITTED, never left to the server's default. A PUT is a | |
| // whole-record replace and this editor is the last thing the record passes through, | |
| // so a key it withholds is a key whose meaning is decided somewhere else. Emitting it | |
| // is also what keeps `isDirty` honest: that function compares the SENT SHAPE, and a | |
| // capability the payload never carries could be toggled all day without lighting Save. | |
| metrics: e.metrics !== false, | |
| } | |
| // ββ W38-T19 β THE SCHEMA-LESS ARM EMITS **NO** `metrics` KEY, and the omission is the | |
| // rule rather than an oversight. R9: access is the WHOLE rule for a module with no | |
| // readable schema. An Assistant or Agents row has no grid and therefore no measure door, | |
| // this editor renders no Metrics box for it, and a whole-record COPY is the one path that | |
| // could otherwise carry a revocation onto it β a restriction the admin was never shown. | |
| // β ABSENT IS NOT UNDECIDED HERE, IT IS GRANTED, and it resolves in exactly one place: | |
| // `_clean_perms` stores `bool(raw.get("metrics", True))`, so an omitted key persists as | |
| // `true`. Sending `true` explicitly would be the same value by a longer road, and it | |
| // would break `shell/_test/shell.test.ts`'s "a schema-less module saves access only" β | |
| // a leg whose CLAIM is right and which this file must not make false. | |
| : { access: e.access, filter: null, hiddenFields: [] }; | |
| } | |
| return { perms }; | |
| } | |
| /** Which slice of one account's access a copy carries onto another. */ | |
| export type CopyScope = { kind: "all" } | { kind: "module"; key: string }; | |
| /** | |
| * C-PERMCOPY (wave 17, item 16) β the record to PUT onto ONE target, composed | |
| * from that target's OWN payload and this account's SAVED entries. | |
| * | |
| * β WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE COMPONENT. It is the | |
| * SECOND producer of a PUT body in the product, and the first one aimed at | |
| * somebody else's record. A PUT is a whole-record replace: a key that does not | |
| * come back is DELETED. `toPutBody` already carries a negative control named | |
| * `replace-omits-untouched-modules` for exactly that failure β and every way of | |
| * getting it wrong from here reaches the same place by a different road. Inline | |
| * in a `useCallback`, no gate could see any of it. | |
| * | |
| * THE RULE, in one line: **the target's tree is the base; the copy overwrites | |
| * a slice of it.** Never the source's tree with the target's bits merged in β | |
| * that composes a record out of modules the TARGET may not declare. | |
| * | |
| * Β· `{kind:'all'}` β the whole record is replaced by the source's. | |
| * Β· `{kind:'module'}` β that one module is replaced; every other module keeps | |
| * the value the target's own GET returned, byte for byte. | |
| * | |
| * β TWO EDGES, both currently unreachable because `_PERM_MODULES` is server-wide | |
| * (every payload declares the same modules), and both named rather than left to | |
| * be discovered if that ever stops being true: | |
| * 1. The source has a module the TARGET does not declare. `toPutBody` iterates | |
| * the TARGET's modules, so the copy is dropped β silently, under a message | |
| * that says it was copied. `copyDropped()` below is what lets the caller | |
| * tell the truth about that. | |
| * 2. The target declares a module the SOURCE has no entry for. It lands on | |
| * `NO_ACCESS`, which is the honest reading of "apply this account's access": | |
| * if the source does not grant it, the target must not keep it. Deliberate, | |
| * and it is why a whole-record copy is offered as "replace", not "merge". | |
| */ | |
| export function copyTargetRecord( | |
| target: PermsPayload, | |
| source: PermsRecord, | |
| scope: CopyScope | |
| ): PermsRecord { | |
| if (scope.kind === "all") { | |
| const out: PermsRecord = {}; | |
| for (const m of target.modules) { | |
| out[m.key] = source[m.key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| } | |
| return out; | |
| } | |
| return { | |
| ...target.entries, | |
| [scope.key]: source[scope.key] ?? { access: false, filter: null, hiddenFields: [] }, | |
| }; | |
| } | |
| /** The modules a copy CANNOT carry, because the target does not declare them. | |
| * Empty in every deployment where the module list is server-wide; a caller that | |
| * reports "copied" without consulting it would be guessing. */ | |
| export function copyDropped( | |
| target: PermsPayload, | |
| source: PermsRecord, | |
| scope: CopyScope | |
| ): string[] { | |
| // β IT MUST MATCH `toPutBody`'s FILTER EXACTLY β this function's whole job is to | |
| // name what the copy will NOT carry. W36-T22 deleted the `enforced` filter from | |
| // both, so both read the module list whole; if one of them ever grows a filter | |
| // again, the other has to grow it in the same commit. | |
| const declared = new Set(target.modules.map((m) => m.key)); | |
| const wanted = scope.kind === "all" ? Object.keys(source) : [scope.key]; | |
| return wanted.filter((k) => !declared.has(k)).sort(); | |
| } | |
| /** Compares the SENT SHAPE, not the draft, so a change the payload cannot carry | |
| * (a hidden-field list on a schema-less module) never lights up Save. */ | |
| export function isDirty(payload: PermsPayload, saved: PermsRecord, draft: PermsRecord): boolean { | |
| return ( | |
| JSON.stringify(toPutBody(payload, saved)) !== JSON.stringify(toPutBody(payload, draft)) | |
| ); | |
| } | |
| // --- how a rule reads ------------------------------------------------------- | |
| /** Counts a tree's leaves, groups included. The editor states the size of a | |
| * restriction rather than showing "Filtered" for one condition and for twenty. */ | |
| export function countLeaves(tree: FilterTree | null): number { | |
| if (!tree) return 0; | |
| let n = 0; | |
| const walk = (nodes: readonly unknown[]) => { | |
| for (const node of nodes) { | |
| if (node && typeof node === "object" && Array.isArray((node as { children?: unknown[] }).children)) { | |
| walk((node as { children: unknown[] }).children); | |
| } else { | |
| n += 1; | |
| } | |
| } | |
| }; | |
| walk(tree.nodes); | |
| return n; | |
| } | |
| /** | |
| * One sentence per module for the accounts list and the section head. | |
| * | |
| * β NO EMOJI, no icon vocabulary β this string is read aloud by a screen reader | |
| * and printed in the gate's output. It also never says "restricted" without | |
| * saying to WHAT: "Filtered" alone is the kind of summary that makes an admin | |
| * open every section to find the one that is set. | |
| */ | |
| export function moduleSummary(entry: PermsEntry | undefined, schemaless = false): string { | |
| if (!entry || !entry.access) return "No access"; | |
| if (schemaless) return "Full access"; | |
| const conds = countLeaves(entry.filter); | |
| const hidden = entry.hiddenFields.length; | |
| // ββ W38-T19 β A REVOKED CAPABILITY IS A RESTRICTION AND THE COLLAPSED ROW HAS TO SAY SO. | |
| // The whole point of this sentence is that an admin can read a database's rule without | |
| // opening it; a row reading "Full access" over an account that cannot build a Metric column | |
| // is the summary lying, which is the failure mode this function's own header is about. | |
| const noMetrics = entry.metrics === false; | |
| if (!conds && !hidden && !noMetrics) return "Full access"; | |
| const parts: string[] = []; | |
| if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`); | |
| if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`); | |
| if (noMetrics) parts.push("Metrics off"); | |
| return parts.join(", "); | |
| } | |
| /** The account-list cell: what this user may open, across all modules. | |
| * | |
| * β W36-T22 β COUNTS EVERY DECLARED MODULE. Wave 33 counted enforced ones only, | |
| * because this sentence must describe what the payload will SAVE and a `ut_*` | |
| * database's access was not decided here at all. It is now (W36-T21 / R6), so | |
| * omitting those five would under-report an account's real access β the same | |
| * rule, with the fact underneath it changed. */ | |
| export function accessSummary(payload: PermsPayload | null, rec: PermsRecord): string { | |
| const governed = payload?.modules ?? []; | |
| if (!payload || governed.length === 0) return ""; | |
| const open = governed.filter((m) => rec[m.key]?.access); | |
| if (open.length === 0) return "No access"; | |
| // β A SCHEMA-LESS MODULE CANNOT BE RESTRICTED, so it is never counted as one | |
| // β the same guard `moduleSummary` takes. Without it a draft filter on a | |
| // module whose rule `toPutBody` strips would be summarised as "1 restricted", | |
| // describing a restriction that is not going to be saved. The header and the | |
| // Save confirmation both read this sentence, so it must describe the PAYLOAD. | |
| const restricted = open.filter( | |
| (m) => | |
| m.fields.length > 0 && | |
| ((rec[m.key]?.filter?.nodes.length ?? 0) > 0 || | |
| (rec[m.key]?.hiddenFields.length ?? 0) > 0) | |
| ).length; | |
| const all = open.length === governed.length; | |
| const head = all | |
| ? `All ${open.length} module${open.length === 1 ? "" : "s"}` | |
| : `${open.length} of ${governed.length} modules`; | |
| return restricted ? `${head}, ${restricted} restricted` : head; | |
| } | |