loopable / web /src /settings /permsModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
2183dbe verified
Raw
History Blame
63.3 kB
// ---------------------------------------------------------------------------
// 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;
}