From 44b5219cea4d81d5ba51fe27820fa110cc1731a1 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:08:57 -0400 Subject: [PATCH 01/25] fix(backstage): key the cargo lock on the Admin role, not on the widened seat flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positionsAssignmentSafe() gates its two cargo conjuncts on DIFFERENT principals and this branch collapsed them onto one flag. NEW side (cargoAssignableByNonAdmin, the cargo written IN) — delegated, boardSeatDelegate() lifts it. That is the feature. OLD side (currentCargoGrantsEmpty, the cargo REPLACED) — Admin ROLE only, deliberately never delegated: a principal who can overwrite a sitting Admin's cargo can strip every Admin claim in the chapter. Both forms typed `!allowPowerGrants && positionsLockedForNonAdmin(...)`, which was correct only while allowPowerGrants MEANT isAdmin. Widening it to canAssignBoardSeat unlocked the editor for a delegate opening a member seated on a power-granting cargo — full picker, enabled Guardar, guaranteed PERMISSION_DENIED, and the generic "Intenta de nuevo" that every retry earns. The render-then-die shape assignable-cargo exists to prevent, reintroduced in the file that prevents it. The flag is now a PARAMETER of positionsLockedForEditor rather than &&-ed per call site, so the mirror cannot be keyed on the wrong conjunct again, and all four call sites pass allowReplacePowerCargo={isAdmin} alongside allowPowerGrants. Also adds the note for the one outcome in this lane that fails SILENTLY: a delegate may write an Admin-granting cargo and the save succeeds, but resolveTrustedGrants refuses to honor it from a non-Admin assigner, so the seat publishes and mints nothing. Nothing told the editor. Mutation-tested: reverting either call site, or neutralizing either flag, turns exactly the new BLOCKING tests red. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-drawer.tsx | 3 +- .../members/components/member-form.test.tsx | 74 ++++++++- .../members/components/member-form.tsx | 53 ++++-- .../components/member-invite-drawer.tsx | 19 ++- .../components/member-positions-form.test.tsx | 99 ++++++++++- .../components/member-positions-form.tsx | 50 ++++-- .../components/member-profile-page.tsx | 63 +++++-- .../members/lib/assignable-cargo.test.ts | 156 ++++++++++++++++++ .../features/members/lib/assignable-cargo.ts | 54 +++++- 9 files changed, 506 insertions(+), 65 deletions(-) create mode 100644 apps/backstage/src/features/members/lib/assignable-cargo.test.ts diff --git a/apps/backstage/src/features/members/components/member-drawer.tsx b/apps/backstage/src/features/members/components/member-drawer.tsx index 4dbbf5bd..97447c65 100644 --- a/apps/backstage/src/features/members/components/member-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-drawer.tsx @@ -136,7 +136,7 @@ function EditBody({ onSubmit: (data: MemberInput) => Promise; }) { const { onUpload, onRemove } = useMemberPhoto(member.id); - const { canAssignBoardSeat } = useCan(); + const { canAssignBoardSeat, isAdmin } = useCan(); return (
diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index c2252593..042351ac 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -5,6 +5,13 @@ import type { MemberInput, Position } from "@luminova/types"; import { MemberForm } from "./member-form"; import { toMemberUpdateDoc } from "../repositories/member-mapper"; import { pickDate } from "../../../test/pick-date"; +import { permissionLabel } from "../../permissions/lib/permission-matrix"; + +// The note names the permission through `permissionLabel`, and its own comment says the two +// features must not drift. Assert against the same source, not a hardcoded copy — a literal +// here would keep passing after either half of the label is renamed, which is exactly the +// coupling the note is worried about. +const BOARD_SEAT_LABEL = permissionLabel("update:BoardSeat"); const positions: Position[] = [ { @@ -137,7 +144,7 @@ describe("MemberForm", () => { allowPowerGrants={false} />, ); - expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/); + expect(screen.getByRole("note")).toHaveTextContent(BOARD_SEAT_LABEL); await userEvent.click(screen.getByLabelText("Cargo")); expect(await screen.findByText("Sin resultados")).toBeInTheDocument(); unmount(); @@ -330,7 +337,9 @@ describe("MemberForm", () => { onSubmit={vi.fn()} />, ); - expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Solo un administrador puede cambiar el cargo/i), + ).not.toBeInTheDocument(); }); // Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added @@ -423,7 +432,66 @@ describe("MemberForm", () => { onSubmit={vi.fn()} />, ); - expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Solo un administrador puede cambiar el cargo/i), + ).not.toBeInTheDocument(); + }); + + // BLOCKING: the two rules conjuncts of positionsAssignmentSafe() are gated on DIFFERENT + // principals. `update:BoardSeat` lifts the NEW side (cargoAssignableByNonAdmin, the cargo + // written in) — which is what `allowPowerGrants` carries — but the OLD side + // (currentCargoGrantsEmpty, the cargo being REPLACED) is Admin-ROLE only and is deliberately + // NOT delegated. So the delegate is the one principal for whom both flags disagree, and the + // form must still lock. While the lock was `!allowPowerGrants && locked(...)` this render + // handed a delegate an open picker on a write the rules ALWAYS deny: render-then-403. + const powerCargo: Position = { + id: "pos-secre", + title: "Secretario", + titleFemale: "Secretaria", + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "Lleva las actas.", + active: true, + deletedAt: null, + }; + + it("BLOCKING: locks for a board-seat DELEGATE on a member seated on a power-granting cargo", () => { + render( + , + ); + const trigger = screen.getByLabelText("Cargo"); + expect(trigger).toBeDisabled(); + const note = screen.getByText(/Solo un administrador puede cambiar el cargo/i); + expect(note).toBeInTheDocument(); + // The note sits after the field in the DOM, so the association is the only way a + // screen-reader user reaching a disabled trigger meets the reason. + expect(trigger).toHaveAttribute("aria-describedby", note.id); + }); + + it("does NOT lock an Admin on that same power-granting seat", () => { + render( + , + ); + expect(screen.getByLabelText("Cargo")).not.toBeDisabled(); + expect( + screen.queryByText(/Solo un administrador puede cambiar el cargo/i), + ).not.toBeInTheDocument(); }); it("renders comisión option as 'sigla — title' when sigla is present", async () => { diff --git a/apps/backstage/src/features/members/components/member-form.tsx b/apps/backstage/src/features/members/components/member-form.tsx index 10ea19f4..f2465a1d 100644 --- a/apps/backstage/src/features/members/components/member-form.tsx +++ b/apps/backstage/src/features/members/components/member-form.tsx @@ -24,12 +24,15 @@ import { } from "@luminova/types"; import { avatarColor } from "../lib/member-display"; import { + cargoGrantNeedsAdminAssigner, cargoOptionsForEditor, cargoTakedownOnly, noAssignableCargos, - positionsLockedForNonAdmin, + positionsLockedForEditor, } from "../lib/assignable-cargo"; -import { NoAssignableCargosNote } from "./no-assignable-cargos-note"; +import { NoAssignableCargosNote, NO_ASSIGNABLE_CARGOS_NOTE_ID } from "./no-assignable-cargos-note"; + +const LOCKED_NOTE_ID = "member-cargo-locked-note"; interface MemberFormProps { positions: Position[]; @@ -44,6 +47,11 @@ interface MemberFormProps { * `createPositionsSafe` and `positionsAssignmentSafe`). Non-Admin sees only assignable * cargos plus the current selection. */ allowPowerGrants?: boolean; + /** Whether the editor may REPLACE a cargo that already confers power (rules' + * `currentCargoGrantsEmpty`, the other conjunct). Admin role only — `update:BoardSeat` + * deliberately does NOT lift this one, so it must not be folded into `allowPowerGrants`. + * See positionsLockedForEditor(). */ + allowReplacePowerCargo?: boolean; children?: ReactNode; } @@ -74,6 +82,7 @@ export function MemberForm({ showPreview, avatarSeed, allowPowerGrants = false, + allowReplacePowerCargo = false, children, }: MemberFormProps) { const [formError, setFormError] = useState(null); @@ -113,14 +122,14 @@ export function MemberForm({ // per-form copy is what let this one re-add the held seat labelled "(inactivo)" while the // other dropped it). const assignedCargoId = defaultValues?.cargoId ?? null; - // A power-granting assigned cargo locks cargo/comisiones for a non-Admin — the write - // re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no + // A power-granting assigned cargo locks cargo/comisiones for anyone but an Admin — the + // write re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no // positions change succeeds. Bio edits still save, because the mapper omits an unchanged // slot. A grant-free CEL seat is NOT locked: clearing it is deliberately allowed, so the // form stays open, the seat renders disabled (visible, not assignable) and "Quitar cargo" - // makes the takedown reachable. See positionsLockedForNonAdmin() / cargoTakedownOnly(). + // makes the takedown reachable. See positionsLockedForEditor() / cargoTakedownOnly(). const assignedCargo = positions.find((p) => p.id === assignedCargoId); - const positionsLocked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo); + const positionsLocked = positionsLockedForEditor(assignedCargo, allowReplacePowerCargo); const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants); const cargoOptions = cargoOptionsForEditor({ positions, @@ -128,6 +137,16 @@ export function MemberForm({ allowPowerGrants, assignedCargoId, }); + const noCargos = noAssignableCargos({ cargoOptions, allowPowerGrants, locked: positionsLocked }); + // The notes explaining the picker sit after the field in the DOM, so without this a + // screen-reader user reaching the trigger hears "Sin resultados" or a disabled control and + // never meets the reason. Mutually exclusive by construction — a locked slot renders the + // held cargo, so the option list is never empty. + const cargoNoteId = noCargos + ? NO_ASSIGNABLE_CARGOS_NOTE_ID + : positionsLocked + ? LOCKED_NOTE_ID + : undefined; const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title); const activeComisionOptions = positions @@ -249,6 +268,7 @@ export function MemberForm({ }} placeholder="Sin cargo" disabled={positionsLocked} + aria-describedby={cargoNoteId} /> {cargoTakedown && ( + {copyState === "failed" && ( +

+ No se pudo copiar. Selecciona el enlace de arriba y cópialo manualmente. +

+ )} diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts new file mode 100644 index 00000000..908f9c34 --- /dev/null +++ b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import type { Position } from "@luminova/types"; +import { + cargoGrantNeedsAdminAssigner, + cargoOptionsForEditor, + cargoTakedownOnly, + noAssignableCargos, + positionsLockedForEditor, + type CargoOption, +} from "./assignable-cargo"; + +const cargo = ( + category: Position["category"], + grants: Position["grants"] = [], +): Pick => ({ category, grants }); + +const POWER = cargo("CEL", ["Secretary"]); +const CEL_FREE = cargo("CEL"); +const JDL_FREE = cargo("JDL"); + +// positionsLockedForEditor mirrors the OLD side of firestore.rules positionsAssignmentSafe() +// (`currentCargoGrantsEmpty`, the cargo being REPLACED), which is Admin-ROLE only and is +// deliberately NOT lifted by update:BoardSeat. Its flag is therefore NOT `allowPowerGrants`. +// The full truth table lives here because that asymmetry is now the load-bearing one: the +// forms exercise the two diagonal cells, and the delegate cell is the one the old +// `!allowPowerGrants && …` call sites got wrong. +describe("positionsLockedForEditor", () => { + it("locks a power-granting cargo for anyone who may not replace one", () => { + expect(positionsLockedForEditor(POWER, false)).toBe(true); + }); + + it("does not lock a power-granting cargo for an Admin", () => { + expect(positionsLockedForEditor(POWER, true)).toBe(false); + }); + + it("never locks a grant-free cargo, CEL or JDL, either way", () => { + // The asymmetric case: keeping a grant-free CEL seat is denied but CLEARING it is allowed + // on purpose, so locking here would strand the takedown behind an Admin. + for (const flag of [true, false]) { + expect(positionsLockedForEditor(CEL_FREE, flag)).toBe(false); + expect(positionsLockedForEditor(JDL_FREE, flag)).toBe(false); + } + }); + + it("never locks when there is no assigned cargo", () => { + expect(positionsLockedForEditor(undefined, false)).toBe(false); + expect(positionsLockedForEditor(undefined, true)).toBe(false); + }); + + it("BLOCKING: the flag is honored independently of the NEW-side one", () => { + // The regression in one line. A board-seat delegate carries allowPowerGrants=true (the + // NEW side, which update:BoardSeat lifts) while allowReplacePowerCargo stays false (the + // OLD side, Admin-only). Folding the two into one flag unlocked a write the rules always + // deny; this asserts the OLD side is the ONLY input here. + expect(positionsLockedForEditor(POWER, false)).toBe(true); + expect(cargoTakedownOnly(POWER, true)).toBe(false); + }); +}); + +// The third derived render-state. Two of its three clauses are unreachable through the form +// tests, so each is pinned as an explicit row rather than inferred from a rendered note. +describe("noAssignableCargos", () => { + const OPTION: CargoOption = { value: "dir", label: "Director" }; + + it("is false for a delegate: they may assign, the ceiling filtered nothing", () => { + expect(noAssignableCargos({ cargoOptions: [], allowPowerGrants: true, locked: false })).toBe( + false, + ); + }); + + it("is false when locked: the locked note is the honest explanation, not this one", () => { + expect(noAssignableCargos({ cargoOptions: [], allowPowerGrants: false, locked: true })).toBe( + false, + ); + }); + + it("is true for a non-delegate whose option list came back empty", () => { + expect(noAssignableCargos({ cargoOptions: [], allowPowerGrants: false, locked: false })).toBe( + true, + ); + }); + + it("is false whenever there is anything to pick", () => { + expect( + noAssignableCargos({ cargoOptions: [OPTION], allowPowerGrants: false, locked: false }), + ).toBe(false); + expect( + noAssignableCargos({ cargoOptions: [OPTION], allowPowerGrants: true, locked: false }), + ).toBe(false); + }); + + // The `!locked` clause is DEFENSIVE, not reachable through either form today, and this pins + // the invariant that makes it so — rather than deleting a clause whose redundancy depends on + // a coincidence between two other functions. + // + // Both forms derive `locked` and `cargoOptions` from the SAME (positions, assignedCargoId) + // pair. locked === true therefore implies the id resolved (positionsLockedForEditor returns + // false for an unresolved cargo) and carries grants, so cargoOptionsForEditor appends it as a + // disabled option and the length clause alone already returns false. Break either half — give + // the forms independent inputs, or stop appending the held cargo — and `!locked` becomes the + // only thing keeping the locked note and the empty-catalog note from rendering together. + it("BLOCKING: locked implies a non-empty option list, which is why !locked is defensive", () => { + const held: Position = { + id: "pos-power", + title: "Secretario", + titleFemale: null, + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, + }; + const locked = positionsLockedForEditor(held, false); + expect(locked).toBe(true); + const cargoOptions = cargoOptionsForEditor({ + positions: [held], + gender: "Masculino", + allowPowerGrants: false, + assignedCargoId: held.id, + }); + expect(cargoOptions).toHaveLength(1); + expect(cargoOptions[0]).toMatchObject({ value: "pos-power", disabled: true }); + expect(noAssignableCargos({ cargoOptions, allowPowerGrants: false, locked })).toBe(false); + }); +}); + +// The one outcome in this lane that fails SILENTLY. boardSeatDelegate() lets a delegate write +// an Admin-granting cargo and the write succeeds — but resolveTrustedGrants honors an +// Admin-conferring cargo only for an Admin-ROLE assigner, so the seat publishes and mints +// nothing. Keyed on the same flag as positionsLockedForEditor (the Admin role), NOT on +// allowPowerGrants, for the same reason: update:BoardSeat does not lift it. +describe("cargoGrantNeedsAdminAssigner", () => { + const ADMIN_CARGO = cargo("CEL", ["Admin"]); + + it("warns a non-Admin assigning an Admin-granting cargo", () => { + expect(cargoGrantNeedsAdminAssigner(ADMIN_CARGO, false)).toBe(true); + }); + + it("stays silent for an Admin, who mints what they assign", () => { + expect(cargoGrantNeedsAdminAssigner(ADMIN_CARGO, true)).toBe(false); + }); + + it("stays silent for a cargo whose grants a delegate DOES mint", () => { + // The delegation's whole point: a Secretary-granting seat is honored from an + // update:BoardSeat assigner, so warning about it would be false. + expect(cargoGrantNeedsAdminAssigner(POWER, false)).toBe(false); + expect(cargoGrantNeedsAdminAssigner(CEL_FREE, false)).toBe(false); + expect(cargoGrantNeedsAdminAssigner(JDL_FREE, false)).toBe(false); + }); + + it("stays silent with no cargo selected", () => { + expect(cargoGrantNeedsAdminAssigner(undefined, false)).toBe(false); + }); +}); diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index 982c985c..ea42e2e2 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -16,20 +16,28 @@ import { currentTermKey, positionTitle, type MemberGender, type Position } from * render-then-die shape this repo guards against, and two copies drift. */ // Module-local: every consumer now goes through cargoOptionsForEditor() / -// cargoTakedownOnly() / positionsLockedForNonAdmin(), which is the point — a caller that +// cargoTakedownOnly() / positionsLockedForEditor(), which is the point — a caller that // re-derived the option list from this raw predicate is how the two forms drifted apart in // the first place. Exporting it again would give that back. function cargoAssignableByNonAdmin(cargo: Pick): boolean { return cargo.grants.length === 0 && cargo.category !== "CEL"; } +/** Module-local, like `cargoAssignableByNonAdmin`: the raw shape of the rules' OLD-side + * question, with no permission flag folded in. Every caller goes through + * `positionsLockedForEditor` / `cargoTakedownOnly`, which each fold in the flag the rules + * actually gate that side on — and those two flags are NOT the same one. */ +function cargoConfersPower(cargo: Pick | undefined): boolean { + return cargo !== undefined && cargo.grants.length > 0; +} + /** - * Whether a non-Admin is barred from touching the positions slot AT ALL, given the cargo the + * Whether this editor is barred from touching the positions slot AT ALL, given the cargo the * member currently holds. NOT the negation of `cargoAssignableByNonAdmin` — the two rules * conjuncts are asymmetric, and mirroring the wrong one strands a takedown: * * grants.length > 0 → locked. `currentCargoGrantsEmpty()` gates the cargo being REPLACED, - * so a non-Admin can neither keep it (the save re-stamps it) nor clear + * so the editor can neither keep it (the save re-stamps it) nor clear * it. Nothing they can do here succeeds. * grant-free CEL → NOT locked. `currentCargoGrantsEmpty()` is deliberately not * category-gated — firestore.rules says denying this "would strand a @@ -37,11 +45,26 @@ function cargoAssignableByNonAdmin(cargo: Pick) * though keeping it is not. The cargo is dropped from the options * instead, which makes the only submittable states "clear" or "some * other assignable cargo" — exactly the rules' answer. + * + * `allowReplacePowerCargo` is a PARAMETER, and it is deliberately not the same flag as + * `allowPowerGrants`. positionsAssignmentSafe() gates its two cargo conjuncts on different + * principals, and this function mirrors the OLD one: + * + * NEW side (cargoAssignableByNonAdmin, the cargo written IN) → `allowPowerGrants`, + * which update:BoardSeat lifts. + * OLD side (currentCargoGrantsEmpty, the cargo REPLACED) → Admin ROLE only, never + * delegated. + * + * The flag is taken here rather than `&&`-ed at each call site because that is exactly how the + * two drifted: both forms typed `!allowPowerGrants && positionsLockedForNonAdmin(...)`, which + * was correct only while `allowPowerGrants` MEANT `isAdmin`. Widening it to include the + * delegate silently unlocked the editor for a write the rules always deny. */ -export function positionsLockedForNonAdmin( +export function positionsLockedForEditor( cargo: Pick | undefined, + allowReplacePowerCargo: boolean, ): boolean { - return cargo !== undefined && cargo.grants.length > 0; + return !allowReplacePowerCargo && cargoConfersPower(cargo); } /** @@ -65,14 +88,31 @@ export function cargoTakedownOnly( !allowPowerGrants && cargo !== undefined && !cargoAssignableByNonAdmin(cargo) && - !positionsLockedForNonAdmin(cargo) + !cargoConfersPower(cargo) ); } +/** + * A seat the editor may WRITE but whose grants will not be MINTED — the one outcome in this + * lane that fails silently. + * + * `boardSeatDelegate()` lets an `update:BoardSeat` holder write any vacant cargo, Admin-granting + * included, and the write succeeds; the seat even publishes to the Directiva. But + * `resolveTrustedGrants` honors an Admin-conferring cargo only for an assigner holding the Admin + * ROLE, so the member is seated with no Admin claim, and nothing in the save path says so. An + * Admin re-saving the same slot re-stamps `assignedBy` and completes it. + */ +export function cargoGrantNeedsAdminAssigner( + cargo: Pick | undefined, + assignerIsAdmin: boolean, +): boolean { + return !assignerIsAdmin && cargo !== undefined && cargo.grants.includes("Admin"); +} + export type CargoOption = { value: string; label: string; disabled?: boolean }; /** - * The third derived render-state, alongside `positionsLockedForNonAdmin` and + * The third derived render-state, alongside `positionsLockedForEditor` and * `cargoTakedownOnly`: this editor may assign, but the ceiling filtered every option away. * * Lives here for the same reason as its two siblings — both member forms ask it, and typing From 47581466c9e80cf810975dabd86e1b0e7ca55c80 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:09:12 -0400 Subject: [PATCH 02/25] fix(backstage): mirror every provisioning refusal, and say which one fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite affordances gated on `isAdmin || !member.uid`, which mirrors ONE of the four refusals provisionMember applies to a non-Admin. So a delegate was offered "Invitar a la app" on any uid-less member an Admin had already seated on a granting cargo — the normal state between being seated and being invited — and every click 403'd. The invite drawer had a pre-check for exactly this; the row menu and the profile header did not. provision-gate.ts is now that predicate, once: adoption, direct grants (roleIds/permissionOverrides.grant, matching beacon's hasDirectGrants — a revoke-only override mints nothing) and a power-granting cargo in ANY term, since syncMemberClaims reads the current term at trigger time and a future-term seat mints on the year rollover. Fails closed on an unresolvable cargo id, as the callable does on an unreadable one. beacon already tagged all four refusals with details.reason and the UI dropped three, so every delegate 403 read as a transient failure worth retrying. They now render what actually blocked. The table is a Map, not an object literal: `reason` arrives inside an error payload, and {...}["toString"] resolves to Object.prototype's function, which `?? fallback` would hand to React as a message. Also fixes the one surface where an invite sent no mail at all — the profile header returned early on the action link, so an Admin inviting from there produced a link and no email, against the rule that mail goes to every new user. The link is the manual fallback on top; its copy button gained the failure state it lacked, and the dialog copy now tracks whether the mail actually went. Pins the three client conjuncts that mirror server guards: a test-quality pass found all three would survive deletion with the suite green, because every existing case rendered either a uid-less member or an Admin. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/member-invite-drawer.test.tsx | 98 +++++++-- .../components/member-profile-page.test.tsx | 207 ++++++++++++++++++ .../components/member-row-menu.test.tsx | 118 +++++++++- .../members/components/member-row-menu.tsx | 17 +- .../members/components/member-table.tsx | 1 + .../members/lib/provision-error.test.ts | 47 +++- .../features/members/lib/provision-error.ts | 38 +++- .../members/lib/provision-gate.test.ts | 144 ++++++++++++ .../features/members/lib/provision-gate.ts | 72 ++++++ 9 files changed, 704 insertions(+), 38 deletions(-) create mode 100644 apps/backstage/src/features/members/components/member-profile-page.test.tsx create mode 100644 apps/backstage/src/features/members/lib/provision-gate.test.ts create mode 100644 apps/backstage/src/features/members/lib/provision-gate.ts diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx index ee0ceda1..dc4c142f 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx @@ -2,10 +2,28 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement, ReactNode } from "react"; +import type { Position } from "@luminova/types"; import { MemberInviteDrawer } from "./member-invite-drawer"; import { AbilityProvider } from "../../../lib/authz/ability-context"; import { pickDate } from "../../../test/pick-date"; +/** A catalog whose only cargo confers a role — the input to `draftProvisionBlocked`. Shared so + * the delegate case and the Admin case below differ in the CALLER and nothing else. */ +const powerCargoCatalog: Position[] = [ + { + id: "pos-power", + title: "Secretario", + titleFemale: null, + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, + }, +]; + // The drawer's "Enviar acceso" checkbox is gated on canProvisionLogin (Admin role OR the // exact create:MemberLogin perm); default to Admin so the provisioning path under test is // available, and parameterize for the delegation cases below. @@ -181,7 +199,11 @@ describe("MemberInviteDrawer", () => { expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument(); await fill(); fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); - await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + // Wait for the POSITIVE signal, not the absence of an alert: the negative is already true + // before the submit resolves, so a waitFor on it returns on the first tick and the + // onProvision assertion below would pass merely because the async handler had not run yet. + await screen.findByText(/Aún no tiene acceso a la app/); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(onProvision).not.toHaveBeenCalled(); }); @@ -197,24 +219,10 @@ describe("MemberInviteDrawer", () => { // beacon's power-seat guard would refuse it, so attempting it would create the member, // 403, and point the user at a row action that fails identically forever. const onProvision = vi.fn(); - const powerCargo = [ - { - id: "pos-power", - title: "Secretario", - titleFemale: null, - category: "CEL" as const, - grants: ["Secretary"] as never, - term: null, - sigla: null, - description: "", - active: true, - deletedAt: null, - }, - ]; renderWithAbility( {}} onCreate={async () => "idB"} onProvision={onProvision} @@ -228,7 +236,63 @@ describe("MemberInviteDrawer", () => { await userEvent.click(await screen.findByText(/Secretari[ao]/)); fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); - expect(screen.getByRole("alert")).toHaveTextContent(/solo un Admin puede enviarle el acceso/); + expect(screen.getByRole("alert")).toHaveTextContent( + /solo un administrador puede enviarle el acceso/, + ); expect(onProvision).not.toHaveBeenCalled(); }); + + // The `!isAdmin` term of provisionBlocked had no test: every Admin-path case above passes + // positions={[]}, so seatedCargo was always undefined and the cargo clause never fired. + // Mutate that term away and this is the only case that notices — without it, an Admin + // inviting a board member would be told "solo un administrador puede enviarle el acceso", + // self-contradictory copy, suite green. + it("BLOCKING: an ADMIN inviting a member on a power-granting cargo still provisions", async () => { + const onProvision = vi + .fn() + .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + renderWithAbility( + {}} + onCreate={async () => "idAdminPower"} + onProvision={onProvision} + />, + ); + await fill(); + await userEvent.click(screen.getByLabelText("Cargo")); + await userEvent.click(await screen.findByText(/Secretari[ao]/)); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + await waitFor(() => expect(onProvision).toHaveBeenCalledWith("idAdminPower")); + expect(await screen.findByText(/Invitación enviada a ana@jci\.bo/)).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + // beacon withholds the action link from a non-Admin caller (it is a bearer credential for + // the account), so a delegate whose reset mail then fails has NO manual fallback — the copy + // must send them to an Admin rather than to a copy button that would copy nothing. Only + // reachable as delegate + provision succeeded + requestPasswordReset rejected. + it("BLOCKING: tells a delegate to ask an administrator when there is no action link to share", async () => { + mockedRequestPasswordReset.mockRejectedValue(new Error("network error")); + renderWithAbility( + {}} + onCreate={async () => "idNoLink"} + onProvision={async () => ({ email: "ana@jci.bo", actionLink: "" })} + />, + { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] }, + ); + await fill(); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + expect(screen.getByRole("alert")).toHaveTextContent( + "El correo no se pudo enviar. Pídele a un administrador que reenvíe la invitación.", + ); + expect( + screen.queryByRole("button", { name: /Copiar enlace de acceso/ }), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/backstage/src/features/members/components/member-profile-page.test.tsx b/apps/backstage/src/features/members/components/member-profile-page.test.tsx new file mode 100644 index 00000000..4368e5af --- /dev/null +++ b/apps/backstage/src/features/members/components/member-profile-page.test.tsx @@ -0,0 +1,207 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { Timestamp } from "firebase/firestore"; +import { currentTermKey, type Member, type Position } from "@luminova/types"; +import type { AuthClaims } from "@luminova/auth/roles"; +import { roleClaims } from "@luminova/auth/test-helpers"; + +function member(over: Partial = {}): Member { + return { + id: "m1", + name: "Ana Gómez", + email: "ana@jci.bo", + joinDate: Timestamp.now(), + birthdate: Timestamp.now(), + status: "Activo", + profilePicture: null, + totalPoints: 0, + active: true, + deletedAt: null, + ...over, + }; +} + +const memberQuery = { + data: member(), + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), +}; + +vi.mock("@tanstack/react-router", async (orig) => ({ + ...(await orig()), + getRouteApi: () => ({ useParams: () => ({ memberId: "m1" }) }), + Link: (props: { to: string; children: ReactNode }) => {props.children}, +})); + +const POWER_CARGO: Position = { + id: "pos-power", + title: "Secretario", + titleFemale: "Secretaria", + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, +}; +const positionsQuery = { data: [POWER_CARGO] as Position[] }; + +vi.mock("../hooks/use-member", () => ({ useMember: () => memberQuery })); +vi.mock("../../positions/hooks/use-positions", () => ({ usePositions: () => positionsQuery })); +vi.mock("../hooks/use-member-points", () => ({ useMemberPoints: () => ({ data: null }) })); +vi.mock("../hooks/use-member-participations", () => ({ + useMemberParticipations: () => ({ data: [] }), +})); +vi.mock("../hooks/use-member-points-by-term", () => ({ + useMemberPointsByTerm: () => ({ data: [] }), +})); +vi.mock("../../activities/hooks/use-activities-by-term", () => ({ + useActivitiesByTerm: () => ({ data: [] }), +})); +vi.mock("../../initiatives/hooks/use-initiatives-by-term", () => ({ + useInitiativesByTerm: () => ({ data: [] }), +})); +vi.mock("../hooks/use-update-member", () => ({ + useUpdateMember: () => ({ mutateAsync: vi.fn() }), +})); +vi.mock("../hooks/use-set-member-positions", () => ({ + useSetMemberPositions: () => ({ mutateAsync: vi.fn() }), +})); +vi.mock("../../../lib/auth/auth", () => ({ + useAuth: () => ({ user: { uid: "admin" }, claims: { roles: ["Admin"] } }), +})); +vi.mock("../../../lib/auth/request-password-reset", () => ({ + requestPasswordReset: vi.fn().mockResolvedValue(undefined), +})); + +// The callable's result is what the component branches on, so it is the knob each case turns. +// vi.hoisted because the factory runs at import time, before any plain top-level const here +// has been evaluated. +const { provisionMutate } = vi.hoisted(() => ({ provisionMutate: vi.fn() })); +vi.mock("../hooks/use-provision-member-login", () => ({ + useProvisionMemberLogin: () => ({ mutate: provisionMutate, isPending: false }), +})); + +import { MemberProfilePage } from "./member-profile-page"; +import { AbilityProvider } from "../../../lib/authz/ability-context"; +import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; + +const mockedRequestPasswordReset = vi.mocked(requestPasswordReset); + +/** Drive the mocked mutation's onSuccess with whatever beacon is pretending to return. */ +function provisionResolvesWith(result: { email: string; actionLink: string }) { + provisionMutate.mockImplementation((...args: unknown[]) => { + const opts = args[1] as { onSuccess?: (r: typeof result) => void } | undefined; + opts?.onSuccess?.(result); + }); +} + +function renderPage(claims: AuthClaims = roleClaims("Admin")) { + // The sidebar panels still run their own real queries (roles, etc.); a throwaway client with + // retries off keeps them from retrying against a mock-less Firestore for the whole test. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +describe("MemberProfilePage — InviteAccess", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedRequestPasswordReset.mockResolvedValue(undefined); + memberQuery.data = member(); + }); + + // BLOCKING: the reset MAIL is the delivery path for every new login — a stated owner + // requirement that mail goes out for every new user. Returning an action link (which beacon + // does only for an ADMIN caller) used to short-circuit it with an early `return`, so this was + // the one surface where an Admin's invite sent nothing and the member waited for a mail that + // never came. The link is a manual FALLBACK on top, never a substitute. + it("BLOCKING: sends the reset mail even when beacon returns an action link", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + renderPage(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo")); + expect(mockedRequestPasswordReset).toHaveBeenCalledTimes(1); + expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument(); + // …and the link is still offered as the manual fallback. + expect(screen.getByRole("button", { name: /Copiar enlace/ })).toBeInTheDocument(); + }); + + it("sends the reset mail when beacon withholds the link (delegate caller)", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + renderPage(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo")); + expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Copiar enlace/ })).not.toBeInTheDocument(); + }); + + it("points at the manual link when the mail fails but a link came back", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + mockedRequestPasswordReset.mockRejectedValue(new Error("network")); + renderPage(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + // findByText, not findByRole("alert"): the link dialog is open on top, and its modal + // aria-hidden takes the header's alert out of the accessibility tree while it is. + expect( + await screen.findByText( + "Se creó el acceso, pero no se pudo enviar el correo. Comparte el enlace manualmente.", + ), + ).toBeInTheDocument(); + }); + + it("points at an administrator when the mail fails and there is no link to share", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + mockedRequestPasswordReset.mockRejectedValue(new Error("network")); + renderPage(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un administrador que lo reenvíe.", + ); + }); +}); + +// The four call sites all pass `allowReplacePowerCargo={isAdmin}` — a value the two form unit +// tests receive as a prop and therefore cannot police. These cover the profile page's two, the +// only place a delegate meets a seated member. +describe("MemberProfilePage — cargo editor for a board-seat delegate", () => { + const term = currentTermKey(); + const seatedOnPower = () => + member({ positions: { [term]: { cargoId: POWER_CARGO.id, comisionIds: [] } } }); + + beforeEach(() => { + vi.clearAllMocks(); + memberQuery.data = seatedOnPower(); + }); + + // update:BoardSeat lifts the NEW-side conjunct only. Passing it as allowReplacePowerCargo + // would open the picker on a write positionsAssignmentSafe() always denies. + it("BLOCKING: locks the full MemberForm's cargo picker for a delegate", () => { + renderPage({ roles: ["Member"], perms: ["update:Member", "update:BoardSeat"] }); + expect(screen.getByLabelText("Cargo")).toBeDisabled(); + expect(screen.getByText(/Solo un administrador puede cambiar el cargo/i)).toBeInTheDocument(); + }); + + it("BLOCKING: locks the positions-only form's cargo picker for a delegate", () => { + renderPage({ roles: ["Member"], perms: ["update:Position", "update:BoardSeat"] }); + expect(screen.getByLabelText("Cargo")).toBeDisabled(); + expect(screen.getByText(/Solo un administrador puede cambiar los cargos/i)).toBeInTheDocument(); + }); + + it("leaves both open for an Admin on the same seat", () => { + renderPage(); + expect(screen.getByLabelText("Cargo")).not.toBeDisabled(); + expect(screen.queryByText(/Solo un administrador puede cambiar/i)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/backstage/src/features/members/components/member-row-menu.test.tsx b/apps/backstage/src/features/members/components/member-row-menu.test.tsx index 792236f7..dbce1f65 100644 --- a/apps/backstage/src/features/members/components/member-row-menu.test.tsx +++ b/apps/backstage/src/features/members/components/member-row-menu.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import { Timestamp } from "firebase/firestore"; -import type { Member } from "@luminova/types"; +import type { Member, Position } from "@luminova/types"; import type { AuthClaims } from "@luminova/auth/roles"; import { roleClaims } from "@luminova/auth/test-helpers"; import { AbilityProvider } from "../../../lib/authz/ability-context"; @@ -33,19 +33,54 @@ const handlers = { onUnpublish: noop, }; +const cargo = (id: string, grants: Position["grants"]): Position => ({ + id, + title: id, + titleFemale: id, + category: "CEL", + grants, + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, +}); + +// The catalog the row menu resolves seated cargos against. `pos-power` confers a role, so +// beacon's power-seat guard refuses a non-Admin provisioning its holder; `pos-plain` is the +// grant-free control that proves the gate keys on the grants, not on being seated at all. +const POSITIONS_BY_ID: ReadonlyMap = new Map([ + ["pos-power", cargo("pos-power", ["Secretary"])], + ["pos-plain", cargo("pos-plain", [])], +]); + // Render the menu behind the REAL Can/ActionGate wiring so the per-item authz gates are // actually exercised (the previous suite mocked both to always-render children, making // every "is visible" assertion unconditionally true). uid="admin" so no uid-scoped // conditional grant muddies the coarse gates under test. -function renderMenu(m: Member, claims: AuthClaims, overrides?: Partial) { +function renderMenu( + m: Member, + claims: AuthClaims, + overrides?: Partial, + positionsById: ReadonlyMap = POSITIONS_BY_ID, +) { return render( - + , ); } const ADMIN: AuthClaims = roleClaims("Admin"); +// The delegation principal: create:MemberLogin without the Admin role. update:Member is what +// keeps the menu itself reachable. +const DELEGATE: AuthClaims = { + roles: ["Member"], + perms: ["update:Member", "create:MemberLogin"], +}; +const seated = (cargoId: string, term = "2026") => ({ + positions: { [term]: { cargoId, comisionIds: [] } }, +}); describe("MemberRowMenu", () => { it("shows Desactivar for an active member and Invitar when no uid", async () => { @@ -104,7 +139,11 @@ describe("MemberRowMenu", () => { const claims: AuthClaims = { roles: ["Member"], perms: ["read:Member"] }; render( - + , ); await userEvent.click(screen.getByLabelText(/Acciones para Ana/)); @@ -145,4 +184,75 @@ describe("MemberRowMenu", () => { await userEvent.click(screen.getByLabelText(/Acciones para Ana/)); expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); }); + + // --- memberProvisionBlocked, the non-Admin half of the invite gate --- + // + // Every case above renders either a member with NO uid or an Admin caller, so the second + // disjunct of the old `isAdmin || !member.uid` gate was never the deciding term and the + // whole conjunct survived deletion. These pin each refusal `provisionMember` applies to a + // NON-Admin caller: adoption, direct grants, and a power-granting cargo in any term. + + const openMenu = () => userEvent.click(screen.getByLabelText(/Acciones para Ana/)); + + it("shows the invite item to a delegate for a clean, unseated member", async () => { + renderMenu(member({ status: "Activo" }), DELEGATE); + await openMenu(); + expect(screen.getByText("Invitar a la app")).toBeInTheDocument(); + }); + + it("shows it to a delegate for a member seated on a GRANT-FREE cargo", async () => { + // The control for the two cases below: being seated is not the blocker, conferring power + // is. Without this the cargo clause could be "seated at all" and nothing would notice. + renderMenu(member({ status: "Activo", ...seated("pos-plain") }), DELEGATE); + await openMenu(); + expect(screen.getByText("Invitar a la app")).toBeInTheDocument(); + }); + + it("BLOCKING: hides it from a delegate for a member who already has a login (adoption)", async () => { + // beacon tags this reprovision-requires-admin: a delegate may only mint a NEW login, so + // "Reenviar invitación" would 403 on every click. + renderMenu(member({ status: "Activo", uid: "u1" }), DELEGATE); + await openMenu(); + expect(screen.queryByText("Reenviar invitación")).not.toBeInTheDocument(); + }); + + it("BLOCKING: hides it from a delegate for a member seated on a power-granting cargo", async () => { + renderMenu(member({ status: "Activo", ...seated("pos-power") }), DELEGATE); + await openMenu(); + expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); + }); + + it("BLOCKING: hides it from a delegate for a member carrying direct roleIds", async () => { + renderMenu(member({ status: "Activo", roleIds: ["custom-role"] }), DELEGATE); + await openMenu(); + expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); + }); + + it("BLOCKING: hides it from a delegate while the cargo catalog is still empty (fails closed)", async () => { + // The positions query has not landed, so the seated id resolves to undefined. Fail closed: + // offer the invite once the catalog arrives rather than offer it and then 403. + renderMenu( + member({ status: "Activo", ...seated("pos-power") }), + DELEGATE, + undefined, + new Map(), + ); + await openMenu(); + expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); + }); + + it("still shows it to an Admin in every one of those cases", async () => { + for (const m of [ + member({ status: "Activo", uid: "u1" }), + member({ status: "Activo", ...seated("pos-power") }), + member({ status: "Activo", roleIds: ["custom-role"] }), + ]) { + const { unmount } = renderMenu(m, ADMIN); + await openMenu(); + expect( + screen.getByText(m.uid ? "Reenviar invitación" : "Invitar a la app"), + ).toBeInTheDocument(); + unmount(); + } + }); }); diff --git a/apps/backstage/src/features/members/components/member-row-menu.tsx b/apps/backstage/src/features/members/components/member-row-menu.tsx index 03d9a6f9..600a0096 100644 --- a/apps/backstage/src/features/members/components/member-row-menu.tsx +++ b/apps/backstage/src/features/members/components/member-row-menu.tsx @@ -1,11 +1,13 @@ import { Menu, MenuItem, MenuSeparator } from "@luminova/ui"; -import type { Member, MemberStatus } from "@luminova/types"; +import type { Member, MemberStatus, Position } from "@luminova/types"; import { Can } from "../../../lib/authz/ability-context"; import { ActionGate } from "../../../lib/authz/action-gate"; import { useCan } from "../../../lib/authz/use-can"; +import { memberProvisionBlocked } from "../lib/provision-gate"; interface MemberRowMenuProps { member: Member; + positionsById: ReadonlyMap; onView: (member: Member) => void; onEdit: (member: Member) => void; onProvision: (member: Member) => void; @@ -15,6 +17,7 @@ interface MemberRowMenuProps { export function MemberRowMenu({ member, + positionsById, onView, onEdit, onProvision, @@ -22,6 +25,8 @@ export function MemberRowMenu({ onUnpublish, }: MemberRowMenuProps) { const { canProvisionLogin, isAdmin } = useCan(); + const provisionBlocked = + !isAdmin && memberProvisionBlocked(member, (id) => positionsById.get(id)); return ( {/* provisionMemberLogin is requireAdminOrPerm(create:MemberLogin) — the Admin role or - that exact code, never the manage:all perm. The `!member.uid` half is the adoption - guard mirrored: a delegate may only mint a NEW login, so offering "Reenviar" to one - would be an item that 403s on every click. */} - + that exact code, never the manage:all perm. memberProvisionBlocked mirrors every + refusal the callable applies to a non-Admin (adoption, direct grants, a power-granting + cargo in any term), so a delegate is not offered an item that 403s on every click. + The residual case the client cannot see — an Auth account already existing for the + address — still 403s, and provisionErrorMessage names it. */} + onProvision(member)}> {member.uid ? "Reenviar invitación" : "Invitar a la app"} diff --git a/apps/backstage/src/features/members/components/member-table.tsx b/apps/backstage/src/features/members/components/member-table.tsx index f9602355..ebcc21b3 100644 --- a/apps/backstage/src/features/members/components/member-table.tsx +++ b/apps/backstage/src/features/members/components/member-table.tsx @@ -169,6 +169,7 @@ export function MemberTable({ + Object.assign(new Error("failed-precondition"), { details: { reason } }); + describe("provisionErrorMessage", () => { it("explains the console unlink when the callable reports a uid conflict", () => { - const err = Object.assign(new Error("failed-precondition"), { - details: { reason: "linked-to-different-login" }, - }); - expect(provisionErrorMessage(err, FALLBACK)).toBe( + expect(provisionErrorMessage(withReason("linked-to-different-login"), FALLBACK)).toBe( "El miembro ya está vinculado a otro acceso (correo cambiado). Desvincúlalo desde la consola antes de reintentar.", ); }); + // The three non-Admin refusals. provisionBlockedForNonAdmin sees the member DOC, not the + // Auth directory, so a delegate still reaches these — and without a message each reads as a + // transient failure the operator retries forever. + it("names beacon's adoption refusal (reprovision-requires-admin)", () => { + expect(provisionErrorMessage(withReason("reprovision-requires-admin"), FALLBACK)).toBe( + "Ya existe un acceso para este correo. Pídele a un administrador que lo reenvíe o lo vincule.", + ); + }); + + it("names beacon's direct-grants refusal (granted-member-requires-admin)", () => { + expect(provisionErrorMessage(withReason("granted-member-requires-admin"), FALLBACK)).toBe( + "Este miembro tiene roles o permisos asignados: solo un administrador puede crear su acceso.", + ); + }); + + it("names beacon's power-seat refusal (power-seat-requires-admin)", () => { + expect(provisionErrorMessage(withReason("power-seat-requires-admin"), FALLBACK)).toBe( + "El cargo de este miembro otorga permisos: solo un administrador puede crear su acceso.", + ); + }); + + it("gives each reason a DISTINCT message", () => { + // A table is one copy-paste away from two reasons sharing a message, which would tell the + // operator to do the wrong thing about half the time. + const messages = [ + "linked-to-different-login", + "reprovision-requires-admin", + "granted-member-requires-admin", + "power-seat-requires-admin", + ].map((reason) => provisionErrorMessage(withReason(reason), FALLBACK)); + expect(new Set(messages).size).toBe(messages.length); + expect(messages).not.toContain(FALLBACK); + }); + it("falls back to the generic message for any other failure", () => { expect(provisionErrorMessage(new Error("boom"), FALLBACK)).toBe(FALLBACK); expect(provisionErrorMessage(undefined, FALLBACK)).toBe(FALLBACK); + expect(provisionErrorMessage(null, FALLBACK)).toBe(FALLBACK); expect( provisionErrorMessage(Object.assign(new Error("x"), { details: "otra cosa" }), FALLBACK), ).toBe(FALLBACK); + // An unknown code, and a non-string reason: neither may index the table. + expect(provisionErrorMessage(withReason("no-such-reason"), FALLBACK)).toBe(FALLBACK); + expect(provisionErrorMessage(withReason(42), FALLBACK)).toBe(FALLBACK); + expect(provisionErrorMessage(withReason(undefined), FALLBACK)).toBe(FALLBACK); }); }); diff --git a/apps/backstage/src/features/members/lib/provision-error.ts b/apps/backstage/src/features/members/lib/provision-error.ts index bde16daf..da002abb 100644 --- a/apps/backstage/src/features/members/lib/provision-error.ts +++ b/apps/backstage/src/features/members/lib/provision-error.ts @@ -1,15 +1,37 @@ -// provisionMemberLogin tags its uid-conflict rejection with details.reason so -// the UI can point the operator at the console unlink instead of a dead-end -// generic failure (the callable refuses to relink a member whose stored uid -// still resolves to a live, different Auth account). +// provisionMemberLogin tags every refusal it can be argued with using details.reason, so the +// UI can name the actual blocker instead of a dead-end generic failure. Three of the four are +// non-Admin refusals a delegate can hit on a member the client cannot fully evaluate +// (provisionBlockedForNonAdmin sees the member doc, not the Auth directory), and without a +// message each reads as a transient error the operator retries forever. +// A Map, not an object literal: `reason` is attacker-adjacent input (it arrives in the +// callable's error payload), and `{...}[reason]` resolves "toString" / "constructor" / +// "valueOf" to the inherited Object.prototype FUNCTION, which `?? fallback` then happily +// returns as the message. TypeScript types that `string` and React would render a function. +const REASON_MESSAGES = new Map([ + [ + "linked-to-different-login", + "El miembro ya está vinculado a otro acceso (correo cambiado). Desvincúlalo desde la consola antes de reintentar.", + ], + [ + "reprovision-requires-admin", + "Ya existe un acceso para este correo. Pídele a un administrador que lo reenvíe o lo vincule.", + ], + [ + "granted-member-requires-admin", + "Este miembro tiene roles o permisos asignados: solo un administrador puede crear su acceso.", + ], + [ + "power-seat-requires-admin", + "El cargo de este miembro otorga permisos: solo un administrador puede crear su acceso.", + ], +]); + export function provisionErrorMessage(err: unknown, fallback: string): string { const details = (err as { details?: unknown } | null | undefined)?.details; const reason = typeof details === "object" && details !== null ? (details as { reason?: unknown }).reason : undefined; - if (reason === "linked-to-different-login") { - return "El miembro ya está vinculado a otro acceso (correo cambiado). Desvincúlalo desde la consola antes de reintentar."; - } - return fallback; + if (typeof reason !== "string") return fallback; + return REASON_MESSAGES.get(reason) ?? fallback; } diff --git a/apps/backstage/src/features/members/lib/provision-gate.test.ts b/apps/backstage/src/features/members/lib/provision-gate.test.ts new file mode 100644 index 00000000..e625747b --- /dev/null +++ b/apps/backstage/src/features/members/lib/provision-gate.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { Timestamp } from "firebase/firestore"; +import { currentTermKey, type Member, type Position } from "@luminova/types"; +import { draftProvisionBlocked, memberProvisionBlocked, type CargoLookup } from "./provision-gate"; + +const POWER = "pos-power"; +const PLAIN = "pos-plain"; + +const catalog: CargoLookup = (id) => + ({ + [POWER]: { grants: ["Secretary"] as Position["grants"] }, + [PLAIN]: { grants: [] as Position["grants"] }, + })[id]; + +function member(p: Partial = {}): Member { + return { + id: "m1", + name: "Ana", + email: "a@jci.bo", + joinDate: Timestamp.now(), + birthdate: Timestamp.now(), + status: "Activo", + profilePicture: null, + totalPoints: 0, + active: true, + deletedAt: null, + ...p, + }; +} + +const term = currentTermKey(); +const nextTerm = String(Number(term) + 1); +const seat = (cargoId: string | null, key = term) => ({ + positions: { [key]: { cargoId, comisionIds: [] } }, +}); + +describe("memberProvisionBlocked", () => { + it("does not block a clean, unseated member", () => { + expect(memberProvisionBlocked(member(), catalog)).toBe(false); + }); + + it("does not block a member seated on a grant-free cargo", () => { + // The control for the cargo clause: being seated is not the refusal, conferring power is. + expect(memberProvisionBlocked(member(seat(PLAIN)), catalog)).toBe(false); + }); + + it("does not block a member whose only term entry has a null cargo", () => { + expect(memberProvisionBlocked(member(seat(null)), catalog)).toBe(false); + }); + + it("blocks a member who already has a login (beacon's adoption guard)", () => { + expect(memberProvisionBlocked(member({ uid: "u1" }), catalog)).toBe(true); + }); + + it("treats an empty-string uid as no login", () => { + // A stored empty string is not a linked account; blocking on it would hide the invite for + // exactly the member who still needs one. + expect(memberProvisionBlocked(member({ uid: "" }), catalog)).toBe(false); + }); + + it("blocks a member carrying direct roleIds", () => { + expect(memberProvisionBlocked(member({ roleIds: ["custom"] }), catalog)).toBe(true); + }); + + it("does not block on an empty roleIds array", () => { + expect(memberProvisionBlocked(member({ roleIds: [] }), catalog)).toBe(false); + }); + + it("blocks a member carrying a permissionOverrides GRANT", () => { + expect( + memberProvisionBlocked( + member({ permissionOverrides: { grant: ["update:Member"], revoke: [] } }), + catalog, + ), + ).toBe(true); + }); + + // Deliberate mirror of beacon's hasDirectGrants, which checks `grant` only: a revoke-only + // override mints nothing, so it is not a reason to withhold the invite. Widening this to + // "has any override" would silently take the affordance away from a delegate for a member + // who has strictly FEWER permissions than the default. + it("BLOCKING: does NOT block on a revoke-only override — it mints nothing", () => { + expect( + memberProvisionBlocked( + member({ permissionOverrides: { grant: [], revoke: ["update:Member"] } }), + catalog, + ), + ).toBe(false); + }); + + it("blocks a member seated on a power-granting cargo in the CURRENT term", () => { + expect(memberProvisionBlocked(member(seat(POWER)), catalog)).toBe(true); + }); + + // syncMemberClaims reads the current term at trigger time, so a future-term seat mints on + // the year rollover. Reading only the current term here would offer the invite today and + // 403 on it — beacon reads every term. + it("BLOCKING: blocks a power-granting cargo seated in a FUTURE term", () => { + expect(memberProvisionBlocked(member(seat(POWER, nextTerm)), catalog)).toBe(true); + }); + + it("blocks a power-granting cargo seated in a PAST term", () => { + expect(memberProvisionBlocked(member(seat(POWER, "2020")), catalog)).toBe(true); + }); + + // Fails CLOSED in the same direction as beacon, which reads grants === null from an + // unreadable cargo the same way. A stale or still-loading `positions` prop must hide the + // affordance rather than promise a 403. + it("BLOCKING: blocks when the cargo id does not resolve against the catalog", () => { + expect(memberProvisionBlocked(member(seat("gone")), catalog)).toBe(true); + expect(memberProvisionBlocked(member(seat(PLAIN)), () => undefined)).toBe(true); + }); + + it("blocks when any ONE term is power-granting among several clean ones", () => { + const m = member({ + positions: { + "2024": { cargoId: PLAIN, comisionIds: [] }, + [term]: { cargoId: POWER, comisionIds: [] }, + [nextTerm]: { cargoId: null, comisionIds: [] }, + }, + }); + expect(memberProvisionBlocked(m, catalog)).toBe(true); + }); +}); + +describe("draftProvisionBlocked", () => { + it("does not block a draft with no cargo", () => { + expect(draftProvisionBlocked(null, catalog)).toBe(false); + expect(draftProvisionBlocked(undefined, catalog)).toBe(false); + expect(draftProvisionBlocked("", catalog)).toBe(false); + }); + + it("does not block a draft seated on a grant-free cargo", () => { + expect(draftProvisionBlocked(PLAIN, catalog)).toBe(false); + }); + + it("blocks a draft seated on a power-granting cargo", () => { + expect(draftProvisionBlocked(POWER, catalog)).toBe(true); + }); + + it("BLOCKING: fails closed on a cargo id the catalog cannot resolve", () => { + expect(draftProvisionBlocked("gone", catalog)).toBe(true); + }); +}); diff --git a/apps/backstage/src/features/members/lib/provision-gate.ts b/apps/backstage/src/features/members/lib/provision-gate.ts new file mode 100644 index 00000000..c0eab3a8 --- /dev/null +++ b/apps/backstage/src/features/members/lib/provision-gate.ts @@ -0,0 +1,72 @@ +import type { Member, Position } from "@luminova/types"; + +/** + * Client mirror of the refusals `provisionMember` applies to a non-Admin caller — the + * adoption guard (`reprovision-requires-admin`) and both halves of the power-seat guard + * (`granted-member-requires-admin`, `power-seat-requires-admin`). + * + * NOT a security boundary: beacon is, and it re-derives all of this server-side from the + * stored doc. This exists so the three entry points that offer "invitar / enviar acceso" do + * not offer it for a member the callable refuses on every click — the render-then-die shape + * this feature already guards against on the cargo picker. One function, not a predicate + * re-typed per entry point: the invite drawer had it and the row menu and profile header did + * not, which is exactly how the two cargo forms drifted before. + * + * Fails CLOSED in the same direction as beacon: an unresolvable cargo id counts as + * power-conferring (beacon reads `grants === null` from an unreadable cargo the same way), so + * a stale `positions` prop hides the affordance rather than promising a 403. + */ +// Module-local, like assignable-cargo's raw predicates: the two adapters below are the whole +// public surface, and a caller that re-assembled the input itself is how a mirror drifts from +// the guard it mirrors. +function provisionBlockedForNonAdmin(input: { + /** The member already has a login, or an Auth account exists for their address. Only the + * first half is visible to the client; beacon checks both, so this is a subset — the + * residual case still 403s and is what `provisionErrorMessage` explains. */ + hasLogin: boolean; + hasDirectGrants: boolean; + /** Every seated cargo across EVERY term, resolved against the catalog. `undefined` = the id + * did not resolve. Beacon reads every term too: syncMemberClaims reads the current term at + * trigger time, so a future-term seat mints on the year rollover. */ + seatedCargos: readonly (Pick | undefined)[]; +}): boolean { + return ( + input.hasLogin || + input.hasDirectGrants || + input.seatedCargos.some((cargo) => cargo === undefined || cargo.grants.length > 0) + ); +} + +/** Resolves a cargo id against the catalog. A callback rather than a `Position[]` or a Map so + * each caller passes whatever it already holds — the members table has a `positionsById` Map, + * the profile page and the invite drawer have arrays — without one of them allocating. */ +export type CargoLookup = (id: string) => Pick | undefined; + +/** `provisionBlockedForNonAdmin` for a stored member doc. */ +export function memberProvisionBlocked(member: Member, cargo: CargoLookup): boolean { + const cargoIds = Object.values(member.positions ?? {}).flatMap((term) => + term.cargoId ? [term.cargoId] : [], + ); + return provisionBlockedForNonAdmin({ + hasLogin: typeof member.uid === "string" && member.uid.length > 0, + // `grant` only, mirroring beacon's hasDirectGrants: a revoke-only override mints nothing, + // so it is not a reason to withhold the invite. + hasDirectGrants: + (member.roleIds?.length ?? 0) > 0 || (member.permissionOverrides?.grant.length ?? 0) > 0, + seatedCargos: cargoIds.map(cargo), + }); +} + +/** `provisionBlockedForNonAdmin` for a member about to be CREATED: the create arm forbids + * `uid`, `roleIds` and `permissionOverrides` to a non-Admin, so the cargo is the only half + * that can be true. */ +export function draftProvisionBlocked( + cargoId: string | null | undefined, + cargo: CargoLookup, +): boolean { + return provisionBlockedForNonAdmin({ + hasLogin: false, + hasDirectGrants: false, + seatedCargos: cargoId ? [cargo(cargoId)] : [], + }); +} From f857ba41fa01b86183fa3b1110a2fbac1ec33fcd Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:09:24 -0400 Subject: [PATCH 03/25] fix(beacon,ui): screen assignedBy before Auth; associate the cargo notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beacon: resolveTrustedGrants screened cargoId with isSafeDocId and explained at length why — a permanent throw under retry:false kills that member's claims sync forever — then passed assignedBy straight to auth.getUser(), which rethrows anything but auth/user-not-found. A console- or admin-SDK-written value Auth rejects therefore threw permanently, and only on power-granting cargos: exactly the members whose claims matter. Screened with a uid-shaped check, NOT isSafeDocId, which allows 1500 bytes where getUser caps at 128 and rejects the `/` and `.` a custom uid may legitimately contain. An unusable value is untrusted: mint nothing. ui: Combobox takes aria-describedby. Both cargo notes sit after the field in the DOM, so a screen-reader user reaching an empty or disabled trigger heard only "Sin resultados" and never met the explanation. The empty-picker note now derives the permission name from permissionLabel("update:BoardSeat") instead of hardcoding it. It is the one place a user is told which permission to go ask an Admin for, and its own comment admitted that nothing enforced the match. Adds coverage for the port/adapter seam, which had none at any level: the narrowed createUser catch (quota and disabled-provider errors now surface instead of masquerading as user-not-found) and getAssignerClaims, the wire that carries a delegate's perm into the trust gate. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/no-assignable-cargos-note.tsx | 18 +++- apps/beacon/src/callable-auth.test.ts | 15 +-- .../src/claims-sync/firestore-deps.test.ts | 57 ++++++++++++ apps/beacon/src/claims-sync/sync.test.ts | 56 ++++++++++- apps/beacon/src/claims-sync/sync.ts | 10 +- apps/beacon/src/provision-deps.test.ts | 92 +++++++++++++++++++ .../beacon/src/provision-member-login.test.ts | 84 ++++++++++------- packages/ui/src/components/combobox.tsx | 7 ++ 8 files changed, 283 insertions(+), 56 deletions(-) create mode 100644 apps/beacon/src/provision-deps.test.ts diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx index fe0b830f..ffed91a9 100644 --- a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx @@ -1,3 +1,10 @@ +import { permissionLabel } from "../../permissions/lib/permission-matrix"; + +/** The id both member forms point the Cargo Combobox's `aria-describedby` at. A constant, not + * a prop: only one of these renders per form, and the association is the whole reason a + * screen-reader user reaching the empty picker hears more than "Sin resultados". */ +export const NO_ASSIGNABLE_CARGOS_NOTE_ID = "cargo-no-assignable-note"; + /** Why the Cargo picker is empty, for an editor who is not a board-seat delegate. * * Without it the Combobox renders its bare "Sin resultados" and the editor cannot tell a @@ -7,14 +14,15 @@ * Shared by both member forms rather than typed into each: the `locked` and `takedownOnly` * notes legitimately differ in wording between them, this one does not. * - * The quoted permission name must stay equal to `permissionLabel("update:BoardSeat")` — - * ACTION_LABELS.update + SUBJECT_LABELS.BoardSeat, in features/permissions. Nothing enforces - * the match across the two features, so it is stated here. */ + * The permission is NAMED through `permissionLabel`, not spelled out here. It is the one + * place a user is told which permission to go ask an Admin for, and a hardcoded copy would + * drift silently the first time either half of that label is renamed. */ export function NoAssignableCargosNote() { return ( -

+

Ningún cargo del catálogo es asignable con tus permisos. Los cargos del Comité Ejecutivo Local - y los que otorgan permisos requieren un Admin o el permiso «Editar Asientos de directiva». + y los que otorgan permisos requieren un administrador o el permiso « + {permissionLabel("update:BoardSeat")}».

); } diff --git a/apps/beacon/src/callable-auth.test.ts b/apps/beacon/src/callable-auth.test.ts index 9a9357e0..7ddc223f 100644 --- a/apps/beacon/src/callable-auth.test.ts +++ b/apps/beacon/src/callable-auth.test.ts @@ -83,19 +83,6 @@ describe("requireAdminOrPerm", () => { ).toBe("permission-denied"); }); - it("keeps the two delegations independent", () => { - // A board-seat delegate is not a login provisioner and vice versa. Pinned because both - // codes ship together and the obvious future mistake is to conflate them. - expect( - codeOf(() => - requireAdminOrPerm( - req({ roles: ["Member"], perms: ["update:BoardSeat"] }), - "create:MemberLogin", - ), - ), - ).toBe("permission-denied"); - }); - it("fails closed on a malformed perms claim", () => { // A string (or anything non-array) reads as empty rather than throwing — a malformed // token must deny, not 500. @@ -115,7 +102,7 @@ describe("callerIsAdmin", () => { expect(callerIsAdmin(req())).toBe(false); expect(callerIsAdmin(req({ roles: ["Member"], perms: ["manage:all"] }))).toBe(false); }); - it("is true only for the Admin role", () => { + it("is true for the Admin role", () => { expect(callerIsAdmin(req({ roles: ["Admin"] }))).toBe(true); }); }); diff --git a/apps/beacon/src/claims-sync/firestore-deps.test.ts b/apps/beacon/src/claims-sync/firestore-deps.test.ts index 3e7d33fd..9657bd47 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.test.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.test.ts @@ -70,6 +70,21 @@ function fakeDb(fixtures: RoleFixture[]) { /** Cast, not a fabricated Auth: every dep exercised here is a pure Firestore read. */ const auth = {} as Auth; +/** An Auth stub narrow enough to drive `auth.getUser(uid)` — the only Auth surface the claims + * accessors reach. The cast is test-only and justified: UserRecord carries a dozen fields + * (metadata, providerData, toJSON) that nothing under test reads, and fabricating them would + * assert nothing. A missing uid throws the real `auth/user-not-found` code, because that is + * the ONE error getUserOrNull swallows. */ +function fakeAuth(users: Record): Auth { + return { + getUser: async (uid: string) => { + const user = users[uid]; + if (!user) throw Object.assign(new Error("user not found"), { code: "auth/user-not-found" }); + return user; + }, + } as unknown as Auth; +} + const builtIn = (id: string, key: string, extra: Record = {}): RoleFixture => ({ id, data: { builtIn: true, builtInKey: key, permissions: ["read:Member"], active: true, ...extra }, @@ -153,6 +168,48 @@ describe("getRoleDocsByBuiltInKeys coverage anomalies", () => { }); }); +/** The wire that carries the assigner's claims into resolveTrustedGrants' power-grant trust + * gate. sync.test.ts drives that gate through an in-memory fake, so this is the only place the + * real accessor's filtering is exercised — and a `perms` array that arrived unfiltered would + * hand the gate a junk code to match on. */ +describe("getAssignerClaims", () => { + it("BLOCKING: round-trips roles and perms, dropping entries outside the vocabulary", async () => { + const { db } = fakeDb([]); + const deps = firestoreClaimsDeps( + db, + fakeAuth({ + "delegate-uid": { + customClaims: { + roles: ["Member", "NotARole", 42], + perms: ["update:BoardSeat", "nope:Thing", null], + }, + }, + }), + ); + await expect(deps.getAssignerClaims("delegate-uid")).resolves.toEqual({ + roles: ["Member"], + perms: ["update:BoardSeat"], + }); + }); + + it("fails closed to empty claims for an absent, claimless or malformed-claim assigner", async () => { + // Unlike getExistingClaims, `perms` is never undefined here: the gate does an `.includes()` + // on it, so absence must arrive as an empty array and DENY rather than throw. + const { db } = fakeDb([]); + const deps = firestoreClaimsDeps( + db, + fakeAuth({ + claimless: {}, + malformed: { customClaims: { roles: "Admin", perms: "update:BoardSeat" } }, + }), + ); + const empty = { roles: [], perms: [] }; + await expect(deps.getAssignerClaims("ghost")).resolves.toEqual(empty); + await expect(deps.getAssignerClaims("claimless")).resolves.toEqual(empty); + await expect(deps.getAssignerClaims("malformed")).resolves.toEqual(empty); + }); +}); + describe("getRolesByIds id screening", () => { const custom = (id: string, extra: Record = {}): RoleFixture => ({ id, diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts index b5bef8f0..0a6af33d 100644 --- a/apps/beacon/src/claims-sync/sync.test.ts +++ b/apps/beacon/src/claims-sync/sync.test.ts @@ -275,9 +275,12 @@ describe("syncMemberClaims", () => { positions: { "pos-pres": { grants: ["Admin"] } }, userRoles: { "president-uid": ["Admin", "Member"] }, userPerms: { "president-uid": ["manage:all"] }, - existing: { - "president-uid": { roles: ["Admin", "Member"], perms: permsFor(["Admin", "Member"]) }, - }, + // Seeded DELIBERATELY below the denied outcome, not byte-equal to it: seeding the + // already-correct claims would make the idempotent no-op path indistinguishable from a + // strip, and the assertion would only prove "nothing was written". From ["Member"] the + // guard's two outcomes diverge — honored writes the Admin claim, denied writes the plain + // Member one — so the write itself is the evidence. + existing: { "president-uid": { roles: ["Member"] } }, }); await syncMemberClaims( deps, @@ -289,8 +292,12 @@ describe("syncMemberClaims", () => { }, "2026", ); - // Idempotent no-op: the claims are already correct, so no write — and crucially not a strip. - expect(writes["president-uid"]).toBeUndefined(); + // The Admin grant is HONORED through a self-stamped assignedBy: the full claim is written, + // not withheld and not stripped down to Member. + expect(writes["president-uid"]).toEqual({ + roles: ["Admin", "Member"], + perms: permsFor(["Admin", "Member"]), + }); }); it("de-elevates a delegate-conferred NON-Admin grant once the perm is revoked", async () => { @@ -478,6 +485,45 @@ describe("syncMemberClaims", () => { } }); + it("BLOCKING: screens an unusable assignedBy instead of failing that member's sync forever", async () => { + // The OTHER id on the same line, and the same failure mode as the cargoId screen above. + // `assignedBy` reaches auth.getUser(), whose uid contract is a non-empty string of at most + // 128 chars; anything else is a PERMANENT auth/invalid-uid, and getAssignerClaims rethrows + // everything but auth/user-not-found. onMemberWritten is retry:false and the bad value + // PERSISTS in the member doc, so every later write re-throws — and only on power-granting + // cargos, i.e. exactly the members whose claims matter most. Nothing in firestore.rules + // caps assignedBy's length; the console and the admin SDK reach this shape. + const reached: string[] = []; + for (const assignedBy of ["", "x".repeat(129)]) { + const { deps, writes } = fakeDeps({ + positions: { "pos-pres": { grants: ["Admin"] } }, + // The fixture would hand back Admin if the screen fell through, so the assertion + // below is about the screen, not about the assigner failing the trust gate. + userRoles: { [assignedBy]: ["Admin"] }, + existing: { "target-uid": { roles: ["Member"] } }, + }); + const spied: ClaimsSyncDeps = { + ...deps, + getAssignerClaims: async (uid) => { + reached.push(uid); + return deps.getAssignerClaims(uid); + }, + }; + await syncMemberClaims( + spied, + { + uid: "target-uid", + positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy } }, + }, + "2026", + ); + // The screened uid never reaches the port, so it never reaches auth.getUser(). + expect(reached).toEqual([]); + // Fails closed: the Admin grant behind that cargo is NOT minted. + expect(writes["target-uid"]).toEqual({ roles: ["Member"], perms: permsFor(["Member"]) }); + } + }); + it("honors ONLY the cargo's grants — comisión grants are never power, even Admin-assigned", async () => { // Comisiones are chips-only (position-schema forbids Comision grants; rules // enforce it). A console-written power comisión — or a power cargo's id diff --git a/apps/beacon/src/claims-sync/sync.ts b/apps/beacon/src/claims-sync/sync.ts index a0b861a7..102a95a0 100644 --- a/apps/beacon/src/claims-sync/sync.ts +++ b/apps/beacon/src/claims-sync/sync.ts @@ -113,7 +113,15 @@ async function resolveTrustedGrants( if (!isSafeDocId(cargoId)) return []; const position = await deps.getPosition(cargoId); if (!position || position.grants.length === 0) return []; - if (!assignedBy) return []; + // Screened for the same reason as cargoId above, one line up: `assignedBy` reaches + // auth.getUser(), which rejects anything outside the admin SDK's uid shape with a PERMANENT + // auth/invalid-uid that getAssignerClaims rethrows — under retry:false that ends this + // member's claims sync for good, and only on power-granting cargos. NOT isSafeDocId: a uid + // is not a path segment ("/" and reserved forms are legal in one), and the 128-char cap + // isSafeDocId lacks is the half that actually bites here. Untrusted shape → no grants. + if (typeof assignedBy !== "string" || assignedBy.length === 0 || assignedBy.length > 128) { + return []; + } const assigner = await deps.getAssignerClaims(assignedBy); const assignerIsAdmin = assigner.roles.includes("Admin"); // A delegate may confer power on OTHERS, never on themselves, and never Admin at all. diff --git a/apps/beacon/src/provision-deps.test.ts b/apps/beacon/src/provision-deps.test.ts new file mode 100644 index 00000000..6212d2ee --- /dev/null +++ b/apps/beacon/src/provision-deps.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { Auth } from "firebase-admin/auth"; +import type { Firestore } from "firebase-admin/firestore"; +import { firestoreProvisionDeps } from "./provision-deps.js"; + +/** Nothing under test reads Firestore — `createUser` is a pure Auth path. */ +const db = {} as Firestore; + +/** An Auth stub narrow enough to drive `createUser`'s one call chain. The cast is test-only + * and justified: `UserRecord` carries a dozen fields (metadata, providerData, toJSON) that + * nothing here reads, and fabricating them would assert nothing. A missing email throws the + * real `auth/user-not-found`, which is what the live SDK does. */ +function fakeAuth(opts: { createError?: unknown; byEmail?: Record }) { + const calls = { createUser: [] as string[], getUserByEmail: [] as string[] }; + const auth = { + createUser: async ({ email }: { email: string }) => { + calls.createUser.push(email); + if (opts.createError !== undefined) throw opts.createError; + return { uid: `new-${email}`, email }; + }, + getUserByEmail: async (email: string) => { + calls.getUserByEmail.push(email); + const user = opts.byEmail?.[email]; + if (!user) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); + return user; + }, + } as unknown as Auth; + return { auth, calls }; +} + +const authError = (code: string) => Object.assign(new Error(code), { code }); + +describe("firestoreProvisionDeps.createUser", () => { + it("returns the freshly minted account without consulting getUserByEmail", async () => { + const { auth, calls } = fakeAuth({}); + await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).resolves.toMatchObject({ + uid: "new-a@b.co", + }); + expect(calls.createUser).toEqual(["a@b.co"]); + expect(calls.getUserByEmail).toEqual([]); + }); + + it("BLOCKING: rethrows a non-collision Auth error instead of masking it as a lookup", async () => { + // The catch used to be blanket. A quota, disabled-provider or invalid-email failure then + // fell through to getUserByEmail — which throws auth/user-not-found for an email no + // account was ever created for, and nullIfUserNotFound turns THAT into a null the relink + // guard reads as "the account was safely deleted". Wrong outcome, and the real cause was + // gone from the log. The fallback must fire for exactly one code. + for (const code of [ + "auth/quota-exceeded", + "auth/operation-not-allowed", + "auth/invalid-email", + ]) { + const { auth, calls } = fakeAuth({ createError: authError(code) }); + await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).rejects.toMatchObject({ + code, + }); + expect(calls.getUserByEmail).toEqual([]); + } + }); + + it("rethrows a codeless throw too — an unrecognized shape is not a collision", async () => { + const { auth, calls } = fakeAuth({ createError: new Error("socket hang up") }); + await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).rejects.toThrow( + "socket hang up", + ); + expect(calls.getUserByEmail).toEqual([]); + }); + + it("falls back to getUserByEmail ONLY on a concurrent-create collision", async () => { + // The one tolerated case: a parallel invite already minted the account, so adopting it is + // the correct resolution rather than a failure. + const { auth, calls } = fakeAuth({ + createError: authError("auth/email-already-exists"), + byEmail: { "a@b.co": { uid: "u-existing" } }, + }); + await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).resolves.toMatchObject({ + uid: "u-existing", + }); + expect(calls.createUser).toEqual(["a@b.co"]); + expect(calls.getUserByEmail).toEqual(["a@b.co"]); + }); + + it("propagates the lookup's own failure when the collision resolves to nothing", async () => { + // Not squashed to null: the caller's null contract means "no account exists", and this + // path just proved one does. + const { auth } = fakeAuth({ createError: authError("auth/email-already-exists") }); + await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).rejects.toMatchObject({ + code: "auth/user-not-found", + }); + }); +}); diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts index 14e9f3c8..09e6ca96 100644 --- a/apps/beacon/src/provision-member-login.test.ts +++ b/apps/beacon/src/provision-member-login.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { HttpsError } from "firebase-functions/v2/https"; import type { Role } from "@luminova/auth/roles"; import { validateProvisionInput, @@ -9,13 +10,41 @@ import { } from "./provision-member-login.js"; describe("validateProvisionInput", () => { + function codeOf(data: unknown): string { + try { + validateProvisionInput(data); + return "no-throw"; + } catch (err) { + return err instanceof HttpsError ? err.code : "not-an-https-error"; + } + } + it("accepts a clean memberId", () => { expect(validateProvisionInput({ memberId: "m-1" })).toEqual({ memberId: "m-1" }); }); - it("rejects missing / empty / unclean memberId", () => { - expect(() => validateProvisionInput({})).toThrow(); - expect(() => validateProvisionInput({ memberId: "" })).toThrow(); - expect(() => validateProvisionInput({ memberId: "a/b" })).toThrow(); + + it("rejects every unusable memberId with invalid-argument, never a 500", () => { + // The CODE, not merely a throw: `.`, `..` and `__x__` BUILD a valid ref and fail LATER at + // get() with a permanent INVALID_ARGUMENT, which reaches the caller as `internal` — a 500 + // for what is a malformed request. A non-string had no typeof check at all before. Those + // are exactly the shapes moving to isSafeDocId added; the empty and "/"-bearing rows were + // already caught by the hand-rolled check this replaced, and stay as the regression floor. + const unusable: unknown[] = [ + undefined, + "", + "a/b", + ".", + "..", + "__name__", + 42, + { id: "m-1" }, + "x".repeat(1501), + ]; + for (const memberId of unusable) { + expect(codeOf({ memberId })).toBe("invalid-argument"); + } + expect(codeOf({})).toBe("invalid-argument"); + expect(codeOf(null)).toBe("invalid-argument"); }); }); @@ -173,6 +202,7 @@ describe("provisionMember", () => { }); await expect(provisionMember(deps, "m1", false)).rejects.toMatchObject({ code: "permission-denied", + details: { reason: "reprovision-requires-admin" }, }); // Nothing partial: no claim write, no uid link, no reset link generated. expect(calls.setClaims).toEqual([]); @@ -180,17 +210,6 @@ describe("provisionMember", () => { expect(calls.createUser).toEqual([]); }); - it("still lets a non-Admin caller mint a BRAND-NEW account", async () => { - // The delegation costs nothing on the path it is actually for: a genuinely new member - // has neither an Auth account nor a stored uid. - const fresh = fakeDeps({ member: active }); - await expect(provisionMember(fresh.deps, "m1", false)).resolves.toEqual({ - email: "a@b.co", - actionLink: "", - }); - expect(fresh.calls.createUser).toEqual(["a@b.co"]); - }); - it("BLOCKING: a non-Admin caller may NOT re-provision an ALREADY-LINKED member", async () => { // The resend path is an account-takeover primitive, not a convenience. passwordResetLink // is generatePasswordResetLink — it hands the oobCode URL to the CALLER, unlike the @@ -206,6 +225,7 @@ describe("provisionMember", () => { }); await expect(provisionMember(deps, "m1", false)).rejects.toMatchObject({ code: "permission-denied", + details: { reason: "reprovision-requires-admin" }, }); expect(calls.setClaims).toEqual([]); expect(calls.linkUid).toEqual([]); @@ -383,42 +403,40 @@ describe("provisionMember", () => { }); await expect(provisionMember(missing.deps, "m1", false)).rejects.toMatchObject({ code: "permission-denied", + details: { reason: "power-seat-requires-admin" }, }); + // The malformed half pins readCargoIds' `isSafeDocId(cargoId) ? cargoId : ""` mapping, + // which is why the fake ANSWERS "a/b" with a grant-free cargo: without that seed both + // halves would resolve through `opts.positions?.[…] ?? null` alike, and turning the + // mapping into a passthrough would leave this green. Seeded, a passthrough would read the + // grant-free entry and ALLOW. const malformed = fakeDeps({ member: { ...active, positions: { [TERM]: { cargoId: "a/b", comisionIds: [], assignedBy: "admin-uid" } }, }, + positions: { "a/b": [] }, }); await expect(provisionMember(malformed.deps, "m1", false)).rejects.toMatchObject({ code: "permission-denied", + details: { reason: "power-seat-requires-admin" }, }); }); - it("still lets a delegate provision a member seated on a GRANT-FREE cargo", async () => { - // Seating plus inviting on a grant-free cargo mints nothing, and is exactly the enrolment - // flow the delegation exists for. Without this pair the guard above would pass for a rule - // that simply refused every seated member. - const { deps, calls } = fakeDeps({ - member: { - ...active, - positions: { [TERM]: { cargoId: "pos-dir", comisionIds: [], assignedBy: "delegate-uid" } }, - }, - positions: { "pos-dir": [] }, - }); - await expect(provisionMember(deps, "m1", false)).resolves.toMatchObject({ email: "a@b.co" }); - expect(calls.createUser).toEqual(["a@b.co"]); - }); - it("BLOCKING: a delegate never receives the password-reset link", async () => { // generatePasswordResetLink returns a bearer credential for the account. The client sends // the reset mail itself through the unprivileged sendPasswordResetEmail, so a delegate has // no need to hold it. Defence in depth behind the power-seat guard, not a substitute. + // + // The delegate half doubles as the paired ALLOW for every BLOCKING case above: the + // delegation costs nothing on the path it is actually for, since a genuinely new member + // has neither an Auth account nor a stored uid — hence the createUser assertion. const delegate = fakeDeps({ member: active }); await expect(provisionMember(delegate.deps, "m1", false)).resolves.toEqual({ email: "a@b.co", actionLink: "", }); + expect(delegate.calls.createUser).toEqual(["a@b.co"]); const admin = fakeDeps({ member: active }); await expect(provisionMember(admin.deps, "m1", true)).resolves.toEqual({ email: "a@b.co", @@ -433,6 +451,7 @@ describe("provisionMember", () => { const { deps, calls } = fakeDeps({ member: { ...active, uid: "dead-uid" } }); await expect(provisionMember(deps, "m1", false)).rejects.toMatchObject({ code: "permission-denied", + details: { reason: "reprovision-requires-admin" }, }); expect(calls.createUser).toEqual([]); }); @@ -443,7 +462,10 @@ describe("provisionMember", () => { member: active, usersByEmail: { "a@b.co": { uid: "u9", email: "a@b.co" } }, }); - await expect(provisionMember(deps, "m1")).rejects.toMatchObject({ code: "permission-denied" }); + await expect(provisionMember(deps, "m1")).rejects.toMatchObject({ + code: "permission-denied", + details: { reason: "reprovision-requires-admin" }, + }); }); it("rejects a missing / inactive / email-less member", async () => { diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index 838e3c5d..a22380cf 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -16,6 +16,11 @@ interface ComboboxProps { emptyText?: string; disabled?: boolean; id?: string; + /** Ids of the elements explaining this control — a note about why the list is empty, or why + * the control is locked. Without it those notes sit later in the reading order, so a + * screen-reader user reaches the trigger, hears only `emptyText`, and never meets the + * explanation. */ + "aria-describedby"?: string; } /** Single-select + search on Radix Popover + cmdk, JCI-token styled. Re-select clears. */ @@ -28,6 +33,7 @@ export function Combobox({ emptyText = "Sin resultados", disabled, id, + "aria-describedby": describedBy, }: ComboboxProps) { const [open, setOpen] = useState(false); const selected = options.find((o) => o.value === value) ?? null; @@ -39,6 +45,7 @@ export function Combobox({ type="button" id={id} disabled={disabled} + aria-describedby={describedBy} aria-haspopup="listbox" aria-expanded={open} className={cn( From 367b76a07860e1bc6d75fedbb6e67b77745df4f8 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:09:38 -0400 Subject: [PATCH 04/25] docs(rules,specs): pin the unconstrained email, correct the superseded guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: three beacon guards are justified by "firestore.rules never constrains members.email" — the adoption guard, the power-seat guard and their comments all lean on it, and nothing pinned it. A characterization test now asserts the exposure directly, so adding an email pin later cannot silently make those guards over-strict, and loosening something adjacent cannot pass unnoticed. Also retargets four test names that no longer described their bodies and drops one create-lane test strictly subsumed by its Admin twin. rules comments: the takedown reference pointed "below" at logic that is above, and "a delegate cannot displace a power-cargo holder" was stated unqualified when it holds only within the current term — currentCargoGrantsEmpty() reads positions[currentTermKey()], so the UTC-year rollover empties a sitting Admin's slot. The spec already disclosed the caveat; the rules did not. plan doc: marked SUPERSEDED, with its two guard descriptions corrected inline. It still recommended, verbatim and under a heading that reads as current, the trust-gate form that was rejected for stripping the seeded president's Admin — a near production outage — plus a provisioning guard looser than what shipped. spec: operator notes for what the audit surfaced. Revocation is not immediate — claims decode only in onAuthStateChanged off a cached token, and both the rules and the callable read request.auth.token, so a revoked code keeps working until the ID token expires; an immediate cut needs revokeRefreshTokens. The self-assignment guard blocks the one-write self-promotion, not the multi-write puppet form, so the ceiling with all three codes held together is "everything but Admin" — stated plainly rather than left implied. And nothing in the codebase deletes or disables an Auth user while syncMemberClaims ignores membership status, so a puppet account outlives revocation until the Firebase console removes it. Also delivers the two cross-references the plan's own Slice 8 promised and never made. Co-Authored-By: Claude Opus 5 (1M context) --- docs/plans/board-seat-delegation.md | 142 ++++++++++++++++++++++--- docs/specs/board-seat-delegation.md | 61 ++++++++++- docs/specs/position-assignment-lane.md | 12 +++ firestore.rules | 8 +- packages/auth/CLAUDE.md | 7 ++ tests/firestore-rules/rules.test.ts | 48 +++++---- 6 files changed, 237 insertions(+), 41 deletions(-) diff --git a/docs/plans/board-seat-delegation.md b/docs/plans/board-seat-delegation.md index 61207936..64fcd694 100644 --- a/docs/plans/board-seat-delegation.md +++ b/docs/plans/board-seat-delegation.md @@ -2,6 +2,18 @@ Branch: `feat/board-seat-delegation` +> **SUPERSEDED — point-in-time artifact, not current guidance.** This plan was written before +> the design went through two adversarial reviews and 15 commits, and it was never updated as +> the design changed under it. `docs/specs/board-seat-delegation.md` is the authority on what +> actually shipped. Most importantly: **section 0b's G2 guard, below, describes a form of the +> self-assignment fix that was proposed, found to be wrong, and REJECTED** — it would have +> stripped the seeded bootstrap president's `Admin` claim on their very next member write, a +> near production outage. Do not implement G2 as written. See the correction inline at G2 and +> the spec's "Two earlier forms of this guard were wrong" for the full account. Kept in the repo +> as the commit-by-commit record of how the design evolved (per the "Fact-check corrections" +> table near the end, this document corrects itself in several other places too) — read it as +> history, not as a spec to implement from. + ## 0. Accepted decision The chapter owner has accepted that `update:BoardSeat` is a **claims-minting delegation**: a @@ -15,6 +27,14 @@ delegate can still seat any vacant cargo, including `Presidente`. ## 0b. Security guards (added after adversarial review — G1/G2/G3) +> **SUPERSEDED section.** G1 and G2 below describe the FIRST proposed shape of each guard, from +> before implementation. Neither is what shipped — G1's shipped guard is narrower, and G2's +> proposed fix was rejected outright (see the correction under G2). Section 0b also predates +> three guards that were added later, in response: the power-seat guard in `provisionMember`, +> fail-closed handling for a malformed `grants`/`positions` shape, and a narrowed `createUser` +> catch — listed under "Guards added after this section was written", below. Treat this whole +> section as the audit trail, and `docs/specs/board-seat-delegation.md` as the design authority. + Two independent reviews found three defects, all confirmed by reading code. Each guard below removes a capability the owner did **not** ask for, and none removes one they did. @@ -30,30 +50,82 @@ claims via `adoptedClaims` at `:109`, (c) `linkUid` the Admin's uid onto their o the admin SDK at `:111`, and (d) return a **password-reset link for the Admin's email** at `:112-114`. Full account takeover, unrelated to board seating. -**Guard:** thread the caller's privilege into `provisionMember`. For a non-Admin caller require +**Guard, as first proposed here — NARROWER in the shipped code, see correction:** thread the +caller's privilege into `provisionMember`. For a non-Admin caller require `user === null || user.uid === linkedUid` **before** `:97` — a delegate may create a brand-new Auth account or re-provision an already-linked one, never adopt a pre-existing account. Costs the delegate nothing: a genuinely new member has no existing account. +**Correction — what shipped is stricter than this.** The resend/re-provision half of this +proposal was itself found to be a hole and closed before merge: +`generatePasswordResetLink` (`passwordResetLink` in the `ProvisionDeps` port) returns the oobCode +URL **to the caller**, categorically unlike `sendPasswordResetEmail`, which delivers the secret +to the mailbox owner. A delegate permitted to "re-provision an already-linked" member could name +any member's id — including an Admin's — and receive a live reset link for that address. The +shipped guard in `provisionMember` (`apps/beacon/src/provision-member-login.ts:206`) is therefore: + +```ts +if (!callerHoldsAdminRole && (user !== null || linkedUid !== null)) { + throw new HttpsError("permission-denied", /* ... */); +} +``` + +A non-Admin caller gets exactly one shape — mint a brand-new Auth account for a member that has +**neither** an existing Auth user for its email **nor** a stored `uid`. Resend, adoption, and the +deleted-account self-heal are all Admin-only; there is no re-provision path left for a delegate at +all. See `docs/specs/board-seat-delegation.md` § "`create:MemberLogin` — what is actually +privileged" for the full account, including the power-seat guard this section does not mention +(added later — see below). + Note also: the invite **email** is not the privileged part. `requestPasswordReset` (`apps/backstage/src/lib/auth/request-password-reset.ts`) is a plain client-side `sendPasswordResetEmail` any signed-in user can already call. The callable's privilege is account creation/adoption + uid linking + claim writing. -### G2 — the trust gate must be non-reflexive on self-assignment - -`sync.ts:69` + `compute-roles.ts:8-12`. A delegate self-seats `Presidente` in one write on the -positions-only lane; beacon mints `roles: ["Admin","Member"]`. Revoking `update:BoardSeat` then -re-fires `onMemberWritten`, the gate reads their **live** claims, finds the `Admin` role the -cargo just minted, and re-honors the grants. The claim satisfies the gate that minted it, so the -delegation is permanent. `recomputeAllClaims` runs the same code and does not break the loop. +### G2 — the trust gate must be non-reflexive on self-assignment — proposed fix REJECTED, do not implement as written + +`sync.ts:69` + `compute-roles.ts:8-12` (pre-implementation line refs; current shipped location is +`resolveTrustedGrants` in `apps/beacon/src/claims-sync/sync.ts:97-135`). A delegate self-seats +`Presidente` in one write on the positions-only lane; beacon mints `roles: ["Admin","Member"]`. +Revoking `update:BoardSeat` then re-fires `onMemberWritten`, the gate reads their **live** claims, +finds the `Admin` role the cargo just minted, and re-honors the grants. The claim satisfies the +gate that minted it, so the delegation is permanent. `recomputeAllClaims` runs the same code and +does not break the loop. This diagnosis stands; only the fix below was wrong. + +**Guard as first proposed here — REJECTED, DO NOT RE-PROPOSE:** when `assignedBy === member.uid`, +trust **only** `assigner.perms.includes("update:BoardSeat")`, never `assigner.roles.includes("Admin")`. + +**Why this is wrong, and is the exact inverse of what shipped:** the seeded bootstrap president +self-stamps `assignedBy` with their own uid (`tools/scripts/lib/seed-president.mjs`), and an +Admin's perms are `manage:all` — never the exact `update:BoardSeat` code. Keying the +self-assignment trust check on the perm, as proposed above, means the sitting president's own +`Admin` claim would be **stripped on their next member write**. Confirmed against the live +production member doc before this shipped; a regression test pins it. This form also does not +close the two-write puppet loop (a delegate seats a SECOND member, not themselves, on +`Presidente` — not a self-assignment, so it is still trusted) — see +`docs/specs/board-seat-delegation.md` § "Two earlier forms of this guard were wrong" for both +failure modes in full. + +**What shipped instead:** self-assignment is honored ONLY for an assigner holding the Admin +**role** — the opposite disjunct from the one proposed above — and the same rule extends to any +cargo whose `grants` include `Admin`, self-assigned or not: + +```ts +const assignerIsAdmin = assigner.roles.includes("Admin"); +const selfAssigned = assignedBy === memberUid; +const trusted = + position.grants.includes("Admin") || selfAssigned + ? assignerIsAdmin + : assignerIsAdmin || assigner.perms.includes("update:BoardSeat"); +``` -**Guard:** when `assignedBy === member.uid`, trust **only** `assigner.perms.includes("update:BoardSeat")`, -never `assigner.roles.includes("Admin")`. Cargo-derived Admin can no longer bootstrap its own -trust. An Admin seating *someone else* is unaffected; an Admin self-seating still works via the -perm if they hold it, and via a second Admin otherwise. +An Admin seating *someone else* on a non-Admin power cargo is unaffected either way; an Admin +self-seating still works because they hold the Admin role. Revoking `update:BoardSeat` from a +delegate is then real for everything they *can* confer, because no cargo-derived Admin can ever +originate from a delegate in the first place — there is no loop to close. -Test: assigner === target, `roles:["Admin"]`, `perms:[]` → grants dropped. +Test: assigner === target, `roles:["Admin"]`, `perms:[]` → grants **still honored** (assigner is +Admin) — the inverse of what the rejected proposal above would have asserted. ### G3 — `currentCargoGrantsEmpty()` stays Admin-only @@ -78,6 +150,33 @@ substitution. If the owner later wants de-elevation delegated too, that is a separate code with its own anti-lockout guard — not this one. +### Guards added after this section was written + +Later commits on this branch (`5f9408e`, `e02dbf1`, `d7cd564`, `2c328f5`, `58266d3`) found and +closed three more gaps this section does not mention. Confirmed against the shipped code, not +restated from memory: + +- **The power-seat guard in `provisionMember`.** G1 above stops a delegate from re-provisioning + or adopting an ALREADY-linked account, but says nothing about an unlinked member who is already + granted or already power-seated — "unprovisioned" is not "enrolled by this delegate". Without a + further check, `provisionMemberLogin` would link a fresh Auth uid onto such a member, fire + `onMemberWritten`, and `resolveTrustedGrants` would read the *stored* `assignedBy` (a genuine + Admin) and mint the grants onto the account the delegate's call just created — a clean + escalation the delegate never had to forge. The shipped guard + (`apps/beacon/src/provision-member-login.ts:213-251`) checks both claims-mint sources + `syncMemberClaims` reads: `hasDirectGrants()` (`:83-99`, `roleIds`/`permissionOverrides`) and a + per-term cargo read via `readCargoIds()` (`:101-139`, every term in `positions`, not just the + current one — a future-term slate is invisible to claims-sync today but not to this guard). +- **Fail-closed handling for a malformed `grants` or `positions` shape.** `readCargoIds()` + (`:119-139`) yields `""` — not skip — for a non-object term, a non-string `cargoId`, or one + `isSafeDocId` rejects; the caller then refuses on `grants === null`, so a shape it cannot parse + is treated as power-seated, never as "no cargo". `hasDirectGrants()` (`:83-99`) is symmetric for + `roleIds` / `permissionOverrides`: a present-but-unparseable value reads as granted, and only a + genuinely absent/null value reads as ungranted. +- **The narrowed `createUser` catch.** `apps/beacon/src/provision-deps.ts:25-29` swallows exactly + `auth/email-already-exists` (a benign race with a concurrent create) and rethrows everything + else — it does not swallow arbitrary Auth errors into a silent fallback. + ## The two codes | Subject | Spanish label | Live code | Grants | @@ -319,11 +418,26 @@ Rejected alternatives: return trusted ? [...new Set(position.grants)] : []; ``` + **This snippet is superseded — it is missing the self-assignment / Admin-granting-cargo branch + that G2 (above) required and that shipped.** The real trust computation, unchanged since, + reads (`apps/beacon/src/claims-sync/sync.ts:126-134`): + + ```ts + const assignerIsAdmin = assigner.roles.includes("Admin"); + const selfAssigned = assignedBy === memberUid; + const trusted = + position.grants.includes("Admin") || selfAssigned + ? assignerIsAdmin + : assignerIsAdmin || assigner.perms.includes("update:BoardSeat"); + return trusted ? [...new Set(position.grants)] : []; + ``` + Extend the doc comment: name `update:BoardSeat` as the second trust source; state why (a delegate stamps their own uid into `assignedBy` via `assignedBySelf()`, so without this the seat publishes on the public Directiva and mints nothing — half-working, not safe); state the live-claims re-evaluation now also covers perm revocation; cross-reference `boardSeatDelegate()` in - `firestore.rules`. One line on the cap interaction (C2). + `firestore.rules`. One line on the cap interaction (C2). State the self-assignment / Admin-grant + exception too (G2) — it is not a detail, it is the guard the whole delegation rests on. 2. `apps/beacon/src/claims-sync/firestore-deps.ts` — rename the impl at `:287`, returning both, reusing the two module-local readers already there. No new read: `loadUser` is the same per-instance memo. diff --git a/docs/specs/board-seat-delegation.md b/docs/specs/board-seat-delegation.md index 9597dace..515a8c57 100644 --- a/docs/specs/board-seat-delegation.md +++ b/docs/specs/board-seat-delegation.md @@ -139,9 +139,20 @@ This costs the delegation nothing: a genuinely new member has neither an account `update:BoardSeat` override; an org-chart-only delegate wants `update:Position` instead. 2. **The delegate must sign out and back in after being granted or revoked.** All three gates — `firestore.rules`, `requireAdminOrPerm` and `useCan` — read the `perms` claim off the ID - token, and `auth-store.ts` calls `getIdTokenResult()` without `forceRefresh`. A freshly - granted code is invisible for up to an hour otherwise, and a freshly revoked one keeps - working for up to an hour. + token, and `auth-store.ts` calls `getIdTokenResult()` without `forceRefresh` — it is the only + place the app reads a token, there is no `onIdTokenChanged` listener anywhere, and no code + path forces a refresh. A freshly granted code is invisible for up to an hour otherwise, and a + freshly revoked one keeps working for up to an hour: the delegate's UI keeps its affordances + until they reload, and both `hasPerm()` in `firestore.rules` and `requireAdminOrPerm` in + beacon keep honoring the revoked code from their cached ID token until it expires. Signing out + and back in **does** mint a fresh token off the current (revoked) claims — but that is the + delegate's choice to make, not the Admin's; nothing forces it, and an Admin who revokes the + code has no lever to shorten the window on a delegate who simply keeps their existing session + open. The only immediate, Admin-side cut is `admin.auth().revokeRefreshTokens(uid)` on the + delegate's uid — it invalidates every outstanding session and forces re-auth on the next + request, regardless of what the delegate does. Nothing in this codebase currently calls it; it + is a console or one-off-script op, run by hand when "revoked" needs to mean "now" rather than + "within the hour". 3. **A delegate's failed provision escalates to an Admin.** If `provisionMemberLogin` fails after creating the Auth account (a transient `setClaims`/`linkUid` error), the retry is refused by the adoption guard — the account now exists, so `user !== null`. Finish it from @@ -167,6 +178,50 @@ This costs the delegation nothing: a genuinely new member has neither an account `resolveTrustedGrants` declines to mint the grants. The member is then published on the public Directiva with no claim — visible, powerless. Re-running the write after the token refreshes resolves it. +8. **The puppet path — say plainly what the three codes together can reach.** The + self-assignment guard (above) blocks the ONE-WRITE form of self-promotion: a delegate cannot + seat *themselves* on a power cargo and have it mint. It does **not** block the multi-write + form, and nothing else in the system does either. A holder of a member-creation capability + (`create:Member` or `manage:Member`) plus `update:BoardSeat` plus `create:MemberLogin` can: + create a second member on a mailbox they control, `provisionMemberLogin` it (the power-seat + guard does not fire — the puppet is brand new and unseated), sign in as the puppet, and then — + back as themselves — seat the *puppet* on any non-Admin power-granting cargo. That is not a + self-assignment (`assignedBy` is the delegate's uid, the seated member is the puppet's), so + `resolveTrustedGrants` trusts it on the `update:BoardSeat` perm alone. The delegate now + controls, through the puppet's session, every built-in role's capabilities short of `Admin`. + `Admin` itself stays closed — `resolveTrustedGrants` refuses an Admin-granting cargo from any + assigner who is not Admin-by-role, puppet or not. **Say the ceiling correctly: it is "everything + but Admin," not "nothing."** Granting all three codes to one person is materially different + from granting them to three different people, and nothing in the UI distinguishes that for the + Admin doing the granting. +9. **What survives a revocation — nothing removes the account, and nothing surfaces who a + delegate seated.** Two facts worth stating together, because they compound: + - No code path in `apps/beacon/src` calls `deleteUser` or `updateUser(..., { disabled: true + })` — confirmed by grep, there is no disable/delete of an Auth user anywhere in this + codebase. Revoking `update:BoardSeat` or `create:MemberLogin` never touches the Auth account + a delegate (or their puppet) created. + - `syncMemberClaims` (`apps/beacon/src/claims-sync/sync.ts`) never reads `active` or + `membershipStatus` on the member doc — only `positions`, `roleIds` and + `permissionOverrides`. Soft-deleting a member (`active: false` / `deletedAt` set) or marking + them `"Desafiliado"` does not drop their claims; their `Member` role, and any cargo-derived + role, survives until something else re-triggers `syncMemberClaims` with different inputs. + - Nothing in `apps/backstage` renders `assignedBy` — it is written by the repository layer + (`member-mapper.ts:53,92,102`) but read by no `.tsx` in the app. An Admin who revokes a + delegate's codes has **no in-app way** to find which members that delegate seated; the only + path is a manual Firestore query on `positions..assignedBy`. + + **Post-revocation checklist for an Admin** (do all four; none is optional): + 1. Query `members` for every `positions..assignedBy == ` across every term + key present (console query, or a one-off script — no in-app view does this). + 2. For each match, decide by hand whether the seat should stand; unseat or re-seat as an + Admin action if not (a delegate/former-delegate cannot fix this after their code is gone). + 3. Run `recomputeAllClaims` (or re-write each affected member doc) so cargo-derived claims + resolve against the delegate's now-revoked authority, closing the "recomputed on the next + write, which may be never" gap in note 4. + 4. If the delegate might have created a puppet member (note 8), find any member whose `email` + the delegate plausibly controls and whose account was provisioned in the delegation window; + it is not deleted or disabled by revocation and must be handled explicitly — `Member` role + and directory read access persist until removed by hand in the Firebase console. ## Out of scope diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md index db7ffdb8..af09721c 100644 --- a/docs/specs/position-assignment-lane.md +++ b/docs/specs/position-assignment-lane.md @@ -72,6 +72,18 @@ assignable-only semantic this lane wants. `currentCargoGrantsEmpty()` must stay non-Admin branch: without it an `update:Position` holder could overwrite a president's power cargo with a grant-free one and silently strip Admin. +> **Superseded by one disjunct.** `docs/specs/board-seat-delegation.md` (branch +> `feat/board-seat-delegation`, after this PR) widens the NEW-side cargo conjunct from +> `cargoAssignableByNonAdmin()` to `boardSeatDelegate() || cargoAssignableByNonAdmin()` +> (`positionsAssignmentSafe()`, `firestore.rules:228-232`), so the "grant-free cargos only, on +> BOTH sides of a swap" claim +> above is no longer complete: an `update:BoardSeat` holder on this lane may now also assign a +> **power-granting** cargo (CEL or otherwise) to someone else — beacon's claims-sync trust gate +> honors that assignment too, unless it would mint `Admin` or the assignment is self-seating, +> both of which stay Admin-role-only. The OLD side, `currentCargoGrantsEmpty()`, is untouched by +> that branch — a delegate still cannot displace a sitting power-cargo holder. See the spec for +> the full trust-gate design. + `hasOnly(['positions'])` is the correct constraint for the production dot-path payload `{"positions.2026": {...}}` — `affectedKeys()` reports **top-level** keys, pinned by `rules.test.ts:2262-2270` and `:1310`. diff --git a/firestore.rules b/firestore.rules index c6f13eae..3c26dbeb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -217,10 +217,14 @@ service cloud.firestore { // no Admin: setUserRoles is requireAdmin, and roles/* plus permissionOverrides // writes are all hasAnyRole(['Admin']). The chapter would be unrecoverable outside // the Firebase console. A delegate therefore seats VACANT and grant-free cargos and - // cannot displace a power-cargo holder; hand-over stays an Admin action. + // cannot displace a power-cargo holder; hand-over stays an Admin action. One caveat: + // currentCargoGrantsEmpty() reads positions[currentTermKey()] only, so in the window + // after a UTC-year rollover a sitting Admin's current-term slot is empty and a + // delegate CAN write into it — pre-existing, disclosed in + // docs/specs/board-seat-delegation.md, not something this feature introduced. // // A non-delegate is unchanged: both conjuncts still bind, so the deliberately - // asymmetric grant-free-CEL takedown below still works for an update:Position holder. + // asymmetric grant-free-CEL takedown above still works for an update:Position holder. function positionsAssignmentSafe() { return positionsDelta().hasOnly([currentTermKey()]) && assignedBySelf() diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index 8717dd55..51d305f8 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -110,6 +110,13 @@ a data change. - A role that ends up with an empty perm set is a **degenerate role**: it still passes `isValidRole` but grants nothing. Check the drop-safety of a role before removing its last permission. +- **`BoardSeat` and `MemberLogin` are exact-code gates, not `canDo` subjects.** The live codes + (`update:BoardSeat`, `create:MemberLogin`) are each checked with `hasPerm` — in + `firestore.rules`, in beacon's `requireAdminOrPerm`, and in backstage's `useCan` — never a + `canDo`-style expansion, so a `manage:all` holder does **not** satisfy either one. The other + four generated codes per subject (`manage:BoardSeat`, `read:MemberLogin`, …) are consequently + inert: valid `PermissionCode`s, assignable in `/permisos`, satisfying nothing anywhere. See + `docs/specs/board-seat-delegation.md`. ## Consumers (why changes here are wide-blast) diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 031564f0..100bc0de 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -1162,27 +1162,17 @@ describe("firestore.rules — members", () => { ); }); - it("BLOCKING: a delegate may NOT create with a ride-along NON-current term key", async () => { - // The create-lane twin of positionsDelta().hasOnly([currentTermKey()]). assignedBySelf() - // and cargoAssignableByNonAdmin() both read only positions[currentTermKey()], so a second - // term key rode along completely unvalidated: a clean current-term entry to pass the arm, - // plus a next-term power cargo attributed to a real Admin. On the UTC-year rollover - // claims-sync reads THAT entry and mints Admin onto a member whose login the creator - // controls. Forged attribution, one term deferred. - await assertFails( - setDoc(doc(createDelegate(), "members/new_delegate_ridealong"), { - name: "Ximena Paz", - totalPoints: 0, - ...BORN_LIVE, - positions: { - [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "createdelegate-uid" }, - "2099": { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" }, - }, - }), - ); - }); + // The delegate variant of this same ride-along (createDelegate(), assignedBy stays self + // on the current-term entry) was here and is dropped: the term-key conjunct above is + // UNCONDITIONAL in createPositionsSafe() — not gated by boardSeatDelegate() — and Admin + // already satisfies boardSeatDelegate() (hasAnyRole(['Admin']) is one of its two arms), so + // any mutation that reddens the delegate variant (e.g. gating the conjunct behind + // `boardSeatDelegate() ||`) reddens this Admin one too, but not vice versa: a + // `hasAnyRole(['Admin']) ||` carve-out in front of the conjunct — the tempting future edit + // this test's own comment names — passes the dropped delegate variant untouched while this + // one still catches it. Strict subset; this is the stronger survivor. - it("BLOCKING: create:Member alone, and update:BoardSeat alone, each reach nothing", async () => { + it("BLOCKING: update:BoardSeat alone reaches nothing on the create lane", async () => { // The create-lane twin of the update-lane non-vacuity pin. update:BoardSeat widens WHICH // cargo a creator may use; it does not make anyone a creator. await assertFails( @@ -1194,7 +1184,7 @@ describe("firestore.rules — members", () => { ); }); - it("BLOCKING: the same create principal WITHOUT update:BoardSeat is still denied both", async () => { + it("BLOCKING: create:Member alone (no update:BoardSeat, a different principal) is still denied a CEL or power cargo", async () => { // The paired denial — otherwise the two ALLOWs above would pass for any create:Member // holder and prove nothing about the new disjunct. await assertFails( @@ -1255,6 +1245,20 @@ describe("firestore.rules — members", () => { updateDoc(doc(as("u", ["Membership"]), "members/m1"), { name: "Ana Rivas Paz" }), ); }); + // ACCEPTED EXPOSURE — pins the premise three beacon guards are built on + // (provisionMemberLogin's adoption guard and power-seat guard, in + // apps/beacon/src/provision-member-login.ts, plus their tests and the rules comments + // near currentCargoGrantsEmpty()): "firestore.rules never constrains members.email". + // memberWriteInvariants() locks totalPoints/uid/publicProfile and gates positions — + // email is not among them, so any canDo('update','Member') holder may rewrite ANY + // member's email, not just their own. "u" does not own members/m1 (owner-uid does). + // If a later PR pins email here, this test goes red first — the signal that the beacon + // guards it justifies are now over-strict, not that something silently broke. + it("ACCEPTED EXPOSURE: a manage:Member holder may rewrite another member's email (why beacon's adoption + power-seat guards exist)", async () => { + await assertSucceeds( + updateDoc(doc(as("u", ["Membership"]), "members/m1"), { email: "rewritten@example.com" }), + ); + }); // memberNameValid() binds EVERY lane, not just the member's own: boardShowcase publishes // the name world-read, so the bound belongs at the trust boundary. Leaving it to the admin // form's client-side zod would make it bypassable by any direct authenticated write. @@ -3271,7 +3275,7 @@ describe("firestore.rules — member positions assignment", () => { ); }); - it("denies a delegate a forged assignedBy, a past term, and a ride-along field", async () => { + it("denies a delegate a forged assignedBy, a non-current term, and a ride-along field", async () => { // assignedBySelf(), the current-term restriction and hasOnly(['positions']) all sit // OUTSIDE the substituted disjunction. Pin that the delegation did not loosen them — // a forged assignedBy is what the beacon trust gate reads to decide whether to mint. From 6e475176f8fc05ed1c743dc488718e5ddf4d3863 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:48:35 -0400 Subject: [PATCH 05/25] fix(types,beacon): make the provisioning refusal reasons one typed contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four `details.reason` tags were hand-copied across five places — beacon's throws, beacon's tests, the client's message map, and both test files — with nothing coupling them. Renaming one in beacon degraded the client silently to its generic fallback, which is the exact failure the client mapping was added to eliminate. PROVISION_BLOCK_REASONS now lives in @luminova/types and both sides reference it, so a rename is a compile error on both. Verified by renaming one and watching beacon and backstage each fail to typecheck. Also closes the same mistake-class this branch already fixed twice: `member.email` reached `auth.getUserByEmail` shape-unchecked, and the Admin SDK's auth/invalid-email is not one of the codes nullIfUserNotFound swallows — so it rethrew as an opaque `internal` and left that member unprovisionable through the callable until someone edited the stored value in the console. Screened with firebase-admin's OWN isEmail predicate, copied verbatim so nothing Firebase accepts is refused here, and tagged `member-email-malformed` so the operator is told what to fix. Pins the ACCEPTANCE half of the assignedBy screen. Only the rejection half was tested, so a future "reuse isSafeDocId for consistency" edit would have passed every test while denying legitimate uids — the screen exists precisely because a uid is not a doc id. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/lib/provision-error.test.ts | 42 ++++++++++------ .../features/members/lib/provision-error.ts | 41 ++++++++-------- apps/beacon/src/claims-sync/sync.test.ts | 42 ++++++++++++++++ .../beacon/src/provision-member-login.test.ts | 42 ++++++++++++++++ apps/beacon/src/provision-member-login.ts | 48 +++++++++++++++---- packages/types/src/index.ts | 1 + .../types/src/provision-block-reason.test.ts | 19 ++++++++ packages/types/src/provision-block-reason.ts | 33 +++++++++++++ 8 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 packages/types/src/provision-block-reason.test.ts create mode 100644 packages/types/src/provision-block-reason.ts diff --git a/apps/backstage/src/features/members/lib/provision-error.test.ts b/apps/backstage/src/features/members/lib/provision-error.test.ts index e77d3344..aa0fe5d5 100644 --- a/apps/backstage/src/features/members/lib/provision-error.test.ts +++ b/apps/backstage/src/features/members/lib/provision-error.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { PROVISION_BLOCK_REASONS, type ProvisionBlockReason } from "@luminova/types"; import { provisionErrorMessage } from "./provision-error"; const FALLBACK = "No se pudo enviar la invitación."; @@ -6,9 +7,13 @@ const FALLBACK = "No se pudo enviar la invitación."; const withReason = (reason: unknown) => Object.assign(new Error("failed-precondition"), { details: { reason } }); +/** Typed narrowing of `withReason` for the real tags: a reason renamed in beacon fails to + * compile here too, instead of quietly asserting a message nothing throws any more. */ +const blocked = (reason: ProvisionBlockReason) => withReason(reason); + describe("provisionErrorMessage", () => { it("explains the console unlink when the callable reports a uid conflict", () => { - expect(provisionErrorMessage(withReason("linked-to-different-login"), FALLBACK)).toBe( + expect(provisionErrorMessage(blocked("linked-to-different-login"), FALLBACK)).toBe( "El miembro ya está vinculado a otro acceso (correo cambiado). Desvincúlalo desde la consola antes de reintentar.", ); }); @@ -17,34 +22,40 @@ describe("provisionErrorMessage", () => { // Auth directory, so a delegate still reaches these — and without a message each reads as a // transient failure the operator retries forever. it("names beacon's adoption refusal (reprovision-requires-admin)", () => { - expect(provisionErrorMessage(withReason("reprovision-requires-admin"), FALLBACK)).toBe( + expect(provisionErrorMessage(blocked("reprovision-requires-admin"), FALLBACK)).toBe( "Ya existe un acceso para este correo. Pídele a un administrador que lo reenvíe o lo vincule.", ); }); it("names beacon's direct-grants refusal (granted-member-requires-admin)", () => { - expect(provisionErrorMessage(withReason("granted-member-requires-admin"), FALLBACK)).toBe( + expect(provisionErrorMessage(blocked("granted-member-requires-admin"), FALLBACK)).toBe( "Este miembro tiene roles o permisos asignados: solo un administrador puede crear su acceso.", ); }); it("names beacon's power-seat refusal (power-seat-requires-admin)", () => { - expect(provisionErrorMessage(withReason("power-seat-requires-admin"), FALLBACK)).toBe( + expect(provisionErrorMessage(blocked("power-seat-requires-admin"), FALLBACK)).toBe( "El cargo de este miembro otorga permisos: solo un administrador puede crear su acceso.", ); }); - it("gives each reason a DISTINCT message", () => { - // A table is one copy-paste away from two reasons sharing a message, which would tell the - // operator to do the wrong thing about half the time. - const messages = [ - "linked-to-different-login", - "reprovision-requires-admin", - "granted-member-requires-admin", - "power-seat-requires-admin", - ].map((reason) => provisionErrorMessage(withReason(reason), FALLBACK)); - expect(new Set(messages).size).toBe(messages.length); + it("names the malformed stored email, which no retry can fix", () => { + expect(provisionErrorMessage(blocked("member-email-malformed"), FALLBACK)).toBe( + "El correo guardado de este miembro no es válido. Corrígelo en su ficha antes de crear su acceso.", + ); + }); + + it("gives EVERY reason beacon can throw a distinct message", () => { + // Iterates the shared union rather than re-listing the literals: a reason added in beacon + // and not given a message here fails this test (it falls back), and a copy-paste that + // leaves two reasons sharing a message — telling the operator to do the wrong thing about + // half the time — fails it too. + const messages = PROVISION_BLOCK_REASONS.map((reason) => + provisionErrorMessage(blocked(reason), FALLBACK), + ); + expect(messages).toHaveLength(PROVISION_BLOCK_REASONS.length); expect(messages).not.toContain(FALLBACK); + expect(new Set(messages).size).toBe(messages.length); }); it("falls back to the generic message for any other failure", () => { @@ -58,5 +69,8 @@ describe("provisionErrorMessage", () => { expect(provisionErrorMessage(withReason("no-such-reason"), FALLBACK)).toBe(FALLBACK); expect(provisionErrorMessage(withReason(42), FALLBACK)).toBe(FALLBACK); expect(provisionErrorMessage(withReason(undefined), FALLBACK)).toBe(FALLBACK); + // Inherited Object.prototype keys must not resolve to a function-as-message. + expect(provisionErrorMessage(withReason("toString"), FALLBACK)).toBe(FALLBACK); + expect(provisionErrorMessage(withReason("constructor"), FALLBACK)).toBe(FALLBACK); }); }); diff --git a/apps/backstage/src/features/members/lib/provision-error.ts b/apps/backstage/src/features/members/lib/provision-error.ts index da002abb..ab25fdca 100644 --- a/apps/backstage/src/features/members/lib/provision-error.ts +++ b/apps/backstage/src/features/members/lib/provision-error.ts @@ -1,30 +1,33 @@ +import type { ProvisionBlockReason } from "@luminova/types"; + // provisionMemberLogin tags every refusal it can be argued with using details.reason, so the -// UI can name the actual blocker instead of a dead-end generic failure. Three of the four are +// UI can name the actual blocker instead of a dead-end generic failure. Three of the five are // non-Admin refusals a delegate can hit on a member the client cannot fully evaluate // (provisionBlockedForNonAdmin sees the member doc, not the Auth directory), and without a // message each reads as a transient error the operator retries forever. -// A Map, not an object literal: `reason` is attacker-adjacent input (it arrives in the -// callable's error payload), and `{...}[reason]` resolves "toString" / "constructor" / -// "valueOf" to the inherited Object.prototype FUNCTION, which `?? fallback` then happily -// returns as the message. TypeScript types that `string` and React would render a function. -const REASON_MESSAGES = new Map([ - [ - "linked-to-different-login", +// +// Keyed by ProvisionBlockReason, the union beacon throws from (@luminova/types) — a renamed +// or added reason is a compile error here rather than a silent fall-through to `fallback`. +// Type-only import: the runtime union array is used by the test, never by the bundle. +const MESSAGES: Readonly> = { + "linked-to-different-login": "El miembro ya está vinculado a otro acceso (correo cambiado). Desvincúlalo desde la consola antes de reintentar.", - ], - [ - "reprovision-requires-admin", + "reprovision-requires-admin": "Ya existe un acceso para este correo. Pídele a un administrador que lo reenvíe o lo vincule.", - ], - [ - "granted-member-requires-admin", + "granted-member-requires-admin": "Este miembro tiene roles o permisos asignados: solo un administrador puede crear su acceso.", - ], - [ - "power-seat-requires-admin", + "power-seat-requires-admin": "El cargo de este miembro otorga permisos: solo un administrador puede crear su acceso.", - ], -]); + "member-email-malformed": + "El correo guardado de este miembro no es válido. Corrígelo en su ficha antes de crear su acceso.", +}; + +// A Map, not the object literal above: `reason` is attacker-adjacent input (it arrives in the +// callable's error payload), and `{...}[reason]` resolves "toString" / "constructor" / +// "valueOf" to the inherited Object.prototype FUNCTION, which `?? fallback` then happily +// returns as the message. TypeScript types that `string` and React would render a function. +// The literal buys exhaustiveness against the union; the Map buys a safe lookup. +const REASON_MESSAGES = new Map(Object.entries(MESSAGES)); export function provisionErrorMessage(err: unknown, fallback: string): string { const details = (err as { details?: unknown } | null | undefined)?.details; diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts index 0a6af33d..8fcaa466 100644 --- a/apps/beacon/src/claims-sync/sync.test.ts +++ b/apps/beacon/src/claims-sync/sync.test.ts @@ -3,6 +3,7 @@ import type { Role } from "@luminova/auth/roles"; import type { PermissionCode, RoleDefinition } from "@luminova/types"; import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition"; import { ACTIONS, SUBJECTS } from "@luminova/types/permission"; +import { isSafeDocId } from "../firestore-util.js"; import { syncMemberClaims, type ClaimsSyncDeps, type MemberClaims } from "./sync.js"; import { parseMember } from "./parse-member.js"; import { isActiveRoleDoc } from "./role-doc.js"; @@ -524,6 +525,47 @@ describe("syncMemberClaims", () => { } }); + it("ACCEPTS a uid isSafeDocId would reject — the screen is a uid contract, not a path one", async () => { + // The other half of the screen above, and the half a "let's reuse isSafeDocId for + // consistency" edit would silently break: a uid is NOT a path segment. auth.getUser() + // accepts any 1..128-char string, so "/" and the reserved "." / ".." / "__x__" forms are + // legitimate uids (third-party/SAML-derived ids carry them), while isSafeDocId rejects + // every one. Tightening the screen to match it fails CLOSED — no throw, no log, just a + // legitimate delegate's grants quietly never minted. So pin the acceptance too. + for (const assignedBy of ["saml/acme|ana", ".", "..", "__soporte__"]) { + // Asserted, not assumed: this is the divergence the test exists to hold open. + expect(isSafeDocId(assignedBy)).toBe(false); + const { deps, writes } = fakeDeps({ + positions: { "pos-pres": { grants: ["Admin"] } }, + userRoles: { [assignedBy]: ["Admin"] }, + existing: { "target-uid": { roles: ["Member"] } }, + }); + const reached: string[] = []; + const spied: ClaimsSyncDeps = { + ...deps, + getAssignerClaims: async (uid) => { + reached.push(uid); + return deps.getAssignerClaims(uid); + }, + }; + await syncMemberClaims( + spied, + { + uid: "target-uid", + positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy } }, + }, + "2026", + ); + // The assigner lookup IS reached... + expect(reached).toEqual([assignedBy]); + // ...and their grants ARE minted. + expect(writes["target-uid"]).toEqual({ + roles: ["Admin", "Member"], + perms: permsFor(["Admin", "Member"]), + }); + } + }); + it("honors ONLY the cargo's grants — comisión grants are never power, even Admin-assigned", async () => { // Comisiones are chips-only (position-schema forbids Comision grants; rules // enforce it). A console-written power comisión — or a power cargo's id diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts index 09e6ca96..198d0222 100644 --- a/apps/beacon/src/provision-member-login.test.ts +++ b/apps/beacon/src/provision-member-login.test.ts @@ -479,6 +479,48 @@ describe("provisionMember", () => { provisionMember(fakeDeps({ member: { active: true } }).deps, "m1"), ).rejects.toMatchObject({ code: "failed-precondition" }); }); + + it("BLOCKING: screens a malformed stored email instead of surfacing an opaque `internal`", async () => { + // `email` reaches auth.getUserByEmail / auth.createUser, which reject anything outside the + // Admin SDK's shape with a PERMANENT auth/invalid-email. nullIfUserNotFound only swallows + // auth/user-not-found, so it rethrows and the operator sees `internal` — with no hint that + // the fix is editing the member's stored email. firestore.rules does not shape-validate + // email on the admin write lane, so this shape is reachable. + const reached: string[] = []; + for (const email of ["not-an-email", "@b.co", "a@", "a@b@c.co", " "]) { + const { deps, calls } = fakeDeps({ member: { email, active: true } }); + const spied: ProvisionDeps = { + ...deps, + getUserByEmail: async (value) => { + reached.push(value); + return deps.getUserByEmail(value); + }, + }; + await expect(provisionMember(spied, "m1", true)).rejects.toMatchObject({ + code: "failed-precondition", + details: { reason: "member-email-malformed" }, + }); + // Screened before the SDK sees it — that is the whole point of the check. + expect(reached).toEqual([]); + expect(calls.createUser).toEqual([]); + expect(calls.linkUid).toEqual([]); + } + }); + + it("does NOT refuse the unusual addresses the Admin SDK accepts", async () => { + // The screen is the SDK's own predicate (`/^[^@]+@[^@]+$/`), not an RFC validator: a + // plus-tag, a bare hostname and a non-ASCII local part all provision as before. Tightening + // this regex would make members with legitimate addresses unprovisionable — the exact + // failure the screen exists to prevent, pointed the other way. + for (const email of ["ana+jci@sub.example.co", "root@localhost", "añez@ejemplo.bo"]) { + const { deps, calls } = fakeDeps({ member: { email, active: true } }); + await expect(provisionMember(deps, "m1", true)).resolves.toEqual({ + email, + actionLink: `link:${email}`, + }); + expect(calls.createUser).toEqual([email]); + } + }); }); // Every case here exercises the ADOPTION path, which is Admin-only — hence the explicit diff --git a/apps/beacon/src/provision-member-login.ts b/apps/beacon/src/provision-member-login.ts index 01c07b86..c33883b5 100644 --- a/apps/beacon/src/provision-member-login.ts +++ b/apps/beacon/src/provision-member-login.ts @@ -2,6 +2,7 @@ import { getAuth } from "firebase-admin/auth"; import { getFirestore } from "firebase-admin/firestore"; import { HttpsError, onCall } from "firebase-functions/v2/https"; import { isValidRole, type Role } from "@luminova/auth/roles"; +import type { ProvisionBlockReason } from "@luminova/types"; import { isSafeDocId } from "./firestore-util.js"; import { callerIsAdmin, requireAdminOrPerm } from "./callable-auth.js"; import { firestoreProvisionDeps } from "./provision-deps.js"; @@ -35,6 +36,24 @@ export function nextClaims(existing: RawClaims | undefined, role: Role): { roles return { roles }; } +/** A refusal the CLIENT can name. `reason` is a cross-boundary contract owned by + * `@luminova/types` (PROVISION_BLOCK_REASONS) and consumed by backstage's message table — + * routing every tagged throw through this helper is what makes renaming one a compile + * error on both ends instead of a silent degradation to the generic fallback. */ +function provisionBlocked( + code: "failed-precondition" | "permission-denied", + message: string, + reason: ProvisionBlockReason, +): HttpsError { + return new HttpsError(code, message, { reason }); +} + +/** The Admin SDK's OWN email predicate (`validator.isEmail`: `/^[^@]+@[^@]+$/`), copied + * verbatim rather than tightened. The point is to refuse exactly what `getUserByEmail` / + * `createUser` would refuse — a stricter RFC-ish pattern would start rejecting addresses + * Firebase happily accepts, which is a worse failure than the one being fixed. */ +const ADMIN_SDK_EMAIL_SHAPE = /^[^@]+@[^@]+$/; + export interface ProvisionUser { uid: string; email?: string; @@ -162,6 +181,19 @@ export async function provisionMember( throw new HttpsError("failed-precondition", "member has no email"); } const email = member.email; + // Shape-screened BEFORE it reaches the Auth SDK, for the same reason cargoId and assignedBy + // are screened in claims-sync: a stored value the SDK rejects throws a PERMANENT + // auth/invalid-email, which nullIfUserNotFound rethrows and the caller receives as an opaque + // `internal`. That member is then unprovisionable through this callable — with no hint why — + // until someone edits the doc in the console. firestore.rules deliberately does not + // shape-validate `email` on the admin write lane, so the shape reaches here unchecked. + if (!ADMIN_SDK_EMAIL_SHAPE.test(email)) { + throw provisionBlocked( + "failed-precondition", + "member's stored email is not a valid address; correct it before provisioning", + "member-email-malformed", + ); + } const linkedUid = typeof member.uid === "string" && member.uid.length > 0 ? member.uid : null; let user = await deps.getUserByEmail(email); @@ -170,10 +202,10 @@ export async function provisionMember( // orphaned; if it was deleted out-of-band, relinking by email is the // self-heal, not a conflict. if ((await deps.getUserByUid(linkedUid)) !== null) { - throw new HttpsError( + throw provisionBlocked( "failed-precondition", "member is already linked to a different login; unlink it explicitly before re-provisioning", - { reason: "linked-to-different-login" }, + "linked-to-different-login", ); } } @@ -204,10 +236,10 @@ export async function provisionMember( // member has neither — and leaves resend/adoption/self-heal to an Admin, plus the // client-side sendPasswordResetEmail any member can already use on their own address. if (!callerHoldsAdminRole && (user !== null || linkedUid !== null)) { - throw new HttpsError( + throw provisionBlocked( "permission-denied", "this member already has a login; only an Admin can re-provision or link one", - { reason: "reprovision-requires-admin" }, + "reprovision-requires-admin", ); } // POWER-SEAT GUARD. The check above asks whether this is a NEW login; it does not ask whose @@ -232,19 +264,19 @@ export async function provisionMember( if (!callerHoldsAdminRole) { // Direct grants first — no read required, and it is the half a cargo check cannot see. if (hasDirectGrants(member)) { - throw new HttpsError( + throw provisionBlocked( "permission-denied", "this member has been granted roles or permissions; only an Admin can provision their login", - { reason: "granted-member-requires-admin" }, + "granted-member-requires-admin", ); } for (const cargoId of readCargoIds(member)) { const grants = await deps.getPositionGrants(cargoId); if (grants === null || grants.length > 0) { - throw new HttpsError( + throw provisionBlocked( "permission-denied", "this member holds a cargo that confers permissions; only an Admin can provision their login", - { reason: "power-seat-requires-admin" }, + "power-seat-requires-admin", ); } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 732a293e..28f410e8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,6 +23,7 @@ export { permissionCodeSchema, type RoleDefinitionInput, } from "./role-definition-schema.js"; +export { PROVISION_BLOCK_REASONS, type ProvisionBlockReason } from "./provision-block-reason.js"; export type { Member, MemberStatus } from "./member.js"; export { MEMBER_STATUSES } from "./member.js"; export { MEMBER_GENDERS, type MemberGender } from "./member.js"; diff --git a/packages/types/src/provision-block-reason.test.ts b/packages/types/src/provision-block-reason.test.ts new file mode 100644 index 00000000..a22031dc --- /dev/null +++ b/packages/types/src/provision-block-reason.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { PROVISION_BLOCK_REASONS } from "./provision-block-reason.js"; + +describe("PROVISION_BLOCK_REASONS", () => { + it("carries no duplicate tag", () => { + // A duplicate is invisible to both consumers: the union collapses it, so backstage's + // message table stays exhaustive with one message missing and beacon still compiles. + expect(new Set(PROVISION_BLOCK_REASONS).size).toBe(PROVISION_BLOCK_REASONS.length); + }); + + it("keeps every tag a stable wire token", () => { + // These cross the callable boundary inside `details.reason` and are compared byte-for-byte + // by the client. Lowercase kebab with no spaces or punctuation keeps them safe to log, + // JSON round-trip and eyeball in an error payload. + for (const reason of PROVISION_BLOCK_REASONS) { + expect(reason).toMatch(/^[a-z]+(?:-[a-z]+)*$/); + } + }); +}); diff --git a/packages/types/src/provision-block-reason.ts b/packages/types/src/provision-block-reason.ts new file mode 100644 index 00000000..1e290495 --- /dev/null +++ b/packages/types/src/provision-block-reason.ts @@ -0,0 +1,33 @@ +/** Why `provisionMemberLogin` refused. Beacon carries the tag on the HttpsError's + * `details.reason`; backstage's `provisionErrorMessage` keys its operator-facing message + * table on it. + * + * Declared here because that is a cross-boundary contract with nothing else coupling its + * two ends: renaming a literal in beacon used to leave the client's table silently + * unmatched, degrading every refusal to the generic "no se pudo" fallback — which is the + * dead-end ("a transient error the operator retries forever") the table exists to remove. + * Both apps already depend on `@luminova/types`, so the union turns that rename into a + * compile error on the consumer instead. Same reasoning as the Codegen-drift gate in + * CLAUDE.md; a shared union is the cheap form of it. + * + * Beacon imports the TYPE only — the root barrel pulls zod, which the functions bundle has + * no other reason to carry. The runtime array exists so the client's message table can be + * proved exhaustive by iterating the contract rather than re-listing it. */ +export const PROVISION_BLOCK_REASONS = [ + // The member's stored uid resolves to a different live Auth account than their email does + // (an out-of-band email change). Relinking is a deliberate console op. + "linked-to-different-login", + // A non-Admin caller reached adoption or resend: an account already exists for this email, + // or the member is already linked. Both are Admin-only. + "reprovision-requires-admin", + // A non-Admin caller reached a member carrying direct grants (roleIds / permissionOverrides). + "granted-member-requires-admin", + // A non-Admin caller reached a member seated on a cargo that confers roles. + "power-seat-requires-admin", + // The member's stored email is not an address the Auth SDK will accept, so the call would + // fail as an opaque `internal` and that member would stay unprovisionable until someone + // edits the doc. `firestore.rules` does not shape-validate email on the admin write lane. + "member-email-malformed", +] as const; + +export type ProvisionBlockReason = (typeof PROVISION_BLOCK_REASONS)[number]; From 85a1a5d14ee5be26c4d630eb7c476bdb785e186a Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:48:52 -0400 Subject: [PATCH 06/25] test(rules): assert the client cargo mirror against the real rules engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assignable-cargo.ts is a hand-written mirror of positionsAssignmentSafe() and createPositionsSafe(). Its unit test is thorough but tests the mirror against a hand-written truth table and never reads firestore.rules — change the rules and every assertion stays green. That mirror drifted TWICE on this branch alone: the option list offered a CEL seat the rules deny, and the lock flag was keyed on the conjunct that got widened. Both were caught by review, not by a test. cargo-assignment-parity.test.ts asserts the implication instead: for every (principal, member fixture, cargo), if the editor would let it be submitted then the emulator must allow the write. 17 principals — all nine built-in roles derived from ROLES, never hand-listed, plus eight perms-only customs — against six member fixtures, driving both lanes with assignedBy self-stamped as the mapper does it. 160 offered triples, each a real write. An implication, not equality, so deliberate client strictness is never flagged. Both historical drifts mutation-tested: reverting either turns it red with a message naming the drifted file. Needed a module split — the rules-test package cannot load @luminova/types at runtime — so the pure predicates moved to assignable-cargo-core.ts with zero imports (a structural CargoLike rather than Pick). assignable-cargo.ts re-exports them and wraps the slot logic with the labelling it owns; no call site changed. It cannot see a call-site flag swap, since it recomputes the claims-to-props mapping rather than loading the React hook — that case stays with the component tests. An emulator-driven counterfactual pins the cost of getting that wiring wrong. Also restores the create-lane delegate ride-along test dropped earlier as a "strict subset" of its Admin twin. It is not one: an Admin's perms are manage:all, never the exact code, so a hasPerm('update:BoardSeat') disjunct grafted onto the term conjunct leaves the Admin test denied while reopening the ride-along for the perms-only principal this feature introduces. Both mutations verified. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/lib/assignable-cargo-core.ts | 181 ++++++++ .../members/lib/assignable-cargo.test.ts | 228 +++++++++- .../features/members/lib/assignable-cargo.ts | 184 +++----- .../cargo-assignment-parity.test.ts | 423 ++++++++++++++++++ tests/firestore-rules/rules.test.ts | 38 +- 5 files changed, 913 insertions(+), 141 deletions(-) create mode 100644 apps/backstage/src/features/members/lib/assignable-cargo-core.ts create mode 100644 tests/firestore-rules/cargo-assignment-parity.test.ts diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts new file mode 100644 index 00000000..930d3d9b --- /dev/null +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -0,0 +1,181 @@ +/** + * The rules-mirroring half of `assignable-cargo.ts`: every predicate that answers a + * `firestore.rules` question about a cargo, and nothing that renders one. + * + * Split out for ONE reason, and it is load-bearing: `tests/firestore-rules/` drives these + * predicates and the real emulator from the same fixture + * (`cargo-assignment-parity.test.ts`), and that package cannot resolve `@luminova/types` at + * runtime — it has no such dependency, and the rules suite deliberately keeps its dependency + * surface to the emulator harness. `assignable-cargo.ts` imports `currentTermKey` and + * `positionTitle` from it for VALUE, so importing that file there throws. This module has ZERO + * imports, which is the same trick `nav-equivalence.test.ts` documents for `nav-config.ts`. + * + * Nothing changed shape: `assignable-cargo.ts` re-exports the two predicates it used to own + * (`positionsLockedForEditor`, `cargoTakedownOnly`) and wraps `cargoSlotsForEditor` back into + * `cargoOptionsForEditor`, so every call site and the existing unit test import exactly what + * they always did. What deliberately did NOT move: the render states + * (`noAssignableCargos`, `cargoNoteId`), the labelling itself, and + * `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust gate, not a rules predicate, + * so no rules parity test can hold it and it has no business here. + */ + +/** + * The cargo fields these predicates read — structural, deliberately NOT `Pick`, + * so this module stays import-free (see the header). Every real `Position` satisfies it, so + * callers pass their `Position` objects unchanged and `cargoSlotsForEditor` returns the very + * objects it was handed (it is generic in `P`), never a lossy copy. + */ +export interface CargoLike { + id: string; + grants: readonly string[]; + category: string; + term: number | null; + active: boolean; +} + +/** + * Client mirror of firestore.rules `cargoAssignableByNonAdmin()` — the two questions a + * non-Admin assignment must answer about the cargo being written in: + * grants.length === 0 the claims-mint boundary (assigning it would mint custom claims). + * category !== "CEL" the publication boundary. boardGroupFromCategory publishes CEL and + * JDL alike and boardRank puts 'Presidente' at rank 0, so a + * grant-free CEL cargo seats its holder at the head of the + * world-readable Directiva. JDL direcciones stay assignable — that + * is the accepted exposure this lane exists to deliver. + * + * The rules apply it on BOTH member lanes (`createPositionsSafe` and + * `positionsAssignmentSafe`), so both member forms apply it here. One function, not a + * `grants.length === 0` re-typed per form: rendering an option the save will 403 on is the + * render-then-die shape this repo guards against, and two copies drift. + */ +// Module-local: every consumer goes through cargoOptionsForEditor() / cargoTakedownOnly() / +// positionsLockedForEditor(), which is the point — a caller that re-derived the option list +// from this raw predicate is how the two forms drifted apart in the first place. Exporting it +// again would give that back. +function cargoAssignableByNonAdmin(cargo: Pick): boolean { + return cargo.grants.length === 0 && cargo.category !== "CEL"; +} + +/** The raw shape of the rules' OLD-side question, with no permission flag folded in. Every + * caller goes through `positionsLockedForEditor` / `cargoTakedownOnly` / + * `cargoGrantNeedsAdminAssigner`, which each fold in the flag the rules actually gate that + * side on — and those flags are NOT all the same one. + * + * Exported ONLY so `cargoGrantNeedsAdminAssigner` can keep living next to the render states + * in `assignable-cargo.ts` (it mirrors beacon's trust gate, not a rules predicate, so it has + * no business here). Do not derive an option list from it: a call site that re-derives + * "assignable" from the raw predicates is how the two member forms drifted apart. */ +export function cargoConfersPower(cargo: Pick | undefined): boolean { + return cargo !== undefined && cargo.grants.length > 0; +} + +/** + * Whether this editor is barred from touching the positions slot AT ALL, given the cargo the + * member currently holds. NOT the negation of `cargoAssignableByNonAdmin` — the two rules + * conjuncts are asymmetric, and mirroring the wrong one strands a takedown: + * + * grants.length > 0 → locked. `currentCargoGrantsEmpty()` gates the cargo being REPLACED, + * so the editor can neither keep it (the save re-stamps it) nor clear + * it. Nothing they can do here succeeds. + * grant-free CEL → NOT locked. `currentCargoGrantsEmpty()` is deliberately not + * category-gated — firestore.rules says denying this "would strand a + * takedown behind an Admin" — so clearing the seat is allowed even + * though keeping it is not. The cargo is dropped from the options + * instead, which makes the only submittable states "clear" or "some + * other assignable cargo" — exactly the rules' answer. + * + * `allowReplacePowerCargo` is a PARAMETER, and it is deliberately not the same flag as + * `allowPowerGrants`. positionsAssignmentSafe() gates its two cargo conjuncts on different + * principals, and this function mirrors the OLD one: + * + * NEW side (cargoAssignableByNonAdmin, the cargo written IN) → `allowPowerGrants`, + * which update:BoardSeat lifts. + * OLD side (currentCargoGrantsEmpty, the cargo REPLACED) → Admin ROLE only, never + * delegated. + * + * The flag is taken here rather than `&&`-ed at each call site because that is exactly how the + * two drifted: both forms typed `!allowPowerGrants && positionsLockedForNonAdmin(...)`, which + * was correct only while `allowPowerGrants` MEANT `isAdmin`. Widening it to include the + * delegate silently unlocked the editor for a write the rules always deny. + */ +export function positionsLockedForEditor( + cargo: Pick | undefined, + allowReplacePowerCargo: boolean, +): boolean { + return !allowReplacePowerCargo && cargoConfersPower(cargo); +} + +/** + * The takedown-only state: the member is seated on a cargo this editor may NOT keep but MAY + * clear — a grant-free CEL seat for a non-Admin. It is the one state where the honest render + * is neither "pick anything" nor "locked": + * - the seat must still be VISIBLE (the holder holds it), so it is offered as a disabled + * option and the Combobox trigger shows its title instead of the "Sin cargo" placeholder; + * - the seat must not be re-submittable (the rules 403 any positions write that keeps it); + * - clearing it must be reachable, which a disabled option cannot do on its own — Combobox + * clears by re-selecting the SELECTED option, and a disabled item swallows the select. So + * the forms render an explicit "Quitar cargo" action while this is true. + * Takes the CURRENT selection, not the stored one: once cleared or switched away the state is + * over, and the takedown affordance goes with it. + */ +export function cargoTakedownOnly( + cargo: Pick | undefined, + allowPowerGrants: boolean, +): boolean { + return ( + !allowPowerGrants && + cargo !== undefined && + !cargoAssignableByNonAdmin(cargo) && + !cargoConfersPower(cargo) + ); +} + +/** One row of the cargo Combobox, before labelling. `retired` and `disabled` are the two + * things the label/render layer needs and the predicates above decide. */ +export interface CargoSlot

{ + position: P; + /** Not assignable any more: deactivated, or belonging to a past term. Labelled "(inactivo)". */ + retired: boolean; + /** The rules would 403 a write keeping this cargo — shown for truth, not submittable. */ + disabled: boolean; +} + +/** + * WHICH cargos this editor may pick, and which of them are shown-but-denied. The whole of + * `cargoOptionsForEditor` except the labels, so the parity test can drive the real rules + * predicate without `positionTitle` (see the header). + * + * Assignable cargos of `term`, plus the HELD cargo when it is not among them — appended + * `disabled` when this editor may not assign it, so the trigger tells the truth about who holds + * what without making a denied write one click away. Keyed on the stored assignment, never the + * live selection: an option list that reacts to the selection deletes the entry the user just + * switched away from, so switching back is impossible. + */ +export function cargoSlotsForEditor

({ + positions, + allowPowerGrants, + assignedCargoId, + term, +}: { + positions: readonly P[]; + allowPowerGrants: boolean; + assignedCargoId: string | null | undefined; + term: string; +}): CargoSlot

[] { + const currentTerm = (p: P) => p.term === null || String(p.term) === term; + const slots = positions + .filter((p) => p.active && p.category !== "Comision" && currentTerm(p)) + .filter((p) => allowPowerGrants || cargoAssignableByNonAdmin(p)) + .map((p) => ({ position: p, retired: false, disabled: false })); + + const held = assignedCargoId ? positions.find((p) => p.id === assignedCargoId) : undefined; + if (held === undefined || slots.some((s) => s.position.id === held.id)) return slots; + return [ + ...slots, + { + position: held, + retired: !held.active || !currentTerm(held), + disabled: !allowPowerGrants && !cargoAssignableByNonAdmin(held), + }, + ]; +} diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts index 908f9c34..3183810a 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Position } from "@luminova/types"; import { cargoGrantNeedsAdminAssigner, + cargoNoteId, cargoOptionsForEditor, cargoTakedownOnly, noAssignableCargos, @@ -127,30 +128,225 @@ describe("noAssignableCargos", () => { }); // The one outcome in this lane that fails SILENTLY. boardSeatDelegate() lets a delegate write -// an Admin-granting cargo and the write succeeds — but resolveTrustedGrants honors an -// Admin-conferring cargo only for an Admin-ROLE assigner, so the seat publishes and mints -// nothing. Keyed on the same flag as positionsLockedForEditor (the Admin role), NOT on -// allowPowerGrants, for the same reason: update:BoardSeat does not lift it. +// a vacant power cargo and the write succeeds — but resolveTrustedGrants refuses to mint on +// EITHER of two disjoint conditions, so the seat publishes and nothing says the claim is +// missing. Keyed on the Admin ROLE (beacon's `assignerIsAdmin`), NOT on allowPowerGrants: the +// perm lifts the WRITE, never the MINT. describe("cargoGrantNeedsAdminAssigner", () => { const ADMIN_CARGO = cargo("CEL", ["Admin"]); - it("warns a non-Admin assigning an Admin-granting cargo", () => { - expect(cargoGrantNeedsAdminAssigner(ADMIN_CARGO, false)).toBe(true); + // BLOCKING: the full truth table of all three arguments, because the predicate previously + // mirrored only ONE of resolveTrustedGrants' two refusals: + // + // trusted = (grants.includes("Admin") || selfAssigned) ? assignerIsAdmin + // : assignerIsAdmin || hasBoardSeatPerm + // + // Enumerated rather than sampled: the missing term (`selfAssigned`) sat in exactly two cells + // of this table, both of them non-Admin + non-Admin-granting, which is the shape every + // pre-existing case here already asserted as SILENT. A sampled suite therefore agreed with + // the bug. `expected` is derived from nothing — it is transcribed from beacon. + const TABLE: { + name: string; + cargo: Pick | undefined; + assignerIsAdmin: boolean; + isSelfAssignment: boolean; + expected: boolean; + }[] = [ + // No cargo at all: nothing to mint, nothing to warn about, either principal, either way. + { + name: "none", + cargo: undefined, + assignerIsAdmin: false, + isSelfAssignment: false, + expected: false, + }, + { + name: "none", + cargo: undefined, + assignerIsAdmin: false, + isSelfAssignment: true, + expected: false, + }, + { + name: "none", + cargo: undefined, + assignerIsAdmin: true, + isSelfAssignment: false, + expected: false, + }, + { + name: "none", + cargo: undefined, + assignerIsAdmin: true, + isSelfAssignment: true, + expected: false, + }, + // Grant-free: resolveTrustedGrants returns [] on `grants.length === 0` BEFORE it reads + // either flag, so there is no pending mint to warn about even when self-assigning. + { + name: "grant-free CEL", + cargo: CEL_FREE, + assignerIsAdmin: false, + isSelfAssignment: false, + expected: false, + }, + { + name: "grant-free CEL", + cargo: CEL_FREE, + assignerIsAdmin: false, + isSelfAssignment: true, + expected: false, + }, + { + name: "grant-free CEL", + cargo: CEL_FREE, + assignerIsAdmin: true, + isSelfAssignment: false, + expected: false, + }, + { + name: "grant-free CEL", + cargo: CEL_FREE, + assignerIsAdmin: true, + isSelfAssignment: true, + expected: false, + }, + { + name: "grant-free JDL", + cargo: JDL_FREE, + assignerIsAdmin: false, + isSelfAssignment: false, + expected: false, + }, + { + name: "grant-free JDL", + cargo: JDL_FREE, + assignerIsAdmin: false, + isSelfAssignment: true, + expected: false, + }, + // Non-Admin-granting POWER cargo. The delegation's whole point is the first row: a + // Secretary-granting seat IS honored from an update:BoardSeat assigner, so warning there + // would be false. The second row is the finding — same cargo, same principal, and the mint + // silently stops the moment the target is the caller. + { + name: "Secretary-granting", + cargo: POWER, + assignerIsAdmin: false, + isSelfAssignment: false, + expected: false, + }, + { + name: "Secretary-granting", + cargo: POWER, + assignerIsAdmin: false, + isSelfAssignment: true, + expected: true, + }, + { + name: "Secretary-granting", + cargo: POWER, + assignerIsAdmin: true, + isSelfAssignment: false, + expected: false, + }, + // An Admin self-assigning mints normally — `assignerIsAdmin` satisfies BOTH arms — so the + // note must not fire. This is the cell that stops the fix from being "warn on any self". + { + name: "Secretary-granting", + cargo: POWER, + assignerIsAdmin: true, + isSelfAssignment: true, + expected: false, + }, + // Admin-granting: refused from a non-Admin whoever the target is. + { + name: "Admin-granting", + cargo: ADMIN_CARGO, + assignerIsAdmin: false, + isSelfAssignment: false, + expected: true, + }, + { + name: "Admin-granting", + cargo: ADMIN_CARGO, + assignerIsAdmin: false, + isSelfAssignment: true, + expected: true, + }, + { + name: "Admin-granting", + cargo: ADMIN_CARGO, + assignerIsAdmin: true, + isSelfAssignment: false, + expected: false, + }, + { + name: "Admin-granting", + cargo: ADMIN_CARGO, + assignerIsAdmin: true, + isSelfAssignment: true, + expected: false, + }, + ]; + + it.each(TABLE)( + "BLOCKING: $name, admin=$assignerIsAdmin, self=$isSelfAssignment → $expected", + ({ cargo: c, assignerIsAdmin, isSelfAssignment, expected }) => { + expect(cargoGrantNeedsAdminAssigner(c, assignerIsAdmin, isSelfAssignment)).toBe(expected); + }, + ); + + // The finding in one line, spelled out away from the table so a future reader meets the + // scenario and not just a row. A delegate holding update:Position + update:BoardSeat opens + // their OWN profile and seats themselves on a vacant Secretario: firestore.rules permits the + // write, the seat publishes to the Directiva, and resolveTrustedGrants mints nothing because + // `selfAssigned && !assignerIsAdmin`. No response carries that — the note is the only channel. + it("BLOCKING: a delegate seating THEMSELVES on a non-Admin power cargo mints nothing", () => { + expect(cargoGrantNeedsAdminAssigner(POWER, false, true)).toBe(true); + // Same delegate, same cargo, someone else's profile: honored, so silence is correct. + expect(cargoGrantNeedsAdminAssigner(POWER, false, false)).toBe(false); + }); +}); + +// The fourth derived render-state. Both forms hand-rolled this as a two-branch ternary that +// only knew `noCargos` and `locked`, so the takedown and mint-pending notes were rendered but +// never ASSOCIATED — a screen-reader user on the trigger met neither. +describe("cargoNoteId", () => { + const IDS = { + noCargos: "no-cargos", + locked: "locked", + takedown: "takedown", + mintPending: "mint", + }; + const NONE = { noCargos: false, locked: false, takedown: false, mintPending: false }; + + it("returns undefined when no note is rendered", () => { + expect(cargoNoteId(NONE, IDS)).toBeUndefined(); }); - it("stays silent for an Admin, who mints what they assign", () => { - expect(cargoGrantNeedsAdminAssigner(ADMIN_CARGO, true)).toBe(false); + it("returns each state's own id when it is the only one firing", () => { + expect(cargoNoteId({ ...NONE, noCargos: true }, IDS)).toBe(IDS.noCargos); + expect(cargoNoteId({ ...NONE, locked: true }, IDS)).toBe(IDS.locked); + expect(cargoNoteId({ ...NONE, takedown: true }, IDS)).toBe(IDS.takedown); + expect(cargoNoteId({ ...NONE, mintPending: true }, IDS)).toBe(IDS.mintPending); }); - it("stays silent for a cargo whose grants a delegate DOES mint", () => { - // The delegation's whole point: a Secretary-granting seat is honored from an - // update:BoardSeat assigner, so warning about it would be false. - expect(cargoGrantNeedsAdminAssigner(POWER, false)).toBe(false); - expect(cargoGrantNeedsAdminAssigner(CEL_FREE, false)).toBe(false); - expect(cargoGrantNeedsAdminAssigner(JDL_FREE, false)).toBe(false); + // BLOCKING: priority is most-blocking-first, and it is only observable when states co-fire. + // Asserting one state at a time would pass under ANY ordering of the four ifs. + it("BLOCKING: resolves co-firing states most-blocking-first", () => { + const all = { noCargos: true, locked: true, takedown: true, mintPending: true }; + expect(cargoNoteId(all, IDS)).toBe(IDS.noCargos); + expect(cargoNoteId({ ...all, noCargos: false }, IDS)).toBe(IDS.locked); + expect(cargoNoteId({ ...all, noCargos: false, locked: false }, IDS)).toBe(IDS.takedown); + expect(cargoNoteId({ ...NONE, takedown: true, mintPending: true }, IDS)).toBe(IDS.takedown); }); - it("stays silent with no cargo selected", () => { - expect(cargoGrantNeedsAdminAssigner(undefined, false)).toBe(false); + // The ids are passed in, not owned here, because the locked and takedown wordings differ + // between the two forms. Pin that they are echoed verbatim — a hardcoded id here would send + // aria-describedby at an element that exists in only one of the two forms. + it("echoes the caller's ids rather than owning any", () => { + const other = { noCargos: "a", locked: "b", takedown: "c", mintPending: "d" }; + expect(cargoNoteId({ ...NONE, mintPending: true }, other)).toBe("d"); }); }); diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index ea42e2e2..82a708b1 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -1,112 +1,49 @@ import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types"; +import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core"; /** - * Client mirror of firestore.rules `cargoAssignableByNonAdmin()` — the two questions a - * non-Admin assignment must answer about the cargo being written in: - * grants.length === 0 the claims-mint boundary (assigning it would mint custom claims). - * category !== "CEL" the publication boundary. boardGroupFromCategory publishes CEL and - * JDL alike and boardRank puts 'Presidente' at rank 0, so a - * grant-free CEL cargo seats its holder at the head of the - * world-readable Directiva. JDL direcciones stay assignable — that - * is the accepted exposure this lane exists to deliver. + * The predicates that MIRROR firestore.rules — `positionsLockedForEditor`, + * `cargoTakedownOnly` and the option ceiling — live in `./assignable-cargo-core`, an + * import-free module, so `tests/firestore-rules/cargo-assignment-parity.test.ts` can drive + * them and the real emulator from one fixture. That package cannot resolve `@luminova/types`, + * which this file needs for VALUE (`currentTermKey`, `positionTitle`), so a test importing + * THIS module would throw at load. Same trick `nav-equivalence.test.ts` documents for + * `nav-config.ts`. * - * The rules apply it on BOTH member lanes (`createPositionsSafe` and - * `positionsAssignmentSafe`), so both member forms apply it here. One function, not a - * `grants.length === 0` re-typed per form: rendering an option the save will 403 on is the - * render-then-die shape this repo guards against, and two copies drift. + * They are re-exported here, so this module stays the single import site for every call site + * and for `assignable-cargo.test.ts` — nothing outside the two files knows about the split. + * What stayed: the render states (`noAssignableCargos`, `cargoNoteId`), the labelling half of + * `cargoOptionsForEditor`, and `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust + * gate, not a rules predicate, so the rules-parity module is the wrong home for it. */ -// Module-local: every consumer now goes through cargoOptionsForEditor() / -// cargoTakedownOnly() / positionsLockedForEditor(), which is the point — a caller that -// re-derived the option list from this raw predicate is how the two forms drifted apart in -// the first place. Exporting it again would give that back. -function cargoAssignableByNonAdmin(cargo: Pick): boolean { - return cargo.grants.length === 0 && cargo.category !== "CEL"; -} - -/** Module-local, like `cargoAssignableByNonAdmin`: the raw shape of the rules' OLD-side - * question, with no permission flag folded in. Every caller goes through - * `positionsLockedForEditor` / `cargoTakedownOnly`, which each fold in the flag the rules - * actually gate that side on — and those two flags are NOT the same one. */ -function cargoConfersPower(cargo: Pick | undefined): boolean { - return cargo !== undefined && cargo.grants.length > 0; -} +export { cargoTakedownOnly, positionsLockedForEditor } from "./assignable-cargo-core"; /** - * Whether this editor is barred from touching the positions slot AT ALL, given the cargo the - * member currently holds. NOT the negation of `cargoAssignableByNonAdmin` — the two rules - * conjuncts are asymmetric, and mirroring the wrong one strands a takedown: - * - * grants.length > 0 → locked. `currentCargoGrantsEmpty()` gates the cargo being REPLACED, - * so the editor can neither keep it (the save re-stamps it) nor clear - * it. Nothing they can do here succeeds. - * grant-free CEL → NOT locked. `currentCargoGrantsEmpty()` is deliberately not - * category-gated — firestore.rules says denying this "would strand a - * takedown behind an Admin" — so clearing the seat is allowed even - * though keeping it is not. The cargo is dropped from the options - * instead, which makes the only submittable states "clear" or "some - * other assignable cargo" — exactly the rules' answer. - * - * `allowReplacePowerCargo` is a PARAMETER, and it is deliberately not the same flag as - * `allowPowerGrants`. positionsAssignmentSafe() gates its two cargo conjuncts on different - * principals, and this function mirrors the OLD one: + * A seat the editor may WRITE but whose grants will not be MINTED — the one outcome in this + * lane that fails silently. The rules allow the write, the seat publishes to the Directiva, and + * `syncMemberClaims` is a trigger, so there is no response the client could learn this from. + * Warning before the click is the only channel. * - * NEW side (cargoAssignableByNonAdmin, the cargo written IN) → `allowPowerGrants`, - * which update:BoardSeat lifts. - * OLD side (currentCargoGrantsEmpty, the cargo REPLACED) → Admin ROLE only, never - * delegated. + * Mirrors BOTH refusals in `resolveTrustedGrants`, which are disjoint and were separately + * argued for — covering only the first is how this note would go quietly half-right: + * grants include Admin → honored only for an assigner holding the Admin ROLE. + * SELF-assignment → honored only for an Admin, WHATEVER the cargo grants. A delegate + * may confer power on others, never on themselves. * - * The flag is taken here rather than `&&`-ed at each call site because that is exactly how the - * two drifted: both forms typed `!allowPowerGrants && positionsLockedForNonAdmin(...)`, which - * was correct only while `allowPowerGrants` MEANT `isAdmin`. Widening it to include the - * delegate silently unlocked the editor for a write the rules always deny. - */ -export function positionsLockedForEditor( - cargo: Pick | undefined, - allowReplacePowerCargo: boolean, -): boolean { - return !allowReplacePowerCargo && cargoConfersPower(cargo); -} - -/** - * The takedown-only state: the member is seated on a cargo this editor may NOT keep but MAY - * clear — a grant-free CEL seat for a non-Admin. It is the one state where the honest render - * is neither "pick anything" nor "locked": - * - the seat must still be VISIBLE (the holder holds it), so it is offered as a disabled - * option and the Combobox trigger shows its title instead of the "Sin cargo" placeholder; - * - the seat must not be re-submittable (the rules 403 any positions write that keeps it); - * - clearing it must be reachable, which a disabled option cannot do on its own — Combobox - * clears by re-selecting the SELECTED option, and a disabled item swallows the select. So - * the forms render an explicit "Quitar cargo" action while this is true. - * Takes the CURRENT selection, not the stored one: once cleared or switched away the state is - * over, and the takedown affordance goes with it. - */ -export function cargoTakedownOnly( - cargo: Pick | undefined, - allowPowerGrants: boolean, -): boolean { - return ( - !allowPowerGrants && - cargo !== undefined && - !cargoAssignableByNonAdmin(cargo) && - !cargoConfersPower(cargo) - ); -} - -/** - * A seat the editor may WRITE but whose grants will not be MINTED — the one outcome in this - * lane that fails silently. + * `assignerIsAdmin` is the MINTING authority (beacon's `assignerIsAdmin`), deliberately named + * after that rather than after `allowReplacePowerCargo`, which mirrors a different predicate + * (`currentCargoGrantsEmpty`) and merely happens to equal it today. Feeding one to the other is + * the conflation this file exists to prevent. * - * `boardSeatDelegate()` lets an `update:BoardSeat` holder write any vacant cargo, Admin-granting - * included, and the write succeeds; the seat even publishes to the Directiva. But - * `resolveTrustedGrants` honors an Admin-conferring cargo only for an assigner holding the Admin - * ROLE, so the member is seated with no Admin claim, and nothing in the save path says so. An - * Admin re-saving the same slot re-stamps `assignedBy` and completes it. + * An Admin re-saving the same slot re-stamps `assignedBy` and completes the mint. */ export function cargoGrantNeedsAdminAssigner( cargo: Pick | undefined, assignerIsAdmin: boolean, + isSelfAssignment: boolean, ): boolean { - return !assignerIsAdmin && cargo !== undefined && cargo.grants.includes("Admin"); + if (assignerIsAdmin || !cargoConfersPower(cargo)) return false; + return isSelfAssignment || (cargo?.grants.includes("Admin") ?? false); } export type CargoOption = { value: string; label: string; disabled?: boolean }; @@ -129,17 +66,40 @@ export function noAssignableCargos(input: { return !input.locked && !input.allowPowerGrants && input.cargoOptions.length === 0; } +/** + * Which note the cargo Combobox's `aria-describedby` should point at — the fourth derived + * render-state, and here for the same reason as the other three: both forms ask it, and a + * re-typed ternary at each call site is how they drift. + * + * Order is priority, and the states are NOT all mutually exclusive — `takedown` and + * `mintPending` can co-fire with nothing else, but a `locked` slot always has its held cargo in + * the option list, so `noCargos` and `locked` cannot. First match wins, most-blocking first: a + * note about not being able to pick anything outranks one about what a pick would mint. + * + * The ids differ per form (the locked and takedown wordings legitimately differ between them), + * so they are passed in rather than owned here. + */ +export function cargoNoteId( + state: { noCargos: boolean; locked: boolean; takedown: boolean; mintPending: boolean }, + ids: { noCargos: string; locked: string; takedown: string; mintPending: string }, +): string | undefined { + if (state.noCargos) return ids.noCargos; + if (state.locked) return ids.locked; + if (state.takedown) return ids.takedown; + if (state.mintPending) return ids.mintPending; + return undefined; +} + /** * The cargo Combobox options for one editor, shared by both member forms so they cannot * disagree about the same rules predicate (they did: one dropped the held CEL seat, the other * re-added it labelled "(inactivo)" — an ACTIVE cargo, mislabelled and re-offered to the very * editor whose write the rules reject). * - * Assignable cargos of the current term, plus the HELD cargo when it is not among them — - * appended `disabled` when this editor may not assign it, so the trigger tells the truth about - * who holds what without making a denied write one click away. Keyed on the stored assignment, - * never the live selection: an option list that reacts to the selection deletes the entry the - * user just switched away from, so switching back is impossible. + * WHICH cargos, and which of them are shown-but-denied, is `cargoSlotsForEditor` + * (./assignable-cargo-core) — the rules mirror, held to the emulator by the parity test. This + * function is the labelling layer over it: gendered titles, plus the "(inactivo)" suffix on a + * held cargo that is deactivated or belongs to a past term. */ export function cargoOptionsForEditor({ positions, @@ -154,21 +114,13 @@ export function cargoOptionsForEditor({ assignedCargoId: string | null | undefined; term?: string; }): CargoOption[] { - const currentTerm = (p: Position) => p.term === null || String(p.term) === term; - const options = positions - .filter((p) => p.active && p.category !== "Comision" && currentTerm(p)) - .filter((p) => allowPowerGrants || cargoAssignableByNonAdmin(p)) - .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); - - const held = assignedCargoId ? positions.find((p) => p.id === assignedCargoId) : undefined; - if (held === undefined || options.some((o) => o.value === held.id)) return options; - const retired = !held.active || !currentTerm(held); - return [ - ...options, - { - value: held.id, - label: retired ? `${positionTitle(held, gender)} (inactivo)` : positionTitle(held, gender), - disabled: !allowPowerGrants && !cargoAssignableByNonAdmin(held), - }, - ]; + return cargoSlotsForEditor({ positions, allowPowerGrants, assignedCargoId, term }).map( + ({ position, retired, disabled }) => ({ + value: position.id, + label: retired + ? `${positionTitle(position, gender)} (inactivo)` + : positionTitle(position, gender), + ...(disabled ? { disabled } : {}), + }), + ); } diff --git a/tests/firestore-rules/cargo-assignment-parity.test.ts b/tests/firestore-rules/cargo-assignment-parity.test.ts new file mode 100644 index 00000000..f4366a27 --- /dev/null +++ b/tests/firestore-rules/cargo-assignment-parity.test.ts @@ -0,0 +1,423 @@ +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + assertFails, + assertSucceeds, + initializeTestEnvironment, + type RulesTestEnvironment, +} from "@firebase/rules-unit-testing"; +import { doc, setDoc, updateDoc, type Firestore } from "firebase/firestore"; +import { buildAbility, type Action, type Subject } from "@luminova/auth/ability"; +import { hasAnyRole, hasPerm, ROLES, type AuthClaims } from "@luminova/auth/roles"; +import { permsForRoles } from "../../tools/scripts/lib/role-seed.mjs"; +// The SAME modules the two member forms render from. `assignable-cargo-core` is import-free +// and `member-edit-gate` / `probe` pull only `@luminova/auth`, so all three load here — +// `assignable-cargo.ts` itself does NOT (it needs `@luminova/types` for value, which this +// package cannot resolve), which is exactly why the predicates were split out. Same shape, +// and same reason, as `nav-equivalence.test.ts` importing `nav-config`. +import { + cargoSlotsForEditor, + cargoTakedownOnly, + positionsLockedForEditor, + type CargoLike, +} from "../../apps/backstage/src/features/members/lib/assignable-cargo-core"; +import { memberEditMode } from "../../apps/backstage/src/features/members/lib/member-edit-gate"; +import { abilityAllows } from "../../apps/backstage/src/lib/authz/probe"; + +// The contract: for every (principal, member fixture, cargo) triple, IF the backstage cargo +// editor would let this editor submit this cargo for this member, THEN the real rules engine +// must allow the corresponding members write — on both lanes, `positionsAssignmentSafe()` on +// update and `createPositionsSafe()` on create, with `assignedBy` self-stamped exactly as +// `member-repository.ts`'s `setPositions` does it. +// +// One principal fixture drives both sides: the claims the emulator context receives are the +// claims the client gates are built from, so an option list that admits a write the rules +// reject fails HERE rather than as a PERMISSION_DENIED toast in production. That has already +// happened twice on this branch — `cargoOptionsForEditor` offering a CEL cargo the rules deny, +// and `positionsLockedForEditor`'s flag being widened until the editor unlocked for a write +// the rules ALWAYS deny — and `assignable-cargo.test.ts`, a hand-written truth table that never +// reads `firestore.rules`, stays green through both. +// +// This is an IMPLICATION, not equality. The client is deliberately stricter in places (comisión +// cargos are filtered out of the picker though the rules would take a grant-free one; a member +// editor may clear a seat the rules would also let them keep), and flagging that curation would +// make the test an obstacle to it. It verifies nothing about the rules' OTHER conjuncts — +// `memberWriteInvariants`, the self lane, the takedown arm — which remain `rules.test.ts`'s. +// +// Per `docs/engineering-guardrails.md` this is the "parity test, never a hand-maintained +// mirror list" half only. The parser half is deliberately NOT built: these are BOOLEAN +// PREDICATES over `get()` fan-out and `resource` vs `request.resource`, which no shared TS +// description expresses — the emulator IS the parser. + +const PROJECT_ID = "demo-cargo-parity"; +let env: RulesTestEnvironment; + +// Rules derive the term from request.time.year() (UTC); compute it from the client clock so +// this suite cannot rot when the calendar year rolls over. +const TERM = String(new Date().getUTCFullYear()); +const PRIOR_TERM = Number(TERM) - 1; +/** Whoever seated the fixture member before the test's principal touches it. */ +const SEEDER_UID = "seed-admin-uid"; + +interface Principal { + label: string; + uid: string; + roles: string[]; + perms: string[]; +} +const canonical = (role: string): Principal => ({ + label: role, + uid: `${role.toLowerCase()}-uid`, + roles: [role], + perms: permsForRoles([role]), +}); +const custom = (label: string, perms: string[]): Principal => ({ + label: `custom(${label})`, + uid: `${label}-uid`, + roles: [], + perms, +}); + +const PRINCIPALS: Principal[] = [ + // Derived from ROLES, never hand-listed: a new built-in role is held to the implication from + // the moment it exists, rather than when someone remembers to add it here. + ...ROLES.map((role) => canonical(role)), + // Perms-only customs — the principals owner-op 1 describes, and the ones the delegation + // feature is actually about. Each names the exact capability the lane keys on: + custom("update-Member", ["update:Member"]), // institutional lane, no delegation + custom("update-Position", ["update:Position"]), // members-positions lane, ceiling = grant-free JDL + custom("update-BoardSeat", ["update:BoardSeat"]), // NON-VACUITY: the code widens which cargo an + // editor may seat, it does not make anyone an editor — this principal must reach no lane at all. + custom("position+boardseat", ["update:Position", "update:BoardSeat"]), // the spec's recommended pairing + custom("member+boardseat", ["update:Member", "update:BoardSeat"]), + custom("create-Member", ["create:Member"]), + custom("create+boardseat", ["create:Member", "update:BoardSeat"]), + // Escalation probe: `manage:all` satisfies the rules' canDo() but is NOT the exact code + // boardSeatDelegate()/canAssignBoardSeat ask for, so it must never be offered a CEL seat. + custom("manage-all", ["manage:all"]), +]; + +/** Bridge the seed producer's plain string arrays (role-seed.mjs) to the typed claim. */ +const claimsOf = (p: Principal): AuthClaims => + ({ roles: p.roles, perms: p.perms }) as unknown as AuthClaims; + +function as(p: Principal): Firestore { + return env.authenticatedContext(p.uid, { roles: p.roles, perms: p.perms }).firestore(); +} + +interface Gates { + /** Which member editor the profile page mounts (features/members/lib/member-edit-gate). */ + editMode: "full" | "positions" | "none"; + /** Whether the invite drawer's "Invitar miembro" affordance exists (``). */ + canCreate: boolean; + /** `Can.isAdmin` — the OLD-side flag (`allowReplacePowerCargo`), never delegated. */ + isAdmin: boolean; + /** `Can.canAssignBoardSeat` — the NEW-side flag (`allowPowerGrants`), which update:BoardSeat lifts. */ + allowPowerGrants: boolean; +} + +/** The claims → form-props mapping the four call sites make (`member-profile-page.tsx`, + * `member-drawer.tsx`, `member-invite-drawer.tsx`): `allowPowerGrants={canAssignBoardSeat}`, + * `allowReplacePowerCargo={isAdmin}`. `adminOrPerm` is re-derived from the same `hasAnyRole` / + * `hasPerm` primitives `buildCan` uses rather than imported, because `use-can.ts` is a React + * module this package cannot load; `member-profile-page.test.tsx` is what pins the props to + * those two flags. `hasPerm`, never the ability, is the point — `manage:all` must not answer a + * gate the rules key on an exact code. */ +function gatesFor(p: Principal): Gates { + const claims = claimsOf(p); + const ability = buildAbility(claims, p.uid); + const can = (action: Action, subject: Subject) => abilityAllows(ability, action, subject); + return { + editMode: memberEditMode({ can }), + canCreate: can("create", "Member"), + isAdmin: hasAnyRole(claims, ["Admin"]), + allowPowerGrants: hasAnyRole(claims, ["Admin"]) || hasPerm(claims, "update:BoardSeat"), + }; +} + +interface Cargo extends CargoLike { + title: string; + description: string; + deletedAt: null; +} +const cargo = ( + id: string, + category: string, + grants: string[], + extra: Partial = {}, +): Cargo => ({ + id, + title: id, + description: "", + category, + grants, + term: category === "JDL" ? Number(TERM) : null, + active: true, + deletedAt: null, + ...extra, +}); + +/** The positions catalog, seeded into the emulator AND handed to `cargoSlotsForEditor` — one + * fixture, both sides, so the option list and the `get()` the rules perform can never describe + * different cargos. Spans every axis the two rules conjuncts turn on: grants vs none, CEL vs + * JDL (the publication boundary), plus the three the CLIENT filters on its own (comisión, + * deactivated, past term) so the implication is exercised where the client is stricter. */ +const CATALOG: Cargo[] = [ + cargo("jdl_free", "JDL", []), + cargo("jdl_free_alt", "JDL", []), + cargo("cel_free", "CEL", []), // grant-free CEL: the deliberate takedown asymmetry + cargo("cel_power", "CEL", ["Admin"]), // Presidente-shaped: publishes at board rank 0 AND mints + cargo("jdl_power", "JDL", ["Secretary"]), + cargo("comision_free", "Comision", []), + cargo("jdl_retired", "JDL", [], { term: PRIOR_TERM }), + cargo("jdl_inactive", "JDL", [], { active: false }), +]; +const cargoById = (id: string | null) => CATALOG.find((c) => c.id === id); + +interface MemberFixture { + key: string; + /** The cargo this member already holds in the CURRENT term — the OLD side of the swap. */ + cargoId: string | null; +} +/** The states `currentCargoGrantsEmpty()` (the cargo being REPLACED) turns on. */ +const MEMBER_FIXTURES: MemberFixture[] = [ + { key: "unseated", cargoId: null }, + { key: "seated-jdl-free", cargoId: "jdl_free" }, + { key: "seated-cel-free", cargoId: "cel_free" }, // keeping it is denied, CLEARING it is not + { key: "seated-jdl-power", cargoId: "jdl_power" }, + { key: "seated-cel-power", cargoId: "cel_power" }, + { key: "seated-retired", cargoId: "jdl_retired" }, // held cargo outside the option list +]; + +/** What the cargo Combobox would let this editor SUBMIT for this member: every enabled option, + * plus `null` — clearing the slot, which the forms always offer while the slot is unlocked and + * which `cargoTakedownOnly` makes an explicit "Quitar cargo" action. A locked slot submits + * nothing at all. */ +function offeredCargoIds(g: Gates, assignedCargoId: string | null): (string | null)[] { + const held = cargoById(assignedCargoId); + if (positionsLockedForEditor(held, g.isAdmin)) return []; + const enabled = cargoSlotsForEditor({ + positions: CATALOG, + allowPowerGrants: g.allowPowerGrants, + assignedCargoId, + term: TERM, + }) + .filter((slot) => !slot.disabled) + .map((slot) => slot.position.id); + return [null, ...enabled]; +} + +type Lane = "update" | "create"; +interface Triple { + principal: Principal; + lane: Lane; + fixture: MemberFixture; + cargoId: string | null; +} + +const NEW_MEMBER: MemberFixture = { key: "new-member", cargoId: null }; + +/** Every triple the UI offers, materialized up front so the structural assertions below can + * interrogate the matrix itself — an implication suite is vacuously green when nothing is + * offered, so "what did we actually probe" has to be assertable. */ +const MATRIX: Triple[] = PRINCIPALS.flatMap((principal) => { + const g = gatesFor(principal); + const update: Triple[] = + g.editMode === "none" + ? [] + : MEMBER_FIXTURES.flatMap((fixture) => + offeredCargoIds(g, fixture.cargoId).map((cargoId) => ({ + principal, + lane: "update" as const, + fixture, + cargoId, + })), + ); + const create: Triple[] = g.canCreate + ? offeredCargoIds(g, null).map((cargoId) => ({ + principal, + lane: "create" as const, + fixture: NEW_MEMBER, + cargoId, + })) + : []; + return [...update, ...create]; +}); + +const MEMBER_DOC = { name: "Ana Lopez", totalPoints: 0, active: true, deletedAt: null }; + +/** The exact payload `MemberRepository.setPositions` writes: the current term only, by dot + * path, with `assignedBy` self-stamped. Accepted by BOTH update arms — the institutional + * `update:Member` one and the `update:Position` members-positions lane, which additionally + * requires `affectedKeys().hasOnly(['positions'])`. */ +function writeUpdate(db: Firestore, id: string, cargoId: string | null, uid: string) { + return updateDoc(doc(db, `members/${id}`), { + [`positions.${TERM}`]: { cargoId, comisionIds: [], assignedBy: uid }, + }); +} + +/** The create-lane twin: a member born on the cargo, self-stamped, in the current term only. */ +function writeCreate(db: Firestore, id: string, cargoId: string | null, uid: string) { + return setDoc(doc(db, `members/${id}`), { + ...MEMBER_DOC, + positions: { [TERM]: { cargoId, comisionIds: [], assignedBy: uid } }, + }); +} + +let docCounter = 0; +/** A fresh member doc per assertion. An ALLOWED write mutates the fixture it lands on, so a + * shared doc would make every later triple depend on the order the earlier ones ran in. */ +async function seedMember(fixture: MemberFixture): Promise { + const id = `parity_${fixture.key}_${docCounter++}`; + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), `members/${id}`), { + ...MEMBER_DOC, + ...(fixture.cargoId === null + ? {} + : { + positions: { + [TERM]: { cargoId: fixture.cargoId, comisionIds: [], assignedBy: SEEDER_UID }, + }, + }), + }); + }); + return id; +} + +const describeTriple = (t: Triple) => + `${t.principal.label} ${t.lane}s ${t.fixture.key} → cargo ${t.cargoId ?? "null (clear)"}`; + +async function assertRulesAllow(t: Triple): Promise { + const id = t.lane === "update" ? await seedMember(t.fixture) : `parity_created_${docCounter++}`; + const db = as(t.principal); + const write = + t.lane === "update" + ? writeUpdate(db, id, t.cargoId, t.principal.uid) + : writeCreate(db, id, t.cargoId, t.principal.uid); + try { + await assertSucceeds(write); + } catch (error) { + throw new Error( + `the cargo editor OFFERS "${describeTriple(t)}" but firestore.rules DENY that write — ` + + `the client mirror in assignable-cargo-core.ts has drifted from ` + + `positionsAssignmentSafe()/createPositionsSafe(). Cause: ${String(error)}`, + ); + } +} + +beforeAll(async () => { + const rulesPath = resolve(fileURLToPath(new URL("../../firestore.rules", import.meta.url))); + env = await initializeTestEnvironment({ + projectId: PROJECT_ID, + firestore: { + host: "127.0.0.1", + port: Number(process.env.FIRESTORE_EMULATOR_PORT ?? 4010), + rules: readFileSync(rulesPath, "utf8"), + }, + }); + await env.clearFirestore(); + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + // cargoAssignableByNonAdmin()/currentCargoGrantsEmpty() get() these docs; a missing one + // errors the rule (fail-closed), so the catalog must exist before any triple runs. + for (const c of CATALOG) await setDoc(doc(db, `positions/${c.id}`), c); + }); +}); + +afterAll(async () => { + await env.cleanup(); +}); + +describe("cargo assignment ⟷ rules: every OFFERED cargo is a write the emulator allows", () => { + // The implication is vacuously true wherever nothing is offered, so pin what the matrix + // actually probes. Each of these would have caught a predicate that regressed to offering + // nothing — which passes the implication while breaking the feature. + it("probes every member fixture, both lanes, and a cargo that is not just 'clear'", () => { + for (const fixture of MEMBER_FIXTURES) { + expect( + MATRIX.some((t) => t.fixture.key === fixture.key && t.cargoId !== null), + `no principal is offered any cargo for the ${fixture.key} fixture — it never probes`, + ).toBe(true); + } + for (const lane of ["update", "create"] as const) { + expect(MATRIX.some((t) => t.lane === lane && t.cargoId !== null)).toBe(true); + } + }); + + // The OLD-side conjunct is the Admin ROLE and nothing else — not the delegate, not manage:all + // (which is reachable as a perm without the role). Asserted over the matrix rather than left + // implicit in the absence of generated cases, so a widening shows up as a failure here and not + // as an extra green test nobody reads. + it("a power-cargo seat is submittable by the Admin role and by no one else", () => { + for (const key of ["seated-jdl-power", "seated-cel-power"]) { + const offered = MATRIX.filter((t) => t.fixture.key === key).map((t) => t.principal.label); + expect([...new Set(offered)]).toEqual(["Admin"]); + } + }); + + it("the delegation is live: only a board-seat delegate is offered CEL and power cargos", () => { + const celOffered = (label: string) => + MATRIX.some( + (t) => + t.principal.label === label && (t.cargoId === "cel_free" || t.cargoId === "cel_power"), + ); + expect(celOffered("custom(position+boardseat)")).toBe(true); + expect(celOffered("custom(create+boardseat)")).toBe(true); + expect(celOffered("Admin")).toBe(true); + // The ceiling the lane keeps for everyone else — including the manage:all escalation probe, + // which satisfies canDo() but not the exact code boardSeatDelegate() asks for. + expect(celOffered("custom(update-Position)")).toBe(false); + expect(celOffered("custom(update-Member)")).toBe(false); + expect(celOffered("custom(manage-all)")).toBe(false); + expect(celOffered("Membership")).toBe(false); + }); + + it("update:BoardSeat alone reaches no lane: the code widens a ceiling, it grants no entry", () => { + expect(MATRIX.some((t) => t.principal.label === "custom(update-BoardSeat)")).toBe(false); + }); + + for (const principal of PRINCIPALS) { + for (const lane of ["update", "create"] as const) { + for (const fixture of lane === "update" ? MEMBER_FIXTURES : [NEW_MEMBER]) { + const triples = MATRIX.filter( + (t) => t.principal === principal && t.lane === lane && t.fixture.key === fixture.key, + ); + if (triples.length === 0) continue; + const offered = triples.map((t) => t.cargoId ?? "null").join(", "); + it(`${principal.label} ${lane}s ${fixture.key}: offers [${offered}] ⟹ rules allow each`, async () => { + for (const triple of triples) await assertRulesAllow(triple); + }); + } + } + } + + // The takedown asymmetry, asserted as its own row because it is the one state where the + // client is stricter on one side and NOT the other: a non-delegate may not keep a grant-free + // CEL seat but must be able to clear it, so `null` has to be in the offered set (denying it + // would strand the takedown behind an Admin, which firestore.rules explicitly refuses to do). + it("a grant-free CEL seat is takedown-only for a non-delegate, and the takedown is offered", () => { + const g = gatesFor(PRINCIPALS.find((p) => p.label === "custom(update-Position)")!); + expect(cargoTakedownOnly(cargoById("cel_free"), g.allowPowerGrants)).toBe(true); + const offers = offeredCargoIds(g, "cel_free"); + expect(offers).toContain(null); + expect(offers).not.toContain("cel_free"); + }); + + // The counterfactual for drift #2, driven through the emulator rather than asserted about the + // client alone: feeding the NEW-side flag (`allowPowerGrants`, which update:BoardSeat lifts) + // into `positionsLockedForEditor` — whose conjunct, `currentCargoGrantsEmpty()`, is Admin-ROLE + // only — unlocks the slot for a delegate, and every write it would then submit is denied. This + // is what "the two flags are not the same one" costs when it is got wrong. + it("wiring allowPowerGrants into the OLD-side flag would offer writes the rules deny", async () => { + const delegate = PRINCIPALS.find((p) => p.label === "custom(position+boardseat)")!; + const g = gatesFor(delegate); + const held = cargoById("jdl_power"); + expect(positionsLockedForEditor(held, g.isAdmin)).toBe(true); + expect(positionsLockedForEditor(held, g.allowPowerGrants)).toBe(false); + const id = await seedMember({ key: "seated-jdl-power", cargoId: "jdl_power" }); + await assertFails(writeUpdate(as(delegate), id, "jdl_free", delegate.uid)); + await assertFails(writeUpdate(as(delegate), id, null, delegate.uid)); + }); +}); diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 100bc0de..7598706e 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -1162,15 +1162,35 @@ describe("firestore.rules — members", () => { ); }); - // The delegate variant of this same ride-along (createDelegate(), assignedBy stays self - // on the current-term entry) was here and is dropped: the term-key conjunct above is - // UNCONDITIONAL in createPositionsSafe() — not gated by boardSeatDelegate() — and Admin - // already satisfies boardSeatDelegate() (hasAnyRole(['Admin']) is one of its two arms), so - // any mutation that reddens the delegate variant (e.g. gating the conjunct behind - // `boardSeatDelegate() ||`) reddens this Admin one too, but not vice versa: a - // `hasAnyRole(['Admin']) ||` carve-out in front of the conjunct — the tempting future edit - // this test's own comment names — passes the dropped delegate variant untouched while this - // one still catches it. Strict subset; this is the stronger survivor. + it("BLOCKING: a delegate may NOT create with a ride-along NON-current term key", async () => { + // The create-lane twin of positionsDelta().hasOnly([currentTermKey()]). assignedBySelf() + // and cargoAssignableByNonAdmin() both read only positions[currentTermKey()], so a second + // term key rode along completely unvalidated: a clean current-term entry to pass the arm, + // plus a next-term power cargo attributed to a real Admin. On the UTC-year rollover + // claims-sync reads THAT entry and mints Admin onto a member whose login the creator + // controls. Forged attribution, one term deferred. + // + // NOT a strict subset of the Admin variant above — the two principals differ in a way + // that matters. admin-uid's synthesized perms are ['manage:all'] (tools/scripts/lib/ + // role-seed.mjs), never the literal string 'update:BoardSeat', so hasPerm('update:BoardSeat') + // is false for it; it only satisfies boardSeatDelegate() via the separate hasAnyRole(['Admin']) + // arm. createDelegate() genuinely holds ['create:Member','update:BoardSeat'], so + // hasPerm('update:BoardSeat') is true for it. A mutation that grafts a hasPerm disjunct onto + // the term-key conjunct itself — `hasPerm('update:BoardSeat') || keys().hasOnly([currentTermKey()])` + // in createPositionsSafe() — leaves the Admin test denied (hasPerm still false there) while + // reopening the ride-along for this delegate. Only this test catches that mutation. + await assertFails( + setDoc(doc(createDelegate(), "members/new_delegate_ridealong"), { + name: "Ximena Paz", + totalPoints: 0, + ...BORN_LIVE, + positions: { + [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "createdelegate-uid" }, + "2099": { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" }, + }, + }), + ); + }); it("BLOCKING: update:BoardSeat alone reaches nothing on the create lane", async () => { // The create-lane twin of the update-lane non-vacuity pin. update:BoardSeat widens WHICH From c354f8957a712d277a98b7ba74ff6a6a6f14404b Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 07:49:11 -0400 Subject: [PATCH 07/25] fix(backstage): mirror both mint refusals; close the invite state races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the audit's own fixes found the mint-pending note mirrored only ONE of resolveTrustedGrants' two refusals. It caught an Admin-granting cargo but not a SELF-assignment of any granting cargo, which is the recommended update:Position + update:BoardSeat pairing: a delegate could seat themselves on a Secretario cargo, publish to the Directiva, mint nothing, and be told nothing. Found independently by three reviewers after I wrote it. The predicate takes isSelfAssignment now, and the truth table is enumerated rather than sampled — the missing term lived in exactly two cells, both the shape every existing case already asserted silent, so a sampled suite agreed with the bug. The note itself, and the note-priority derivation both forms had re-typed, are now shared (MintPendingNote, cargoNoteId) — the same drift shape assignable-cargo exists to prevent. aria-describedby now covers all four picker states, not two. memberProvisionBlocked / draftProvisionBlocked take callerIsAdmin instead of three call sites typing `!isAdmin &&`. The function is named ForNonAdmin; the conjunct belongs inside it. They also no longer skip an EMPTY-STRING cargoId: beacon's readCargoIds pushes "" deliberately and then refuses it, so treating it as "no cargo" promised an invite and 403'd describing a cargo that does not exist. InviteAccess: the reset mail was an untracked floating promise, so the button re-enabled while it was in flight and two clicks could leave "enviada" and "no se pudo enviar" both rendered, or overwrite a real failure with a stale success. A sending flag and an attempt ref close it. `open` is gone — it was only ever set alongside `link`, and keeping them in sync by hand is what left `link` out of the reset. The mail failure is repeated inside the dialog because the modal's aria-hidden takes the header alert out of the accessibility tree. useCopyToClipboard is shared by both copy affordances and wraps the call in try/catch: navigator.clipboard is undefined outside a secure context, so the property access throws SYNCHRONOUSLY and the bare .catch() never ran — the failure fallback existed and could not render. The drawer's "invítalo desde el menú de su fila" now keys on blocked-ness alone: with the checkbox unticked a blocked delegate landed there, and the row action is hidden from them for the same reason, so it pointed at an affordance that is not present. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-drawer.test.tsx | 9 + .../members/components/member-drawer.tsx | 5 + .../members/components/member-form.test.tsx | 127 +++++++++++++ .../members/components/member-form.tsx | 55 ++++-- .../components/member-invite-drawer.test.tsx | 99 ++++++++++ .../components/member-invite-drawer.tsx | 32 ++-- .../components/member-positions-form.test.tsx | 153 ++++++++++++++- .../components/member-positions-form.tsx | 50 +++-- .../components/member-profile-page.test.tsx | 178 +++++++++++++++++- .../components/member-profile-page.tsx | 90 +++++---- .../components/member-row-menu.test.tsx | 24 ++- .../members/components/member-row-menu.tsx | 3 +- .../members/components/members-page.test.tsx | 8 + .../components/no-assignable-cargos-note.tsx | 14 ++ .../members/lib/provision-gate.test.ts | 106 +++++++++-- .../features/members/lib/provision-gate.ts | 28 ++- .../src/lib/use-copy-to-clipboard.test.ts | 86 +++++++++ .../src/lib/use-copy-to-clipboard.ts | 34 ++++ 18 files changed, 984 insertions(+), 117 deletions(-) create mode 100644 apps/backstage/src/lib/use-copy-to-clipboard.test.ts create mode 100644 apps/backstage/src/lib/use-copy-to-clipboard.ts diff --git a/apps/backstage/src/features/members/components/member-drawer.test.tsx b/apps/backstage/src/features/members/components/member-drawer.test.tsx index 46656f78..b6fa3685 100644 --- a/apps/backstage/src/features/members/components/member-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-drawer.test.tsx @@ -10,6 +10,15 @@ vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: React.ReactNode }) => {children}, })); +// The drawer now reads the caller's uid to decide whether the row it opened is the caller's +// OWN (the members table lists it too), which makes the edit a SELF-assignment. Mocked as a +// factory with no `importOriginal`: lib/auth/auth builds its store from getFirebase().auth at +// module scope, so merely evaluating the real module initializes Firebase and the whole file +// fails to collect. uid "someone-else" keeps every case below a non-self edit. +vi.mock("../../../lib/auth/auth", () => ({ + useAuth: () => ({ user: { uid: "someone-else" }, claims: { roles: ["Admin"] } }), +})); + const m: Member = { id: "1", name: "Ana Gómez", diff --git a/apps/backstage/src/features/members/components/member-drawer.tsx b/apps/backstage/src/features/members/components/member-drawer.tsx index 97447c65..7dc5c276 100644 --- a/apps/backstage/src/features/members/components/member-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-drawer.tsx @@ -15,6 +15,7 @@ import { joinYear, memberPositionLabel } from "../lib/member-display"; import { memberFormDefaults } from "../lib/member-form-defaults"; import { useMemberPhoto } from "../hooks/use-member-photo"; import { Can } from "../../../lib/authz/ability-context"; +import { useAuth } from "../../../lib/auth/auth"; import { useCan } from "../../../lib/authz/use-can"; interface MemberDrawerProps { @@ -137,6 +138,8 @@ function EditBody({ }) { const { onUpload, onRemove } = useMemberPhoto(member.id); const { canAssignBoardSeat, isAdmin } = useCan(); + // The table lists the caller's own row too, so this drawer can be a self-assignment. + const uid = useAuth().user?.uid; return (

diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index 042351ac..bcdd9720 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { MemberInput, Position } from "@luminova/types"; import { MemberForm } from "./member-form"; +import { MINT_PENDING_NOTE_ID } from "./no-assignable-cargos-note"; import { toMemberUpdateDoc } from "../repositories/member-mapper"; import { pickDate } from "../../../test/pick-date"; import { permissionLabel } from "../../permissions/lib/permission-matrix"; @@ -13,6 +14,12 @@ import { permissionLabel } from "../../permissions/lib/permission-matrix"; // coupling the note is worried about. const BOARD_SEAT_LABEL = permissionLabel("update:BoardSeat"); +// The mint-pending note used to say "permisos de administrador", which was true only of its +// one original trigger. It now fires for a SELF-assignment of any granting cargo, so copy +// naming administrator permissions would be a lie in that case. Matched on the outcome half of +// the sentence, which is the part both triggers share. +const MINT_PENDING_COPY = /no se aplicarán hasta que un administrador confirme la asignación/i; + const positions: Position[] = [ { id: "pos-pres", @@ -494,6 +501,126 @@ describe("MemberForm", () => { ).not.toBeInTheDocument(); }); + // ---- self-assignment: the second, disjoint refusal in resolveTrustedGrants ---- + // + // BLOCKING: the finding. A delegate holding update:Member + update:BoardSeat opens THEIR OWN + // profile and seats themselves on a vacant NON-Admin-granting power cargo. Every gate says + // yes — boardSeatDelegate() permits the write, the seat publishes to the Directiva, the save + // returns 200 — but `resolveTrustedGrants` computes `selfAssigned = assignedBy === memberUid` + // and honors it only for an Admin, so no claim is minted. syncMemberClaims is a background + // trigger, so no response carries the refusal; and while the warning keyed on + // `grants.includes("Admin")` alone, a Secretario seat rendered NO note whatsoever. + const pickSecretario = async () => { + await userEvent.click(screen.getByLabelText("Cargo")); + await userEvent.click(await screen.findByText("Secretario")); + }; + + it("BLOCKING: warns a delegate seating THEMSELVES on a non-Admin power cargo", async () => { + render( + , + ); + expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument(); + await pickSecretario(); + const note = screen.getByText(MINT_PENDING_COPY); + expect(note.id).toBe(MINT_PENDING_NOTE_ID); + // The note sits after the field in the DOM, so the association is the only way a + // screen-reader user on the trigger meets it before committing the save. + expect(screen.getByLabelText("Cargo")).toHaveAttribute( + "aria-describedby", + MINT_PENDING_NOTE_ID, + ); + // The copy must not name administrator permissions: this cargo grants Secretary. + expect(note).not.toHaveTextContent(/permisos de administrador/i); + }); + + it("BLOCKING: the SAME delegate on the SAME cargo for someone else stays silent", async () => { + // The control that makes the case above about self-assignment and nothing else. Identical + // props but `isSelfAssignment={false}`: update:BoardSeat DOES mint a Secretary seat for + // another member, so a note here would be false and would train users past the real one. + render( + , + ); + await pickSecretario(); + expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Cargo")).not.toHaveAttribute("aria-describedby"); + }); + + it("stays silent for an ADMIN seating themselves — they mint it", async () => { + // `assignerIsAdmin` satisfies both arms of the trust gate, so self-assignment is not a + // refusal for them. Without this cell the fix could be "warn on any self-assignment". + render( + , + ); + await pickSecretario(); + expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument(); + }); + + // Both new props default to false, which is what keeps the ~20 renders above (and the + // invite drawer, which passes only `assignerIsAdmin`) compiling. Pin the defaults: a + // required `isSelfAssignment` would be a breaking prop, but a default of TRUE would put the + // note on every create. + it("defaults both new props to false rather than warning by accident", async () => { + render( + , + ); + await pickSecretario(); + expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument(); + }); + + // BLOCKING: the takedown note now has an id and is the third arm of cargoNoteId(). While the + // association was a two-branch ternary over noCargos/locked, a takedown-only editor got + // `aria-describedby={undefined}`: the note rendered, sat AFTER the field in the DOM, and a + // screen-reader user reaching a trigger whose seat option is disabled met no reason at all. + it("BLOCKING: associates the takedown note with the trigger", () => { + render( + , + ); + const note = screen.getByText(/solo un administrador puede asignarlo/i); + expect(note.id).toBeTruthy(); + expect(screen.getByLabelText("Cargo")).toHaveAttribute("aria-describedby", note.id); + // Not the mint-pending id: a grant-free seat mints nothing to warn about, and the two + // notes' ids must not be interchangeable. + expect(note.id).not.toBe(MINT_PENDING_NOTE_ID); + }); + it("renders comisión option as 'sigla — title' when sigla is present", async () => { render( (null); @@ -138,15 +155,23 @@ export function MemberForm({ assignedCargoId, }); const noCargos = noAssignableCargos({ cargoOptions, allowPowerGrants, locked: positionsLocked }); - // The notes explaining the picker sit after the field in the DOM, so without this a + const mintPending = cargoGrantNeedsAdminAssigner( + selectedCargo, + assignerIsAdmin, + isSelfAssignment, + ); + // Every note explaining the picker sits after the field in the DOM, so without this a // screen-reader user reaching the trigger hears "Sin resultados" or a disabled control and - // never meets the reason. Mutually exclusive by construction — a locked slot renders the - // held cargo, so the option list is never empty. - const cargoNoteId = noCargos - ? NO_ASSIGNABLE_CARGOS_NOTE_ID - : positionsLocked - ? LOCKED_NOTE_ID - : undefined; + // never meets the reason. Priority order and the co-firing rules live in cargoNoteId(). + const describedBy = cargoNoteId( + { noCargos, locked: positionsLocked, takedown: cargoTakedown, mintPending }, + { + noCargos: NO_ASSIGNABLE_CARGOS_NOTE_ID, + locked: LOCKED_NOTE_ID, + takedown: TAKEDOWN_NOTE_ID, + mintPending: MINT_PENDING_NOTE_ID, + }, + ); const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title); const activeComisionOptions = positions @@ -268,7 +293,7 @@ export function MemberForm({ }} placeholder="Sin cargo" disabled={positionsLocked} - aria-describedby={cargoNoteId} + aria-describedby={describedBy} /> {cargoTakedown && ( {error && (

@@ -297,32 +311,36 @@ function InviteAccess({ member }: { member: Member }) { Invitación enviada por correo.

)} - + {/* `open` is not separate state: it was only ever set alongside `link`, and keeping the + two in sync by hand is what left `link` out of invite()'s reset. */} + { + if (!o) setLink(null); + }} + title="Acceso de miembro" + >
{/* The dialog opens as soon as the link arrives, before the reset mail settles, so its copy has to track that outcome. A fixed "ya le enviamos el correo" reads as a flat contradiction of the failure alert behind it — and worse, the modal's aria-hidden takes that alert out of the accessibility tree, so a screen-reader - user would hear ONLY the false sentence. */} + user would hear ONLY the false sentence. For the same reason the mail FAILURE is + repeated inside the dialog rather than left to the header alert. */}

{sent ? "Ya le enviamos el correo para crear su contraseña. Si no le llega, comparte este enlace con el miembro." : "Comparte este enlace con el miembro para que cree su contraseña e inicie sesión."}

+ {error && ( +

+ {error} +

+ )} {link} - {copyState === "failed" && ( diff --git a/apps/backstage/src/features/members/components/member-row-menu.test.tsx b/apps/backstage/src/features/members/components/member-row-menu.test.tsx index dbce1f65..9590ae7a 100644 --- a/apps/backstage/src/features/members/components/member-row-menu.test.tsx +++ b/apps/backstage/src/features/members/components/member-row-menu.test.tsx @@ -78,7 +78,7 @@ const DELEGATE: AuthClaims = { roles: ["Member"], perms: ["update:Member", "create:MemberLogin"], }; -const seated = (cargoId: string, term = "2026") => ({ +const seated = (cargoId: string | null, term = "2026") => ({ positions: { [term]: { cargoId, comisionIds: [] } }, }); @@ -241,10 +241,32 @@ describe("MemberRowMenu", () => { expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); }); + // BLOCKING: an EMPTY-STRING cargoId is a MALFORMED seat, not an empty one, and the gate used + // to drop it with a truthiness test — so this member looked unseated, the invite was offered, + // and the click 403'd with power-seat-requires-admin naming a cargo that does not exist. + // beacon's readCargoIds pushes "" on purpose ("a malformed shape must never read as 'no + // cargo' — that is the guard's own bypass") and refuses it at isSafeDocId; the client now + // lets it fall through to the unresolvable-cargo clause and fails closed the same way. + it("BLOCKING: hides it from a delegate for a member whose cargoId is an empty string", async () => { + renderMenu(member({ status: "Activo", ...seated("") }), DELEGATE); + await openMenu(); + expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument(); + }); + + // The paired negative, so the fix cannot be over-applied into "any falsy cargoId blocks": a + // null cargoId is a genuinely unseated term (readCargoIds `continue`s past it) and is the + // ordinary shape of most member docs. Blocking here would hide the invite chapter-wide. + it("shows it to a delegate for a member whose term has a NULL cargoId", async () => { + renderMenu(member({ status: "Activo", ...seated(null) }), DELEGATE); + await openMenu(); + expect(screen.getByText("Invitar a la app")).toBeInTheDocument(); + }); + it("still shows it to an Admin in every one of those cases", async () => { for (const m of [ member({ status: "Activo", uid: "u1" }), member({ status: "Activo", ...seated("pos-power") }), + member({ status: "Activo", ...seated("") }), member({ status: "Activo", roleIds: ["custom-role"] }), ]) { const { unmount } = renderMenu(m, ADMIN); diff --git a/apps/backstage/src/features/members/components/member-row-menu.tsx b/apps/backstage/src/features/members/components/member-row-menu.tsx index 600a0096..e8f7b76b 100644 --- a/apps/backstage/src/features/members/components/member-row-menu.tsx +++ b/apps/backstage/src/features/members/components/member-row-menu.tsx @@ -25,8 +25,7 @@ export function MemberRowMenu({ onUnpublish, }: MemberRowMenuProps) { const { canProvisionLogin, isAdmin } = useCan(); - const provisionBlocked = - !isAdmin && memberProvisionBlocked(member, (id) => positionsById.get(id)); + const provisionBlocked = memberProvisionBlocked(member, (id) => positionsById.get(id), isAdmin); return ( ({ vi.mock("../hooks/use-provision-member-login", () => ({ useProvisionMemberLogin: () => ({ mutateAsync: vi.fn(), mutate: vi.fn(), isPending: false }), })); +// MemberDrawer (rendered from this page) now reads the caller's uid to decide whether the row +// it opened is the caller's OWN — the table lists it too, so that edit is a SELF-assignment. +// Mocked as a factory with no `importOriginal`: lib/auth/auth builds its store from +// getFirebase().auth at module scope, so evaluating the real module initializes Firebase and +// the whole file fails to collect. +vi.mock("../../../lib/auth/auth", () => ({ + useAuth: () => ({ user: { uid: "u" }, claims: { roles: ["Admin"] } }), +})); import { MembersPage } from "./members-page"; import { AbilityProvider } from "../../../lib/authz/ability-context"; diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx index ffed91a9..00e253ab 100644 --- a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx @@ -26,3 +26,17 @@ export function NoAssignableCargosNote() {

); } + +export const MINT_PENDING_NOTE_ID = "cargo-mint-pending-note"; + +/** Why a cargo this editor CAN assign will not actually confer its permissions — see + * `cargoGrantNeedsAdminAssigner`. Shared by both forms for the same reason as the note above: + * the wording does not differ between them, so a second copy would only drift. */ +export function MintPendingNote() { + return ( +

+ Este cargo otorga permisos, pero no se aplicarán hasta que un administrador confirme la + asignación. El cargo sí queda registrado y visible. +

+ ); +} diff --git a/apps/backstage/src/features/members/lib/provision-gate.test.ts b/apps/backstage/src/features/members/lib/provision-gate.test.ts index e625747b..564d2f08 100644 --- a/apps/backstage/src/features/members/lib/provision-gate.test.ts +++ b/apps/backstage/src/features/members/lib/provision-gate.test.ts @@ -36,34 +36,34 @@ const seat = (cargoId: string | null, key = term) => ({ describe("memberProvisionBlocked", () => { it("does not block a clean, unseated member", () => { - expect(memberProvisionBlocked(member(), catalog)).toBe(false); + expect(memberProvisionBlocked(member(), catalog, false)).toBe(false); }); it("does not block a member seated on a grant-free cargo", () => { // The control for the cargo clause: being seated is not the refusal, conferring power is. - expect(memberProvisionBlocked(member(seat(PLAIN)), catalog)).toBe(false); + expect(memberProvisionBlocked(member(seat(PLAIN)), catalog, false)).toBe(false); }); it("does not block a member whose only term entry has a null cargo", () => { - expect(memberProvisionBlocked(member(seat(null)), catalog)).toBe(false); + expect(memberProvisionBlocked(member(seat(null)), catalog, false)).toBe(false); }); it("blocks a member who already has a login (beacon's adoption guard)", () => { - expect(memberProvisionBlocked(member({ uid: "u1" }), catalog)).toBe(true); + expect(memberProvisionBlocked(member({ uid: "u1" }), catalog, false)).toBe(true); }); it("treats an empty-string uid as no login", () => { // A stored empty string is not a linked account; blocking on it would hide the invite for // exactly the member who still needs one. - expect(memberProvisionBlocked(member({ uid: "" }), catalog)).toBe(false); + expect(memberProvisionBlocked(member({ uid: "" }), catalog, false)).toBe(false); }); it("blocks a member carrying direct roleIds", () => { - expect(memberProvisionBlocked(member({ roleIds: ["custom"] }), catalog)).toBe(true); + expect(memberProvisionBlocked(member({ roleIds: ["custom"] }), catalog, false)).toBe(true); }); it("does not block on an empty roleIds array", () => { - expect(memberProvisionBlocked(member({ roleIds: [] }), catalog)).toBe(false); + expect(memberProvisionBlocked(member({ roleIds: [] }), catalog, false)).toBe(false); }); it("blocks a member carrying a permissionOverrides GRANT", () => { @@ -71,6 +71,7 @@ describe("memberProvisionBlocked", () => { memberProvisionBlocked( member({ permissionOverrides: { grant: ["update:Member"], revoke: [] } }), catalog, + false, ), ).toBe(true); }); @@ -84,31 +85,32 @@ describe("memberProvisionBlocked", () => { memberProvisionBlocked( member({ permissionOverrides: { grant: [], revoke: ["update:Member"] } }), catalog, + false, ), ).toBe(false); }); it("blocks a member seated on a power-granting cargo in the CURRENT term", () => { - expect(memberProvisionBlocked(member(seat(POWER)), catalog)).toBe(true); + expect(memberProvisionBlocked(member(seat(POWER)), catalog, false)).toBe(true); }); // syncMemberClaims reads the current term at trigger time, so a future-term seat mints on // the year rollover. Reading only the current term here would offer the invite today and // 403 on it — beacon reads every term. it("BLOCKING: blocks a power-granting cargo seated in a FUTURE term", () => { - expect(memberProvisionBlocked(member(seat(POWER, nextTerm)), catalog)).toBe(true); + expect(memberProvisionBlocked(member(seat(POWER, nextTerm)), catalog, false)).toBe(true); }); it("blocks a power-granting cargo seated in a PAST term", () => { - expect(memberProvisionBlocked(member(seat(POWER, "2020")), catalog)).toBe(true); + expect(memberProvisionBlocked(member(seat(POWER, "2020")), catalog, false)).toBe(true); }); // Fails CLOSED in the same direction as beacon, which reads grants === null from an // unreadable cargo the same way. A stale or still-loading `positions` prop must hide the // affordance rather than promise a 403. it("BLOCKING: blocks when the cargo id does not resolve against the catalog", () => { - expect(memberProvisionBlocked(member(seat("gone")), catalog)).toBe(true); - expect(memberProvisionBlocked(member(seat(PLAIN)), () => undefined)).toBe(true); + expect(memberProvisionBlocked(member(seat("gone")), catalog, false)).toBe(true); + expect(memberProvisionBlocked(member(seat(PLAIN)), () => undefined, false)).toBe(true); }); it("blocks when any ONE term is power-granting among several clean ones", () => { @@ -119,26 +121,92 @@ describe("memberProvisionBlocked", () => { [nextTerm]: { cargoId: null, comisionIds: [] }, }, }); - expect(memberProvisionBlocked(m, catalog)).toBe(true); + expect(memberProvisionBlocked(m, catalog, false)).toBe(true); + }); + + // BLOCKING: an EMPTY-STRING cargoId is a MALFORMED seat, not an empty one. This mirrors + // beacon's readCargoIds, which pushes "" rather than skipping it — "a malformed shape must + // never read as 'no cargo', that is the guard's own bypass" — and then refuses it at + // isSafeDocId. The gate used to test `term.cargoId ? [...] : []`, so "" read as "no cargo" + // here: the invite was offered, the click 403'd with power-seat-requires-admin, and the + // message named a cargo that does not exist. Now only undefined/null are skipped, so "" + // falls through to the unresolvable-cargo clause and blocks — same direction as beacon. + it("BLOCKING: blocks an EMPTY-STRING cargoId instead of reading it as no cargo", () => { + expect(memberProvisionBlocked(member(seat("")), catalog, false)).toBe(true); + // It blocks THROUGH the unresolvable-cargo clause, not through a clause of its own: no + // Firestore document id can be "", so every CargoLookup answers undefined for it. Pinned + // as the mechanism because that is the whole fix — "" was being dropped before it ever + // reached the lookup, and there is no other clause standing behind it. + expect(memberProvisionBlocked(member(seat("gone")), catalog, false)).toBe(true); + }); + + // The `undefined`/`null` half of that same change, so the fix cannot be over-applied into + // "any falsy cargoId blocks". A term row with no cargo is the ordinary shape of an unseated + // member and readCargoIds `continue`s past both — blocking here would hide the invite from + // nearly every member in the chapter. + // The `undefined` arm of the same guard is not exercised here on purpose: TermPositions + // declares `cargoId` as required (`string | null`), so an absent key is unrepresentable in a + // well-typed doc and asserting it would need a cast. It is kept in the gate as a read-side + // defence — Firestore hands back whatever is stored — not as a reachable branch. + it("BLOCKING: still skips a NULL cargoId, which is a genuinely unseated term", () => { + expect(memberProvisionBlocked(member(seat(null)), catalog, false)).toBe(false); + }); + + // The callerIsAdmin short-circuit, which replaced a `!isAdmin &&` conjunct re-typed at three + // call sites. Every refusal beacon applies is guarded by `!callerHoldsAdminRole`, so an Admin + // is subject to none of them. Swept over every blocking fixture above rather than sampled: + // the parameter is a single early return, so a regression that dropped it would show up on + // whichever refusal a sampled test happened not to cover. + it("BLOCKING: never blocks an Admin caller, on any refusal", () => { + const blocked: Member[] = [ + member({ uid: "u1" }), + member({ roleIds: ["custom"] }), + member({ permissionOverrides: { grant: ["update:Member"], revoke: [] } }), + member(seat(POWER)), + member(seat(POWER, nextTerm)), + member(seat("gone")), + member(seat("")), + ]; + for (const m of blocked) { + expect(memberProvisionBlocked(m, catalog, false)).toBe(true); + expect(memberProvisionBlocked(m, catalog, true)).toBe(false); + } }); }); describe("draftProvisionBlocked", () => { it("does not block a draft with no cargo", () => { - expect(draftProvisionBlocked(null, catalog)).toBe(false); - expect(draftProvisionBlocked(undefined, catalog)).toBe(false); - expect(draftProvisionBlocked("", catalog)).toBe(false); + expect(draftProvisionBlocked(null, catalog, false)).toBe(false); + expect(draftProvisionBlocked(undefined, catalog, false)).toBe(false); + }); + + // Deliberately NOT the member variant's answer, and pinned so the difference is a decision + // rather than a leftover. `memberProvisionBlocked` reads a STORED doc, where a malformed "" + // is reachable and must fail closed; this reads the draft the invite drawer is about to + // create, whose cargoId comes from `z.string().min(1).nullable()` — "" cannot be produced, + // and the create lane forbids a non-Admin the uid/roleIds/overrides halves anyway. If the + // draft schema ever stops guaranteeing that, this line is the one that has to move. + it("reads an empty-string draft cargoId as no cargo — the schema cannot produce one", () => { + expect(draftProvisionBlocked("", catalog, false)).toBe(false); }); it("does not block a draft seated on a grant-free cargo", () => { - expect(draftProvisionBlocked(PLAIN, catalog)).toBe(false); + expect(draftProvisionBlocked(PLAIN, catalog, false)).toBe(false); }); it("blocks a draft seated on a power-granting cargo", () => { - expect(draftProvisionBlocked(POWER, catalog)).toBe(true); + expect(draftProvisionBlocked(POWER, catalog, false)).toBe(true); }); it("BLOCKING: fails closed on a cargo id the catalog cannot resolve", () => { - expect(draftProvisionBlocked("gone", catalog)).toBe(true); + expect(draftProvisionBlocked("gone", catalog, false)).toBe(true); + }); + + // Same short-circuit as the member variant, and the reason the invite drawer no longer types + // `!isAdmin &&` at its call site. Without it an Admin creating a board member would be told + // "solo un administrador puede enviarle el acceso" — copy that contradicts itself. + it("BLOCKING: never blocks an Admin caller, on either refusal", () => { + expect(draftProvisionBlocked(POWER, catalog, true)).toBe(false); + expect(draftProvisionBlocked("gone", catalog, true)).toBe(false); }); }); diff --git a/apps/backstage/src/features/members/lib/provision-gate.ts b/apps/backstage/src/features/members/lib/provision-gate.ts index c0eab3a8..5f606ea0 100644 --- a/apps/backstage/src/features/members/lib/provision-gate.ts +++ b/apps/backstage/src/features/members/lib/provision-gate.ts @@ -2,8 +2,9 @@ import type { Member, Position } from "@luminova/types"; /** * Client mirror of the refusals `provisionMember` applies to a non-Admin caller — the - * adoption guard (`reprovision-requires-admin`) and both halves of the power-seat guard - * (`granted-member-requires-admin`, `power-seat-requires-admin`). + * adoption guard and both halves of the power-seat guard. The reasons they throw are the + * `ProvisionBlockReason` union in `@luminova/types` — named there, not spelled out here, so + * this comment cannot quietly outlive a rename the way an unchecked prose copy would. * * NOT a security boundary: beacon is, and it re-derives all of this server-side from the * stored doc. This exists so the three entry points that offer "invitar / enviar acceso" do @@ -42,10 +43,25 @@ function provisionBlockedForNonAdmin(input: { * the profile page and the invite drawer have arrays — without one of them allocating. */ export type CargoLookup = (id: string) => Pick | undefined; -/** `provisionBlockedForNonAdmin` for a stored member doc. */ -export function memberProvisionBlocked(member: Member, cargo: CargoLookup): boolean { +/** `provisionBlockedForNonAdmin` for a stored member doc. + * + * `callerIsAdmin` is a PARAMETER rather than a `!isAdmin &&` at each call site, for the same + * reason `positionsLockedForEditor` takes its flag: three call sites typing the same conjunct + * is how the cargo predicates drifted, and this function is even NAMED for the conjunct. An + * Admin is subject to none of these refusals — beacon's guards are all `!callerHoldsAdminRole`. */ +export function memberProvisionBlocked( + member: Member, + cargo: CargoLookup, + callerIsAdmin: boolean, +): boolean { + if (callerIsAdmin) return false; + // `?? []` on absent/null only — an EMPTY-STRING cargoId must NOT be skipped. beacon's + // readCargoIds pushes "" deliberately ("a malformed shape must never read as 'no cargo' — + // that is the guard's own bypass"), and "" then fails isSafeDocId at the port and refuses. + // A truthiness test here would skip it, promise an invite, and 403 with a message about a + // cargo that does not exist. It falls through to the `cargo === undefined` clause instead. const cargoIds = Object.values(member.positions ?? {}).flatMap((term) => - term.cargoId ? [term.cargoId] : [], + term.cargoId === undefined || term.cargoId === null ? [] : [term.cargoId], ); return provisionBlockedForNonAdmin({ hasLogin: typeof member.uid === "string" && member.uid.length > 0, @@ -63,7 +79,9 @@ export function memberProvisionBlocked(member: Member, cargo: CargoLookup): bool export function draftProvisionBlocked( cargoId: string | null | undefined, cargo: CargoLookup, + callerIsAdmin: boolean, ): boolean { + if (callerIsAdmin) return false; return provisionBlockedForNonAdmin({ hasLogin: false, hasDirectGrants: false, diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.test.ts b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts new file mode 100644 index 00000000..487cb8a3 --- /dev/null +++ b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, afterEach, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useCopyToClipboard } from "./use-copy-to-clipboard"; + +/** Install a `navigator.clipboard` for one test. `undefined` models an INSECURE CONTEXT, where + * the property does not exist at all — jsdom's navigator has no clipboard either, so this is + * the honest default rather than a contrivance. Configurable so afterEach can take it back. */ +function stubClipboard(clipboard: { writeText: (t: string) => Promise } | undefined) { + Object.defineProperty(navigator, "clipboard", { + value: clipboard, + configurable: true, + writable: true, + }); +} + +afterEach(() => { + Reflect.deleteProperty(navigator, "clipboard"); +}); + +describe("useCopyToClipboard", () => { + it("starts idle and writes the text through to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard({ writeText }); + const { result } = renderHook(() => useCopyToClipboard()); + expect(result.current.copyState).toBe("idle"); + await act(async () => result.current.copy("https://example.com/link")); + expect(writeText).toHaveBeenCalledWith("https://example.com/link"); + expect(result.current.copyState).toBe("copied"); + }); + + it("reports a rejected write as failed", async () => { + stubClipboard({ writeText: vi.fn().mockRejectedValue(new Error("denied")) }); + const { result } = renderHook(() => useCopyToClipboard()); + await act(async () => result.current.copy("x")); + expect(result.current.copyState).toBe("failed"); + }); + + // BLOCKING: the reason this hook exists at all. Outside a secure context (plain http, an + // embedded webview) `navigator.clipboard` is `undefined`, so `navigator.clipboard.writeText` + // throws a TypeError SYNCHRONOUSLY — there is no promise, so the `.catch()` both call sites + // used to hang off the call never ran. The failure affordance ("selecciona el enlace y + // cópialo manualmente") is the ONE case that surface exists for, and it was exactly the case + // that instead threw out of the onClick handler and rendered nothing. + it("BLOCKING: reports a synchronous throw (no clipboard API) as failed, not an exception", async () => { + stubClipboard(undefined); + const { result } = renderHook(() => useCopyToClipboard()); + expect(() => act(() => result.current.copy("x"))).not.toThrow(); + expect(result.current.copyState).toBe("failed"); + }); + + // The same shape one layer in: a clipboard object whose writeText throws rather than + // rejecting. Pinned separately because a fix that only null-checked `navigator.clipboard` + // would pass the case above and still throw here. + it("BLOCKING: reports a writeText that THROWS rather than rejecting as failed", async () => { + stubClipboard({ + writeText: () => { + throw new Error("not allowed"); + }, + }); + const { result } = renderHook(() => useCopyToClipboard()); + expect(() => act(() => result.current.copy("x"))).not.toThrow(); + expect(result.current.copyState).toBe("failed"); + }); + + it("resetCopyState returns to idle so a re-opened surface does not show a stale result", async () => { + stubClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + const { result } = renderHook(() => useCopyToClipboard()); + await act(async () => result.current.copy("x")); + expect(result.current.copyState).toBe("copied"); + act(() => result.current.resetCopyState()); + expect(result.current.copyState).toBe("idle"); + }); + + // A retry after a failure has to be able to succeed: `copy` sets state on BOTH outcomes, so + // nothing has to be reset in between. If it only ever set "failed" the button would stay + // wrong for the rest of the session. + it("a later successful copy overwrites an earlier failure", async () => { + stubClipboard(undefined); + const { result } = renderHook(() => useCopyToClipboard()); + act(() => result.current.copy("x")); + expect(result.current.copyState).toBe("failed"); + stubClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + await act(async () => result.current.copy("x")); + expect(result.current.copyState).toBe("copied"); + }); +}); diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.ts b/apps/backstage/src/lib/use-copy-to-clipboard.ts new file mode 100644 index 00000000..01007d6a --- /dev/null +++ b/apps/backstage/src/lib/use-copy-to-clipboard.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; + +export type CopyState = "idle" | "copied" | "failed"; + +/** + * Copy-to-clipboard with a result the caller can render. Both places that offer a + * password-reset link as a manual fallback need exactly this, and a second copy would drift the + * way the cargo predicates already did once. + * + * `navigator.clipboard` is `undefined` outside a secure context, so the property access throws + * SYNCHRONOUSLY — a bare `.catch()` on the returned promise never runs and the failure + * affordance never renders, which is the one case it exists for. Hence the try/catch around the + * call itself, not only the rejection. + */ +export function useCopyToClipboard(): { + copyState: CopyState; + copy: (text: string) => void; + resetCopyState: () => void; +} { + const [copyState, setCopyState] = useState("idle"); + const copy = (text: string) => { + try { + void navigator.clipboard + .writeText(text) + .then(() => setCopyState("copied")) + .catch(() => setCopyState("failed")); + } catch { + // No clipboard API at all (insecure context, embedded webview). Same outcome as a + // rejected write: tell the user to select the text themselves. + setCopyState("failed"); + } + }; + return { copyState, copy, resetCopyState: () => setCopyState("idle") }; +} From 10031c4fe9a7154728a44d52d2313fa6ea181455 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 08:04:47 -0400 Subject: [PATCH 08/25] fix(backstage): answer both conjuncts before promising the row action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of the previous commit found it stopped one conjunct short of its own rationale. The row item is gated on `canProvisionLogin && !provisionBlocked`; the done-screen fallback was made to respect the second and still ignored the first. A `create:Member` holder without `create:MemberLogin` reaches this drawer — the trigger only asks `Can I="create" a="Member"` — never sees the checkbox, and was then sent to a row action that is hidden from them. The same dead end, on the other axis. Also corrects cargoNoteId's co-firing comment, which was wrong in both directions: `takedown ∧ mintPending` is impossible (one needs grants.length === 0, the other > 0, off the same selection), while `locked ∧ mintPending` is the pair that IS reachable and was unmentioned. The order was already right — locked outranks mintPending, and the mint note renders under `!locked` — but a comment saying these cannot co-fire is an invitation to reorder them, which would point aria-describedby at an element that is not in the DOM. And states the CargoLike tradeoff the split accepted: `category`/`grants` are wider than Position's literal unions because narrowing them means importing those unions — the one thing that module may not do — or re-declaring them, and a re-declared union drifts. The direction that can actually break is already enforced: cargoSlotsForEditor is generic in `P extends CargoLike` and gets handed `Position[]`. Mutation-tested: collapsing the three-way branch back to two turns exactly the new test red. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/member-invite-drawer.test.tsx | 24 +++++++++++++++++++ .../components/member-invite-drawer.tsx | 16 +++++++++---- .../members/lib/assignable-cargo-core.ts | 8 +++++++ .../features/members/lib/assignable-cargo.ts | 19 +++++++++++---- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx index 71375ae8..6766667a 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx @@ -347,6 +347,30 @@ describe("MemberInviteDrawer", () => { expect(screen.queryByText(/un administrador debe enviarle el acceso/)).not.toBeInTheDocument(); }); + it("BLOCKING: never promises the row action to a creator who lacks create:MemberLogin", async () => { + // The OTHER conjunct the row item is gated on. This principal reaches the drawer — the + // trigger only asks `Can I="create" a="Member"` — but never sees "Invitar a la app" in the + // row menu, because canProvisionLogin is false. The checkbox is not rendered for them + // either, so they always land on this branch, on an ordinary grant-free member. + renderWithAbility( + {}} + onCreate={async () => "idCreatorOnly"} + onProvision={vi.fn()} + />, + { roles: ["Member"], perms: ["create:Member"] }, + ); + expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument(); + await fill(); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + expect( + await screen.findByText(/Pídele a un administrador que le envíe el acceso/), + ).toBeInTheDocument(); + expect(screen.queryByText(/desde el menú de su fila/)).not.toBeInTheDocument(); + }); + it("keeps the row-menu copy for an ADMIN who unticked the checkbox on a power cargo", async () => { // draftProvisionBlocked short-circuits on callerIsAdmin, so `provisionBlocked` is false // for them even seated on the power cargo — an Admin can always invite from the row. This diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx index df081203..14f5c86c 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx @@ -177,14 +177,20 @@ export function MemberInviteDrawer({ ) : ( <> - {/* Keyed on blocked-ness alone, NOT on `blockedByCargo` (which also requires - sendAccess): with the checkbox unticked the same delegate lands here, and - memberProvisionBlocked hides the row action for exactly the same reason — so - pointing them at it would send them to an affordance that is not there. */} + {/* Only promise the row action to someone who will actually SEE it. The row item + is gated on `canProvisionLogin && !provisionBlocked` (member-row-menu), so + both conjuncts have to be answered here or this sends a caller to an + affordance that is not there. `blockedByCargo` is the wrong flag for the + second one — it also requires sendAccess, and the same delegate lands here + with the checkbox unticked. A `create:Member` holder without + `create:MemberLogin` reaches this drawer too: the trigger only asks + `Can I="create" a="Member"`. */}

{done.provisionBlocked ? "Aún no tiene acceso a la app. Su cargo otorga permisos, así que un administrador debe enviarle el acceso." - : "Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila."} + : canProvisionLogin + ? "Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila." + : "Aún no tiene acceso a la app. Pídele a un administrador que le envíe el acceso."}

{done.errorDetail && (

Detalle: {done.errorDetail}

diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index 930d3d9b..e122e2f9 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -24,6 +24,14 @@ * so this module stays import-free (see the header). Every real `Position` satisfies it, so * callers pass their `Position` objects unchanged and `cargoSlotsForEditor` returns the very * objects it was handed (it is generic in `P`), never a lossy copy. + * + * ACCEPTED TRADEOFF: `category` and `grants` are wider here than `Position`'s literal unions, + * because narrowing them would mean either importing those unions (which is the one thing this + * module may not do) or re-declaring them, and a re-declared union drifts. So a hand-built + * FIXTURE could pass `category: "cel"` and get a wrong answer with no compile error. Every + * production caller passes a real `Position`, and `cargoSlotsForEditor` being generic in + * `P extends CargoLike` means `assignable-cargo.ts` handing it `Position[]` already enforces + * `Position extends CargoLike` — which is the direction that can actually break. */ export interface CargoLike { id: string; diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index 82a708b1..b74a9b73 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -1,4 +1,9 @@ import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types"; +// cargoSlotsForEditor is generic in `P extends CargoLike` and cargoOptionsForEditor below hands +// it `Position[]`, so `Position extends CargoLike` is already enforced at that call — a rename +// or retype of any field the predicates read fails to compile here rather than reading +// `undefined` at runtime. That is the direction that can actually break; a separate assertion +// would only restate it. import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core"; /** @@ -71,10 +76,16 @@ export function noAssignableCargos(input: { * render-state, and here for the same reason as the other three: both forms ask it, and a * re-typed ternary at each call site is how they drift. * - * Order is priority, and the states are NOT all mutually exclusive — `takedown` and - * `mintPending` can co-fire with nothing else, but a `locked` slot always has its held cargo in - * the option list, so `noCargos` and `locked` cannot. First match wins, most-blocking first: a - * note about not being able to pick anything outranks one about what a pick would mint. + * Order is priority and it is load-bearing, because exactly one pair CAN co-fire: + * locked ∧ mintPending REACHABLE — a delegate opening a member seated on an Admin-granting + * cargo has both. `locked` must win: the mint note's render is guarded + * by `!locked`, so pointing aria-describedby at it would reference an + * element that is not in the DOM, which is worse than no association. + * takedown ∧ mintPending impossible — takedown needs grants.length === 0, mintPending needs + * grants.length > 0, both off the same selected cargo. + * noCargos ∧ locked impossible — a locked slot's held cargo is always in the list. + * First match wins, most-blocking first: not being able to pick anything outranks what a pick + * would mint. * * The ids differ per form (the locked and takedown wordings legitimately differ between them), * so they are passed in rather than owned here. From 7f10a359337ae080aebaad6eabb7041a7c161477 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 08:08:27 -0400 Subject: [PATCH 09/25] refactor: state each rationale once, not at both sites /simplify found the previous commit re-explaining two facts that already had a home: why handing Position[] to a CargoLike-generic function is the enforcement that matters (now only in assignable-cargo-core, next to the interface it is about), and why provisionBlocked rather than blockedByCargo answers the row-menu gate (now only on the field's own doc-comment). Two copies of a rationale drift exactly the way two copies of a predicate do. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-invite-drawer.tsx | 10 ++++------ .../src/features/members/lib/assignable-cargo.ts | 5 ----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx index 14f5c86c..71297080 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx @@ -179,12 +179,10 @@ export function MemberInviteDrawer({ <> {/* Only promise the row action to someone who will actually SEE it. The row item is gated on `canProvisionLogin && !provisionBlocked` (member-row-menu), so - both conjuncts have to be answered here or this sends a caller to an - affordance that is not there. `blockedByCargo` is the wrong flag for the - second one — it also requires sendAccess, and the same delegate lands here - with the checkbox unticked. A `create:Member` holder without - `create:MemberLogin` reaches this drawer too: the trigger only asks - `Can I="create" a="Member"`. */} + both conjuncts have to be answered here — see provisionBlocked's doc-comment + above for why it, and not blockedByCargo, is the right flag for the second. + A `create:Member` holder WITHOUT `create:MemberLogin` reaches this drawer too: + the trigger only asks `Can I="create" a="Member"`. */}

{done.provisionBlocked ? "Aún no tiene acceso a la app. Su cargo otorga permisos, así que un administrador debe enviarle el acceso." diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index b74a9b73..8c18b969 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -1,9 +1,4 @@ import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types"; -// cargoSlotsForEditor is generic in `P extends CargoLike` and cargoOptionsForEditor below hands -// it `Position[]`, so `Position extends CargoLike` is already enforced at that call — a rename -// or retype of any field the predicates read fails to compile here rather than reading -// `undefined` at runtime. That is the direction that can actually break; a separate assertion -// would only restate it. import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core"; /** From 3c1df34ab558c40dab01cf3886056cc998f4645f Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 08:13:58 -0400 Subject: [PATCH 10/25] test,docs: name the two gaps the parity test cannot cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify's altitude pass argued against extracting a shared invite predicate — the done screen needs a REASON, not a boolean, and the two flags mirror different server gates that fail closed in different directions. Recorded here rather than acted on; the trigger for revisiting is a 4th consumer or a 3rd conjunct on the row-menu gate. Three of its findings were worth taking: The new drawer test's principal could not reach that drawer in production — /members gates on an unconditional read:Member, so a create-only holder is redirected off the page hosting it. The fix is right for the reachable principal; the fixture now names it. cargoNoteId's comment said "exactly one pair CAN co-fire" while arguing three of six. The other three are unreachable for reasons OUTSIDE that file (the Combobox is disabled while locked; grant-free CEL is never offered without allowPowerGrants), so a future change to either makes `locked ∧ takedown` reachable with nothing saying the exclusion was ever load-bearing. And the CargoLike tradeoff understated itself: a miscased `category` in a fixture is invisible to the PARITY TEST too, not just to the compiler — the same object is fed to the predicate and seeded into the emulator, so both sides agree wrongly and the suite stays green. The factory now pins the literal union, which is the only guard available, and both comments say so. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-invite-drawer.test.tsx | 4 +++- .../src/features/members/lib/assignable-cargo-core.ts | 4 ++++ .../src/features/members/lib/assignable-cargo.ts | 6 +++++- tests/firestore-rules/cargo-assignment-parity.test.ts | 8 +++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx index 6766667a..38f70c77 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx @@ -360,7 +360,9 @@ describe("MemberInviteDrawer", () => { onCreate={async () => "idCreatorOnly"} onProvision={vi.fn()} />, - { roles: ["Member"], perms: ["create:Member"] }, + // read:Member too: /members' nav gate is an unconditional read:Member, so a + // create-only principal never reaches the page that hosts this drawer. + { roles: ["Member"], perms: ["read:Member", "create:Member"] }, ); expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument(); await fill(); diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index e122e2f9..2010ea21 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -32,6 +32,10 @@ * production caller passes a real `Position`, and `cargoSlotsForEditor` being generic in * `P extends CargoLike` means `assignable-cargo.ts` handing it `Position[]` already enforces * `Position extends CargoLike` — which is the direction that can actually break. + * + * Note the parity test cannot cover this either, and it is the one gap in it: the same fixture + * is fed to the predicate AND seeded into the emulator, so a miscased `category` makes both + * sides agree — wrongly, and green. Its factory pins the literal union for that reason. */ export interface CargoLike { id: string; diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index 8c18b969..96d959a2 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -71,7 +71,7 @@ export function noAssignableCargos(input: { * render-state, and here for the same reason as the other three: both forms ask it, and a * re-typed ternary at each call site is how they drift. * - * Order is priority and it is load-bearing, because exactly one pair CAN co-fire: + * Order is priority and it is load-bearing, because of the one pair that CAN co-fire: * locked ∧ mintPending REACHABLE — a delegate opening a member seated on an Admin-granting * cargo has both. `locked` must win: the mint note's render is guarded * by `!locked`, so pointing aria-describedby at it would reference an @@ -79,6 +79,10 @@ export function noAssignableCargos(input: { * takedown ∧ mintPending impossible — takedown needs grants.length === 0, mintPending needs * grants.length > 0, both off the same selected cargo. * noCargos ∧ locked impossible — a locked slot's held cargo is always in the list. + * The remaining three pairs are unreachable only because of facts OUTSIDE this file — the + * Combobox is `disabled={locked}`, so the selection cannot leave the held cargo, and + * `cargoSlotsForEditor` never offers a grant-free CEL while `!allowPowerGrants`. Change either + * and `locked ∧ takedown` becomes reachable, at which point this order matters again. * First match wins, most-blocking first: not being able to pick anything outranks what a pick * would mint. * diff --git a/tests/firestore-rules/cargo-assignment-parity.test.ts b/tests/firestore-rules/cargo-assignment-parity.test.ts index f4366a27..65f63527 100644 --- a/tests/firestore-rules/cargo-assignment-parity.test.ts +++ b/tests/firestore-rules/cargo-assignment-parity.test.ts @@ -142,9 +142,15 @@ interface Cargo extends CargoLike { description: string; deletedAt: null; } +// `category` is a literal union HERE even though CargoLike widens it to `string`. This is the +// one error the parity test structurally cannot catch: the same fixture object is both fed to +// the client predicate and seeded into the emulator, so a `"cel"` typo would make the rules' +// `category != 'CEL'` and the client's `category !== "CEL"` agree with each other — and agree +// wrongly, on the publication boundary, with the suite green. The compiler is the only guard +// available for it, so give it one. const cargo = ( id: string, - category: string, + category: "CEL" | "JDL" | "Comision", grants: string[], extra: Partial = {}, ): Cargo => ({ From 7011747aea9ed3b7c51f607a94a905b118decf40 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 08:14:06 -0400 Subject: [PATCH 11/25] chore: reviews Reviews: 3c1df34ab558c40dab01cf3886056cc998f4645f security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher From 7ca1abbf4669e98e7bfbd8b2ff15f733c2c63fdb Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 11:49:52 -0400 Subject: [PATCH 12/25] fix(beacon): make every fail-closed claims-sync path say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review (run by the user against this PR) found the fail-closed screens in resolveTrustedGrants are silent: a malformed cargoId or assignedBy drops the member's cargo-derived roles, computeMemberRoles mints only Member, the trigger completes successfully, and the member is published on the Directiva with no permissions and no signal anywhere. Guardrail #4 is exactly this shape. Both screens now log through the existing injectable sink, with the id truncated — isSafeDocId tolerates 1500 bytes and Cloud Logging drops an over-large entry whole, which is the failure the neighbouring sampleRejectedIds already existed to avoid. The cargoId log is guarded on a non-null id, or it would fire on every write to every member holding no seat and bury the real anomaly. readPositionGrants' three null returns log too. The DESIGNED refusal now logs as well, which was outside the finding: it is reached on an ordinary delegate path — an Admin-granting cargo, or a self-assignment — and is the likeliest real cause of "they are on the Directiva with no permissions". The meta names which half denied, because the remedy differs: an Admin re-saves the slot for a self-assignment, but only an Admin may seat an Admin-granting cargo at all. Paired with a negative so a log on the success path cannot creep in. Restores two tests deleted earlier in this session on "subsumed by" claims that do not hold: - callable-auth's "keeps the two delegations independent" — the survivor is a same-subject WRONG-ACTION probe; nothing else exercises the callable gate with the sibling delegation code, so a widening keyed on subject family would pass. - provision-member-login's grant-free-cargo ALLOW — the fixture said to subsume it has no positions map at all, so the power-seat loop body never executes. It was the only test driving a resolved cargo through that loop and out the allow side. That is the third false subsumption claim on this branch. Each new comment now says why the test is NOT redundant, so the argument is not repeated a fourth time. Every log mutation-tested: delete it and exactly its own assertion goes red. Co-Authored-By: Claude Opus 5 (1M context) --- apps/beacon/src/callable-auth.test.ts | 21 +++ apps/beacon/src/claims-sync/firestore-deps.ts | 9 +- apps/beacon/src/claims-sync/sync.test.ts | 133 ++++++++++++++++++ apps/beacon/src/claims-sync/sync.ts | 48 ++++++- apps/beacon/src/firestore-util.ts | 10 ++ .../beacon/src/provision-member-login.test.ts | 22 +++ apps/beacon/src/read-position-grants.test.ts | 46 +++++- apps/beacon/src/read-position-grants.ts | 30 +++- 8 files changed, 305 insertions(+), 14 deletions(-) diff --git a/apps/beacon/src/callable-auth.test.ts b/apps/beacon/src/callable-auth.test.ts index 7ddc223f..e0dce395 100644 --- a/apps/beacon/src/callable-auth.test.ts +++ b/apps/beacon/src/callable-auth.test.ts @@ -83,6 +83,27 @@ describe("requireAdminOrPerm", () => { ).toBe("permission-denied"); }); + it("keeps the two delegations independent", () => { + // A board-seat delegate is not a login provisioner and vice versa. Pinned because both + // codes ship together and the obvious future mistake is to conflate them. + // + // NOT subsumed by the manage:MemberLogin case above, which was the argument for deleting + // it once. That one is a same-subject WRONG-ACTION probe: it only pins that the gate + // compares the literal code instead of expanding an action wildcard. This one is a + // DIFFERENT-SUBJECT probe, over the exact pair of codes the feature ships. A widening + // that keyed on the subject family — anything accepting a related-subject perm, e.g. a + // "holds any MemberLogin/BoardSeat delegation" helper — passes every other case in this + // suite and fails only here. + expect( + codeOf(() => + requireAdminOrPerm( + req({ roles: ["Member"], perms: ["update:BoardSeat"] }), + "create:MemberLogin", + ), + ), + ).toBe("permission-denied"); + }); + it("fails closed on a malformed perms claim", () => { // A string (or anything non-array) reads as empty rather than throwing — a malformed // token must deny, not 500. diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts index f81c4958..875723b7 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.ts @@ -3,7 +3,7 @@ import type { Firestore } from "firebase-admin/firestore"; import { isValidRole, type Role } from "@luminova/auth/roles"; import { isValidPermissionCode, type PermissionCode } from "@luminova/types/permission"; import { chunk } from "../chunk.js"; -import { isSafeDocId } from "../firestore-util.js"; +import { isSafeDocId, truncateForLog } from "../firestore-util.js"; import { readPositionGrants } from "../read-position-grants.js"; import { isActiveRoleDoc, permsFromRoleDoc } from "./role-doc.js"; import type { LiveBuiltInRoleDoc } from "./resolve-member-perms.js"; @@ -29,14 +29,9 @@ function permsFromClaims( * limit — the entry is then DROPPED, making the anomaly invisible at exactly the scale * that matters. The count is the alertable signal; the sample is for diagnosis. */ const REJECTED_ID_SAMPLE = 10; -const REJECTED_ID_MAX_CHARS = 64; function sampleRejectedIds(rejected: readonly string[]): string[] { - return rejected - .slice(0, REJECTED_ID_SAMPLE) - .map((id) => - id.length > REJECTED_ID_MAX_CHARS ? `${id.slice(0, REJECTED_ID_MAX_CHARS)}…` : id, - ); + return rejected.slice(0, REJECTED_ID_SAMPLE).map(truncateForLog); } /** The built-in role docs covering `keys`, plus a log line for every coverage anomaly the diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts index 8fcaa466..3890450a 100644 --- a/apps/beacon/src/claims-sync/sync.test.ts +++ b/apps/beacon/src/claims-sync/sync.test.ts @@ -201,6 +201,63 @@ describe("syncMemberClaims", () => { expect(writes["delegate-uid"]).toBeUndefined(); }); + it("BLOCKING: logs the DESIGNED refusal too, naming which half denied it", async () => { + // The likeliest real cause of "they're on the Directiva with no permissions". Unlike the + // two shape screens, this branch is reached on an ORDINARY delegate path, so it was the + // one silent outcome an operator could actually hit. The meta must distinguish the two + // reasons — self-assignment vs an Admin-granting cargo — because the remedy differs: an + // Admin re-saves the slot in the first case, and only an Admin may seat it in the second. + const logged: { message: string; meta: Record }[] = []; + const { deps } = fakeDeps({ + positions: { "pos-presi": { grants: ["Admin"] } }, + userRoles: { "delegate-uid": ["Member"] }, + userPerms: { "delegate-uid": ["update:BoardSeat"] }, + existing: { "target-uid": { roles: ["Member"] } }, + logError: (message, meta) => logged.push({ message, meta }), + }); + await syncMemberClaims( + deps, + { + uid: "target-uid", + positions: { + "2026": { cargoId: "pos-presi", comisionIds: [], assignedBy: "delegate-uid" }, + }, + }, + "2026", + ); + expect(logged).toHaveLength(1); + expect(logged[0]?.message).toMatch(/NOT minted/); + expect(logged[0]?.meta).toMatchObject({ + uid: "target-uid", + cargoId: "pos-presi", + selfAssigned: false, + grantsAdmin: true, + assignerIsAdmin: false, + }); + }); + + it("stays quiet when the grants ARE minted", async () => { + // The paired negative: a log on the success path would bury the refusals it exists to + // surface, since every ordinary seating writes claims. + const logged: { message: string; meta: Record }[] = []; + const { deps } = fakeDeps({ + positions: { "pos-sec": { grants: ["Secretary"] } }, + userRoles: { "delegate-uid": ["Member"] }, + userPerms: { "delegate-uid": ["update:BoardSeat"] }, + existing: { "target-uid": { roles: ["Member"] } }, + logError: (message, meta) => logged.push({ message, meta }), + }); + await syncMemberClaims( + deps, + { + uid: "target-uid", + positions: { "2026": { cargoId: "pos-sec", comisionIds: [], assignedBy: "delegate-uid" } }, + }, + "2026", + ); + expect(logged).toEqual([]); + }); + it("still lets a delegate confer a non-Admin cargo on SOMEONE ELSE", async () => { // The paired ALLOW — the restriction is on self-dealing, not on the delegation itself. const { deps, writes } = fakeDeps({ @@ -460,10 +517,12 @@ describe("syncMemberClaims", () => { // than in each getPosition impl. Fails closed: no cargo means no grants. const reached: string[] = []; for (const cargoId of ["a/b", "", ".", "..", "__name__", `${"x".repeat(1501)}`]) { + const logged: { message: string; meta: Record }[] = []; const { deps, writes } = fakeDeps({ positions: { "a/b": { grants: ["Admin"] }, "": { grants: ["Admin"] } }, userRoles: { "admin-uid": ["Admin"] }, existing: { "target-uid": { roles: ["Member"] } }, + logError: (message, meta) => logged.push({ message, meta }), }); const spy: typeof deps.getPosition = async (id) => { reached.push(id); @@ -483,6 +542,37 @@ describe("syncMemberClaims", () => { expect(reached).toEqual([]); // Fails closed: the Admin grant behind that cargo is NOT minted. expect(writes["target-uid"]).toEqual({ roles: ["Member"], perms: permsFor(["Member"]) }); + // ...and NOT silently (guardrail #4). Failing closed here strips a seated member's + // roles; an operator seeing a Directiva row with no permissions has this line and + // nothing else to explain it. Pinned so the screen cannot go quiet again. + expect(logged).toHaveLength(1); + expect(logged[0].message).toMatch(/cargoId/); + // Names the member, and bounds the offending id rather than serializing 1501 chars — + // Cloud Logging drops an over-large entry whole, losing the anomaly when it is biggest. + expect(logged[0].meta).toMatchObject({ uid: "target-uid", cargoIdLength: cargoId.length }); + expect(String(logged[0].meta.cargoId).length).toBeLessThanOrEqual(65); + } + }); + + it("does NOT log the screen for a member holding no cargo at all", async () => { + // The other half of the log above, and the half that decides whether it is a signal or + // noise: `cargoId: null` fails the same `isSafeDocId` check, but it is the ordinary state + // of every member without a seat. Logging it would emit an error per member per write and + // bury the real anomaly. Absent map, absent entry and explicit null all stay quiet. + for (const positions of [ + undefined, + {}, + { "2026": { cargoId: null, comisionIds: [], assignedBy: "admin-uid" } }, + ]) { + const logged: string[] = []; + const { deps } = fakeDeps({ + positions: {}, + userRoles: {}, + existing: { "target-uid": { roles: ["Member"], perms: permsFor(["Member"]) } }, + logError: (message) => logged.push(message), + }); + await syncMemberClaims(deps, { uid: "target-uid", positions }, "2026"); + expect(logged).toEqual([]); } }); @@ -495,6 +585,7 @@ describe("syncMemberClaims", () => { // cargos, i.e. exactly the members whose claims matter most. Nothing in firestore.rules // caps assignedBy's length; the console and the admin SDK reach this shape. const reached: string[] = []; + const logged: { message: string; meta: Record }[] = []; for (const assignedBy of ["", "x".repeat(129)]) { const { deps, writes } = fakeDeps({ positions: { "pos-pres": { grants: ["Admin"] } }, @@ -502,6 +593,7 @@ describe("syncMemberClaims", () => { // below is about the screen, not about the assigner failing the trust gate. userRoles: { [assignedBy]: ["Admin"] }, existing: { "target-uid": { roles: ["Member"] } }, + logError: (message, meta) => logged.push({ message, meta }), }); const spied: ClaimsSyncDeps = { ...deps, @@ -523,6 +615,47 @@ describe("syncMemberClaims", () => { // Fails closed: the Admin grant behind that cargo is NOT minted. expect(writes["target-uid"]).toEqual({ roles: ["Member"], perms: permsFor(["Member"]) }); } + // ...and not silently. This branch is reached ONLY on a power-granting cargo, so its + // silence hid exactly the claims an operator would come looking for. The uid VALUE is + // deliberately absent from the meta: type and length identify the shape, and the member + // uid plus cargo id already locate the doc to fix. + expect(logged).toHaveLength(2); + expect(logged.map((l) => l.message)).toEqual([ + expect.stringMatching(/assignedBy/), + expect.stringMatching(/assignedBy/), + ]); + expect(logged[0].meta).toEqual({ + uid: "target-uid", + cargoId: "pos-pres", + assignedByType: "string", + assignedByLength: 0, + }); + expect(logged[1].meta).toMatchObject({ assignedByLength: 129 }); + }); + + it("logs the legacy no-assignedBy power seat too, and stays quiet off the power path", async () => { + // A power cargo with no attribution is an anomaly whatever its provenance, so the + // undefined case logs alongside the malformed ones above. But the screen sits BEHIND the + // grants check, so a grant-free cargo with no assignedBy — the ordinary shape of every + // rank-and-file seat — never reaches it and must stay quiet. + for (const [cargoId, expected] of [ + ["pos-pres", 1], + ["pos-plain", 0], + ] as const) { + const logged: string[] = []; + const { deps } = fakeDeps({ + positions: { "pos-pres": { grants: ["Admin"] }, "pos-plain": { grants: [] } }, + userRoles: {}, + existing: { "target-uid": { roles: ["Member"] } }, + logError: (message) => logged.push(message), + }); + await syncMemberClaims( + deps, + { uid: "target-uid", positions: { "2026": { cargoId, comisionIds: [] } } }, + "2026", + ); + expect(logged).toHaveLength(expected); + } }); it("ACCEPTS a uid isSafeDocId would reject — the screen is a uid contract, not a path one", async () => { diff --git a/apps/beacon/src/claims-sync/sync.ts b/apps/beacon/src/claims-sync/sync.ts index 102a95a0..5eeaad33 100644 --- a/apps/beacon/src/claims-sync/sync.ts +++ b/apps/beacon/src/claims-sync/sync.ts @@ -1,7 +1,7 @@ import type { Role } from "@luminova/auth/roles"; import type { TermPositions, PermissionCode } from "@luminova/types"; import { PERMISSION_CAP } from "@luminova/types/permission"; -import { isSafeDocId } from "../firestore-util.js"; +import { isSafeDocId, truncateForLog } from "../firestore-util.js"; import { computeMemberRoles } from "./compute-roles.js"; import { resolveMemberPerms, type RolePermsDeps } from "./resolve-member-perms.js"; @@ -110,7 +110,26 @@ async function resolveTrustedGrants( // that member re-throws. Their claims never sync again until someone edits the id out. // Screened HERE rather than in each port impl so the in-memory test fakes inherit it. // Fails closed in the right direction: no cargo means no grants. - if (!isSafeDocId(cargoId)) return []; + // Captured BEFORE the screen: `isSafeDocId` is an `id is string` predicate, so TS narrows + // the REJECTED branch to `null` and the log below could not name the offending string. + const rejectedCargoId = typeof cargoId === "string" ? cargoId : null; + if (!isSafeDocId(cargoId)) { + // Failing closed is deliberate; failing SILENTLY is not (guardrail #4). Without this the + // member is published on the world-readable Directiva seated on a cargo whose grants were + // never minted, and nothing anywhere says why — no throw, no log, no metric. A null + // cargoId is the ordinary "no cargo" case and is NOT an anomaly: logging it would fire on + // every write to every member who holds no seat. Ids only, bounded — never the doc. + if (rejectedCargoId !== null) { + deps.logError?.("claims-sync: cargoId is not a usable doc id — minting no cargo grants", { + uid: memberUid, + cargoId: truncateForLog(rejectedCargoId), + cargoIdLength: rejectedCargoId.length, + }); + } + return []; + } + // A null `position` is logged by readPositionGrants, at the site that can tell an unusable + // id from a missing doc from a malformed `grants`; re-logging here would double every line. const position = await deps.getPosition(cargoId); if (!position || position.grants.length === 0) return []; // Screened for the same reason as cargoId above, one line up: `assignedBy` reaches @@ -120,6 +139,16 @@ async function resolveTrustedGrants( // is not a path segment ("/" and reserved forms are legal in one), and the 128-char cap // isSafeDocId lacks is the half that actually bites here. Untrusted shape → no grants. if (typeof assignedBy !== "string" || assignedBy.length === 0 || assignedBy.length > 128) { + // Same silence as the cargoId screen above, and it bites harder: this branch is reached + // ONLY on a power-granting cargo, i.e. exactly the members whose missing claims matter. + // A legacy doc with no `assignedBy` lands here too and is logged on purpose — a seat that + // confers power with no attribution is the anomaly, not the routine case. + deps.logError?.("claims-sync: assignedBy is not a usable uid — minting no cargo grants", { + uid: memberUid, + cargoId, + assignedByType: typeof assignedBy, + assignedByLength: typeof assignedBy === "string" ? assignedBy.length : null, + }); return []; } const assigner = await deps.getAssignerClaims(assignedBy); @@ -131,6 +160,21 @@ async function resolveTrustedGrants( position.grants.includes("Admin") || selfAssigned ? assignerIsAdmin : assignerIsAdmin || assigner.perms.includes("update:BoardSeat"); + if (!trusted) { + // The DESIGNED refusal, not a malformed-input one — and the likeliest real cause of the + // support question this feature will generate: "they're on the Directiva with no + // permissions". It is reached on an ordinary delegate path (an Admin-granting cargo, or a + // self-assignment) and on a stale one (the assigner has since lost update:BoardSeat), and + // the outcome is identical either way. The client warns before the click, but nothing + // server-side said which of the two happened, or that anything happened at all. + deps.logError?.("claims-sync: cargo grants NOT minted — assigner is not trusted for them", { + uid: memberUid, + cargoId, + selfAssigned, + grantsAdmin: position.grants.includes("Admin"), + assignerIsAdmin, + }); + } return trusted ? [...new Set(position.grants)] : []; } diff --git a/apps/beacon/src/firestore-util.ts b/apps/beacon/src/firestore-util.ts index 1777d7d9..dc0fdea6 100644 --- a/apps/beacon/src/firestore-util.ts +++ b/apps/beacon/src/firestore-util.ts @@ -20,6 +20,16 @@ export function hasToMillis(v: unknown): v is Timestamp { * persists in the doc) until someone edits it out. * Screen instead — the id contributing nothing fails closed. * Extracted from currentCargoId, which was the first path to need it. */ +const LOG_ID_MAX_CHARS = 64; + +/** An id bounded for a structured-log field. The values screened by `isSafeDocId` run to + * 1500 bytes and Cloud Logging drops an over-large entry ENTIRELY — so serializing them raw + * loses the anomaly precisely when it is biggest. Shared so every screen's log line is + * bounded the same way. */ +export function truncateForLog(value: string): string { + return value.length > LOG_ID_MAX_CHARS ? `${value.slice(0, LOG_ID_MAX_CHARS)}…` : value; +} + export function isSafeDocId(id: unknown): id is string { if (typeof id !== "string" || id.length === 0 || id.includes("/")) return false; if (id === "." || id === "..") return false; diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts index 198d0222..d72c87d6 100644 --- a/apps/beacon/src/provision-member-login.test.ts +++ b/apps/beacon/src/provision-member-login.test.ts @@ -423,6 +423,28 @@ describe("provisionMember", () => { }); }); + it("still lets a delegate provision a member seated on a GRANT-FREE cargo", async () => { + // Seating plus inviting on a grant-free cargo mints nothing, and is exactly the enrolment + // flow the delegation exists for. Without this pair the guard above would pass for a rule + // that simply refused every seated member. + // + // NOT subsumed by the delegate half of "never receives the password-reset link", which is + // the claim this test was once deleted on. That fixture is `member: active` — no + // `positions` map at all — so `readCargoIds` yields nothing and the guard's loop body + // never executes. Only this test drives the loop to a resolved cargo and out the ALLOW + // side; a guard rewritten to refuse whenever ANY cargoId is present leaves the rest of + // this file green. + const { deps, calls } = fakeDeps({ + member: { + ...active, + positions: { [TERM]: { cargoId: "pos-dir", comisionIds: [], assignedBy: "delegate-uid" } }, + }, + positions: { "pos-dir": [] }, + }); + await expect(provisionMember(deps, "m1", false)).resolves.toMatchObject({ email: "a@b.co" }); + expect(calls.createUser).toEqual(["a@b.co"]); + }); + it("BLOCKING: a delegate never receives the password-reset link", async () => { // generatePasswordResetLink returns a bearer credential for the account. The client sends // the reset mail itself through the unprivileged sendPasswordResetEmail, so a delegate has diff --git a/apps/beacon/src/read-position-grants.test.ts b/apps/beacon/src/read-position-grants.test.ts index 3f0a5e64..8a310ebe 100644 --- a/apps/beacon/src/read-position-grants.test.ts +++ b/apps/beacon/src/read-position-grants.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; @@ -16,6 +16,16 @@ function fakeDb(docs: Record | undefined>): Fire } describe("readPositionGrants", () => { + // Every null return logs (see below), so the anomaly cases here would otherwise spray + // stderr across the run. Stubbed for all of them; the two tests that assert on it read + // this same spy. + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("returns the valid roles from a well-formed cargo", async () => { const db = fakeDb({ "positions/p1": { grants: ["Admin", "Membership"] } }); await expect(readPositionGrants(db, "p1")).resolves.toEqual(["Admin", "Membership"]); @@ -41,6 +51,40 @@ describe("readPositionGrants", () => { await expect(readPositionGrants(db, 42)).resolves.toBeNull(); }); + it("logs every null — none of them is visible to either caller otherwise", async () => { + // Guardrail #4. `null` is fail-closed in BOTH directions (grant-free to the claims trust + // gate, power-conferring to the provisioning guard) and neither throws: the trust gate + // just mints nothing and the member is published on the Directiva with no roles. Every + // shape here is an anomaly no legitimate flow produces — a routine grant-free cargo + // returns [], not null — so an operator gets one line per occurrence or nothing at all. + const spy = vi.mocked(console.error); + const db = fakeDb({ "positions/str": { grants: "Admin" } }); + await readPositionGrants(db, "a/b"); + await readPositionGrants(db, 42); + await readPositionGrants(db, "ghost"); + await readPositionGrants(db, "str"); + expect(spy.mock.calls.map(([message]) => message)).toEqual([ + expect.stringMatching(/not a usable doc id/), + expect.stringMatching(/not a usable doc id/), + expect.stringMatching(/missing/), + expect.stringMatching(/not an array/), + ]); + // Ids, never the doc — and bounded, because Cloud Logging drops an over-large entry + // whole and isSafeDocId tolerates 1500 bytes. + await readPositionGrants(db, "x".repeat(1501)); + const [, meta] = spy.mock.calls[4]; + expect(String((meta as { cargoId: string }).cargoId).length).toBeLessThanOrEqual(65); + }); + + it("stays quiet on the paths that resolve", async () => { + // The paired negative: a well-formed cargo — grant-bearing or grant-free — is the routine + // case on every member write, and logging it would drown the anomalies above. + const db = fakeDb({ "positions/p1": { grants: ["Admin"] }, "positions/p2": {} }); + await readPositionGrants(db, "p1"); + await readPositionGrants(db, "p2"); + expect(console.error).not.toHaveBeenCalled(); + }); + it("BLOCKING: returns null instead of THROWING on a non-array grants field", async () => { // firestore.rules short-circuits every grants check on hasAnyRole(['Admin']) and never // type-checks the field, so a console edit or a migration can store one of these. A diff --git a/apps/beacon/src/read-position-grants.ts b/apps/beacon/src/read-position-grants.ts index 689d04bc..d510e58e 100644 --- a/apps/beacon/src/read-position-grants.ts +++ b/apps/beacon/src/read-position-grants.ts @@ -1,6 +1,6 @@ import type { Firestore } from "firebase-admin/firestore"; import { isValidRole, type Role } from "@luminova/auth/roles"; -import { isSafeDocId } from "./firestore-util.js"; +import { isSafeDocId, truncateForLog } from "./firestore-util.js"; /** A cargo's trusted grants, or null when the id is unusable or the doc is missing. * @@ -14,9 +14,25 @@ import { isSafeDocId } from "./firestore-util.js"; * power-conferring by the guard and as grant-free by the trust gate, which is the safe * direction in each. */ export async function readPositionGrants(db: Firestore, id: unknown): Promise { - if (!isSafeDocId(id)) return null; + // Every `null` below is logged (guardrail #4). Each one is an anomaly no legitimate flow + // produces — a routine grant-free cargo returns `[]`, not null — and each is invisible + // downstream: the trust gate mints nothing and the provisioning guard refuses, with no + // throw and no metric either side. Ids only, bounded — never the doc. + if (!isSafeDocId(id)) { + console.error("positions: cargo id is not a usable doc id — cargo unresolvable", { + cargoIdType: typeof id, + cargoId: typeof id === "string" ? truncateForLog(id) : null, + cargoIdLength: typeof id === "string" ? id.length : null, + }); + return null; + } const snap = await db.doc(`positions/${id}`).get(); - if (!snap.exists) return null; + if (!snap.exists) { + console.error("positions: cargo doc is missing — cargo unresolvable", { + cargoId: truncateForLog(id), + }); + return null; + } // A non-array `grants` returns null rather than throwing. firestore.rules short-circuits // every grants check on hasAnyRole(['Admin']) and never type-checks the field, so a console // edit or a migration can store a string or a map — and `.filter` on that is a TypeError. @@ -25,7 +41,13 @@ export async function readPositionGrants(db: Firestore, id: unknown): Promise isValidRole(g)); } From 7e1b8f088fe6e662f4e8a6bb714a6a1097401646 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 11:50:12 -0400 Subject: [PATCH 13/25] fix(backstage): map the drawer's refusals; stop the invite from erasing its own result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behavioral findings from /code-review on this PR, both of which would have shipped. The invite drawer was the third entry point and the only one still storing the callable's raw message, so a delegate saw the server's English prose ("this member already has a login; only an Admin can re-provision or link one"). The PR claimed three refusals were mapped at three entry points; it was two. The raw message stays as the FALLBACK, so an untagged failure keeps the one diagnostic it gives. InviteAccess was mounted behind !inviteBlocked — a flag its own success FLIPS. beacon writes member.uid, the next useMember refetch makes memberProvisionBlocked true, the gate closes, and the component's sent/error state dies with it: for a delegate whose password mail failed, that alert is the only notice the account exists without one, and it vanishes mid-flight. `blocked` is now a prop that hides the BUTTON; the feedback stays mounted. Admins never saw this — the predicate short-circuits for them, so it bit exactly the delegate path. Also from that review: - isSelfMember extracted; three sites had it typed by hand, in the PR whose thesis is that copied predicates drift. The uid !== undefined half is the whole point — an unprovisioned member and an unresolved caller must not read as "yourself". - The CargoLike widening silently deleted a compile-time guard: `category !== "CEL"` against a structural `string` becomes always-true if POSITION_CATEGORIES renames that literal, offering every CEL cargo to a non-delegate while the rules keep denying. Pinned where Position is importable, cross-referenced from the comparison. - cargoNoteIds(prefix) replaces the four-entry ids literal both forms hand-copied. The earlier namespacing was HALF a fix and its comment claimed a property the code did not have: the two shared notes are shared COMPONENTS, so two mounted forms emitted byte-identical ids. All four are prefix-derived now. - The pass-through re-export in assignable-cargo.ts is gone. It read as convenience and was a barrel that erased the one distinction the split exists to make legible — whether the parity test holds a predicate to firestore.rules. - useCopyToClipboard's closures memoized; a cargo-lookup parameter renamed off a shadow of the created member's doc id. Both behavioral fixes mutation-tested: reverting either turns exactly its own tests red, and the un-prefixed id turns four. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backstage/src/components/member-home.tsx | 3 +- .../members/components/member-drawer.tsx | 3 +- .../members/components/member-form.test.tsx | 6 +- .../members/components/member-form.tsx | 30 ++-- .../components/member-invite-drawer.test.tsx | 89 ++++++++++ .../components/member-invite-drawer.tsx | 13 +- .../components/member-positions-form.test.tsx | 6 +- .../components/member-positions-form.tsx | 30 ++-- .../components/member-profile-page.test.tsx | 114 ++++++++++++- .../components/member-profile-page.tsx | 27 ++- .../no-assignable-cargos-note.test.tsx | 161 ++++++++++++++++++ .../components/no-assignable-cargos-note.tsx | 43 +++-- .../members/lib/assignable-cargo-core.ts | 22 ++- .../members/lib/assignable-cargo.test.ts | 6 +- .../features/members/lib/assignable-cargo.ts | 47 ++++- .../members/lib/member-permissions.test.ts | 39 ++++- .../members/lib/member-permissions.ts | 15 ++ .../src/lib/use-copy-to-clipboard.test.ts | 24 +++ .../src/lib/use-copy-to-clipboard.ts | 12 +- 19 files changed, 596 insertions(+), 94 deletions(-) create mode 100644 apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx diff --git a/apps/backstage/src/components/member-home.tsx b/apps/backstage/src/components/member-home.tsx index 2bbf95c6..a6bee5d5 100644 --- a/apps/backstage/src/components/member-home.tsx +++ b/apps/backstage/src/components/member-home.tsx @@ -15,6 +15,7 @@ import { useActivitiesByTerm } from "../features/activities/hooks/use-activities import { useInitiativesByTerm } from "../features/initiatives/hooks/use-initiatives-by-term"; import { usePositions } from "../features/positions/hooks/use-positions"; import { joinYear } from "../features/members/lib/member-display"; +import { isSelfMember } from "../features/members/lib/member-permissions"; import { summarizeParticipations } from "../features/members/lib/participation-summary"; import { MemberPointsSummary } from "../features/members/components/member-points-summary"; import { MemberCredentialCard } from "../features/members/components/member-credential-card"; @@ -92,7 +93,7 @@ export function MemberHome() { // at a role, so the honest mirror is doc ownership — NOT the CASL own-doc grant, which // only members carrying the built-in Member role hold (a roles:["Treasury"] principal // would lose a self-edit the rules would have accepted). - const canEditSelf = member.uid !== undefined && member.uid === uid; + const canEditSelf = isSelfMember(member, uid); const cargoId = member.positions?.[termId]?.cargoId ?? null; const cargo = cargoId ? positionsById.get(cargoId) : null; diff --git a/apps/backstage/src/features/members/components/member-drawer.tsx b/apps/backstage/src/features/members/components/member-drawer.tsx index 7dc5c276..a1e9fd29 100644 --- a/apps/backstage/src/features/members/components/member-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-drawer.tsx @@ -14,6 +14,7 @@ import { MemberForm } from "./member-form"; import { joinYear, memberPositionLabel } from "../lib/member-display"; import { memberFormDefaults } from "../lib/member-form-defaults"; import { useMemberPhoto } from "../hooks/use-member-photo"; +import { isSelfMember } from "../lib/member-permissions"; import { Can } from "../../../lib/authz/ability-context"; import { useAuth } from "../../../lib/auth/auth"; import { useCan } from "../../../lib/authz/use-can"; @@ -156,7 +157,7 @@ function EditBody({ allowPowerGrants={canAssignBoardSeat} allowReplacePowerCargo={isAdmin} assignerIsAdmin={isAdmin} - isSelfAssignment={member.uid !== undefined && member.uid === uid} + isSelfAssignment={isSelfMember(member, uid)} onSubmit={onSubmit} />

diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index bcdd9720..78b8adfc 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -3,11 +3,15 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { MemberInput, Position } from "@luminova/types"; import { MemberForm } from "./member-form"; -import { MINT_PENDING_NOTE_ID } from "./no-assignable-cargos-note"; +import { cargoNoteIds } from "./no-assignable-cargos-note"; import { toMemberUpdateDoc } from "../repositories/member-mapper"; import { pickDate } from "../../../test/pick-date"; import { permissionLabel } from "../../permissions/lib/permission-matrix"; +// Through the same helper the form calls, not a hand-typed literal: the ids are no longer +// exported individually, and a test that re-typed one would keep passing after a rename. +const MINT_PENDING_NOTE_ID = cargoNoteIds("member").mintPending; + // The note names the permission through `permissionLabel`, and its own comment says the two // features must not drift. Assert against the same source, not a hardcoded copy — a literal // here would keep passing after either half of the label is renamed, which is exactly the diff --git a/apps/backstage/src/features/members/components/member-form.tsx b/apps/backstage/src/features/members/components/member-form.tsx index da023d49..2a4f07b8 100644 --- a/apps/backstage/src/features/members/components/member-form.tsx +++ b/apps/backstage/src/features/members/components/member-form.tsx @@ -27,19 +27,14 @@ import { cargoGrantNeedsAdminAssigner, cargoNoteId, cargoOptionsForEditor, - cargoTakedownOnly, noAssignableCargos, - positionsLockedForEditor, } from "../lib/assignable-cargo"; -import { - MintPendingNote, - MINT_PENDING_NOTE_ID, - NoAssignableCargosNote, - NO_ASSIGNABLE_CARGOS_NOTE_ID, -} from "./no-assignable-cargos-note"; +// Directly from the rules-mirroring module, not through assignable-cargo.ts: the file a +// predicate comes from is what says the emulator parity test holds it to firestore.rules. +import { cargoTakedownOnly, positionsLockedForEditor } from "../lib/assignable-cargo-core"; +import { cargoNoteIds, MintPendingNote, NoAssignableCargosNote } from "./no-assignable-cargos-note"; -const LOCKED_NOTE_ID = "member-cargo-locked-note"; -const TAKEDOWN_NOTE_ID = "member-cargo-takedown-note"; +const NOTE_IDS = cargoNoteIds("member"); interface MemberFormProps { positions: Position[]; @@ -165,12 +160,7 @@ export function MemberForm({ // never meets the reason. Priority order and the co-firing rules live in cargoNoteId(). const describedBy = cargoNoteId( { noCargos, locked: positionsLocked, takedown: cargoTakedown, mintPending }, - { - noCargos: NO_ASSIGNABLE_CARGOS_NOTE_ID, - locked: LOCKED_NOTE_ID, - takedown: TAKEDOWN_NOTE_ID, - mintPending: MINT_PENDING_NOTE_ID, - }, + NOTE_IDS, ); const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title); @@ -345,22 +335,22 @@ export function MemberForm({

)} {positionsLocked && ( -

+

Solo un administrador puede cambiar el cargo de un miembro cuyo cargo otorga permisos. Puedes editar el resto de sus datos.

)} {/* Suppressed while locked: the picker is disabled there, so nothing about what the save would mint is actionable. */} - {!positionsLocked && mintPending && } + {!positionsLocked && mintPending && } {cargoTakedown && ( -

+

Este cargo es del Comité Ejecutivo Local: solo un administrador puede asignarlo. Puedes quitárselo con «Quitar cargo» o dejarlo como está; el resto de sus datos se guarda igual.

)} - {noCargos && } + {noCargos && } { expect(await screen.findByText(/desde el menú de su fila/)).toBeInTheDocument(); }); + // --- provision refusals: tagged reason vs raw diagnostic --- + + /** A rejection shaped like the callable's: a FirebaseError carries the server's English prose + * as `message` and the machine-readable refusal under `details.reason`. */ + function provisionRefusal(reason: string, message: string) { + return Object.assign(new Error(message), { details: { reason } }); + } + + // BLOCKING: this drawer was the third and last provisioning entry point, and the only one + // still rendering the server's raw English prose to a Spanish-speaking operator. The row menu + // and the profile header already routed refusals through provisionErrorMessage; a delegate + // who hit `reprovision-requires-admin` here read "this member already has a login…" and had + // no idea an Admin could finish it — so they retried the invite forever. + it("BLOCKING: maps a TAGGED provision refusal to its Spanish message", async () => { + const onProvision = vi + .fn() + .mockRejectedValue( + provisionRefusal( + "reprovision-requires-admin", + "this member already has a login; only an Admin can re-send it", + ), + ); + renderWithAbility( + {}} + onCreate={async () => "idTagged"} + onProvision={onProvision} + />, + { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] }, + ); + await fill(); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + expect( + await screen.findByText( + /Ya existe un acceso para este correo\. Pídele a un administrador que lo reenvíe o lo vincule\./, + ), + ).toBeInTheDocument(); + // The raw prose must be GONE, not merely accompanied — it is the thing being replaced. + expect(screen.queryByText(/this member already has a login/)).not.toBeInTheDocument(); + // The member was still created, so the done screen is guidance, not a create failure. + expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument(); + }); + + // The other half of the same line, and the reason the raw message stays as the FALLBACK: an + // App Check / quota / config failure carries no `details.reason`, and its message is the one + // diagnostic anybody gets. A fix that mapped everything to a generic Spanish sentence would + // pass the test above and destroy this. + it("BLOCKING: keeps the raw message for an UNTAGGED provision failure", async () => { + const onProvision = vi.fn().mockRejectedValue(new Error("AppCheck token is invalid")); + renderWithAbility( + {}} + onCreate={async () => "idUntagged"} + onProvision={onProvision} + />, + { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] }, + ); + await fill(); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + expect(await screen.findByText(/Detalle: AppCheck token is invalid/)).toBeInTheDocument(); + }); + + // A `reason` that is not in the table — beacon adding one before the client ships the copy — + // falls back the same way. Pinned separately because a Map lookup returning `undefined` and a + // plain-object lookup returning `Object.prototype.toString` are both "not found", and only + // one of them renders a function into the DOM. + it("falls back to the raw message for an UNKNOWN tagged reason", async () => { + const onProvision = vi + .fn() + .mockRejectedValue(provisionRefusal("some-future-reason", "server said no")); + renderWithAbility( + {}} + onCreate={async () => "idUnknown"} + onProvision={onProvision} + />, + { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] }, + ); + await fill(); + fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); + expect(await screen.findByText(/Detalle: server said no/)).toBeInTheDocument(); + }); + // beacon withholds the action link from a non-Admin caller (it is a bearer credential for // the account), so a delegate whose reset mail then fails has NO manual fallback — the copy // must send them to an Admin rather than to a copy button that would copy nothing. Only diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx index 71297080..d508cca8 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx @@ -5,6 +5,7 @@ import { MemberForm } from "./member-form"; import { actionMessage } from "../lib/member-display"; import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; import { draftProvisionBlocked } from "../lib/provision-gate"; +import { provisionErrorMessage } from "../lib/provision-error"; import { useCopyToClipboard } from "../../../lib/use-copy-to-clipboard"; import { useCan } from "../../../lib/authz/use-can"; @@ -85,7 +86,10 @@ export function MemberInviteDrawer({ // the row menu and the profile header — see provision-gate.ts. const provisionBlocked = draftProvisionBlocked( data.cargoId, - (id) => positions.find((p) => p.id === id), + // NOT `id` — that name is the CREATED MEMBER's doc id, bound above and passed to + // onProvision. Both are strings, so shadowing it here would let a later edit resolve + // the wrong document with no type error. + (cargoId) => positions.find((p) => p.id === cargoId), isAdmin, ); // Nothing is attempted when blocked: the done screen explains it instead of reporting a @@ -109,7 +113,12 @@ export function MemberInviteDrawer({ } } catch (err) { console.error("No se pudo aprovisionar el acceso del miembro", err); - errorDetail = err instanceof Error ? err.message : String(err); + // Route the callable's REFUSALS through the same table the row menu and the profile + // header use — this was the third entry point and the only one still surfacing the + // server's raw English prose ("this member already has a login; only an Admin…"). + // The raw message stays as the FALLBACK, so an untagged failure (App Check, quota, + // config) still shows the one diagnostic we get. + errorDetail = provisionErrorMessage(err, err instanceof Error ? err.message : String(err)); } } setDone({ diff --git a/apps/backstage/src/features/members/components/member-positions-form.test.tsx b/apps/backstage/src/features/members/components/member-positions-form.test.tsx index 1eebaa9f..9dc183c9 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.test.tsx @@ -3,9 +3,13 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Position } from "@luminova/types"; import { MemberPositionsForm } from "./member-positions-form"; -import { MINT_PENDING_NOTE_ID } from "./no-assignable-cargos-note"; +import { cargoNoteIds } from "./no-assignable-cargos-note"; import { permissionLabel } from "../../permissions/lib/permission-matrix"; +// Through the same helper the form calls, not a hand-typed literal: the ids are no longer +// exported individually, and a test that re-typed one would keep passing after a rename. +const MINT_PENDING_NOTE_ID = cargoNoteIds("positions").mintPending; + // The mint-pending note used to say "permisos de administrador", which was true only of its // one original trigger. It now fires for a SELF-assignment of any granting cargo — a // Secretario, say — so copy naming administrator permissions would be a lie in that case. diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx index bb51b3c6..3ff393ea 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -8,19 +8,14 @@ import { cargoGrantNeedsAdminAssigner, cargoNoteId, cargoOptionsForEditor, - cargoTakedownOnly, noAssignableCargos, - positionsLockedForEditor, } from "../lib/assignable-cargo"; -import { - MintPendingNote, - MINT_PENDING_NOTE_ID, - NoAssignableCargosNote, - NO_ASSIGNABLE_CARGOS_NOTE_ID, -} from "./no-assignable-cargos-note"; +// Directly from the rules-mirroring module, not through assignable-cargo.ts: the file a +// predicate comes from is what says the emulator parity test holds it to firestore.rules. +import { cargoTakedownOnly, positionsLockedForEditor } from "../lib/assignable-cargo-core"; +import { cargoNoteIds, MintPendingNote, NoAssignableCargosNote } from "./no-assignable-cargos-note"; -const LOCKED_NOTE_ID = "positions-cargo-locked-note"; -const TAKEDOWN_NOTE_ID = "positions-cargo-takedown-note"; +const NOTE_IDS = cargoNoteIds("positions"); const positionsSchema = z.object({ cargoId: z.string().min(1).nullable(), @@ -97,12 +92,7 @@ export function MemberPositionsForm({ // never meets the reason. Priority order and the co-firing rules live in cargoNoteId(). const describedBy = cargoNoteId( { noCargos, locked, takedown: takedownOnly, mintPending }, - { - noCargos: NO_ASSIGNABLE_CARGOS_NOTE_ID, - locked: LOCKED_NOTE_ID, - takedown: TAKEDOWN_NOTE_ID, - mintPending: MINT_PENDING_NOTE_ID, - }, + NOTE_IDS, ); const comisionOptions = positions .filter((p) => p.active && p.category === "Comision") @@ -165,20 +155,20 @@ export function MemberPositionsForm({ /> {locked && ( -

+

Solo un administrador puede cambiar los cargos de un miembro cuyo cargo otorga permisos.

)} {/* Suppressed while locked: the picker is disabled there, so nothing about what the save would mint is actionable. */} - {!locked && mintPending && } + {!locked && mintPending && } {takedownOnly && ( -

+

Este cargo es del Comité Ejecutivo Local: solo un administrador puede asignarlo. Puedes quitárselo con «Quitar cargo» y guardar, o elegir otro cargo.

)} - {noCargos && } + {noCargos && } {formError && (
{formError} diff --git a/apps/backstage/src/features/members/components/member-profile-page.test.tsx b/apps/backstage/src/features/members/components/member-profile-page.test.tsx index 58d1f37d..6dcdc6a9 100644 --- a/apps/backstage/src/features/members/components/member-profile-page.test.tsx +++ b/apps/backstage/src/features/members/components/member-profile-page.test.tsx @@ -102,19 +102,31 @@ function provisionResolvesWith(result: { email: string; actionLink: string }) { }); } -function renderPage(claims: AuthClaims = roleClaims("Admin")) { - // The sidebar panels still run their own real queries (roles, etc.); a throwaway client with - // retries off keeps them from retrying against a mock-less Firestore for the whole test. - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( +function pageTree(claims: AuthClaims, queryClient: QueryClient) { + return ( - , + ); } +function renderPage(claims: AuthClaims = roleClaims("Admin")) { + // The sidebar panels still run their own real queries (roles, etc.); a throwaway client with + // retries off keeps them from retrying against a mock-less Firestore for the whole test. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render(pageTree(claims, queryClient)); + return { + ...view, + /** Re-render the SAME tree after mutating `memberQuery.data` — what the real page does when + * `useMember` refetches. Identical element type at the identical position, so React keeps + * the subtree mounted and any state it holds survives; that is precisely the property + * under test below. */ + refetchMember: () => view.rerender(pageTree(claims, queryClient)), + }; +} + describe("MemberProfilePage — InviteAccess", () => { beforeEach(() => { vi.clearAllMocks(); @@ -263,6 +275,96 @@ describe("MemberProfilePage — InviteAccess", () => { }); }); +// A successful invite makes `memberProvisionBlocked` TRUE — beacon writes `member.uid`, and +// `hasLogin` is the first clause of the gate. So the flag that decides whether to offer the +// button flips as a RESULT of pressing it. It therefore cannot gate the mount. +describe("MemberProfilePage — InviteAccess survives its own success", () => { + // A delegate, not an Admin: memberProvisionBlocked short-circuits to false for an Admin, so + // the flag never flips for them and none of this is reachable. + const DELEGATE: AuthClaims = { roles: ["Member"], perms: ["read:Member", "create:MemberLogin"] }; + + beforeEach(() => { + vi.clearAllMocks(); + mockedRequestPasswordReset.mockResolvedValue(undefined); + memberQuery.data = member(); + }); + + // BLOCKING: the whole finding. beacon created the login but the reset MAIL failed, and beacon + // withholds the action link from a delegate — so this alert is the ONLY notice anywhere that + // an account now exists with no password mail sent. Gating the mount on `!inviteBlocked` + // unmounted the component on the very next refetch and deleted that notice, leaving a page + // that looks like nothing happened, on a member who can no longer be invited. + it("BLOCKING: keeps the mail-failure alert after `blocked` flips true", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + mockedRequestPasswordReset.mockRejectedValue(new Error("network")); + const { refetchMember } = renderPage(DELEGATE); + + // Not blocked yet: no uid, no grants, no seat — the button is offered. + expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un administrador que lo reenvíe.", + ); + + // What beacon actually did: the member now carries a uid, so the next refetch blocks. + memberQuery.data = member({ uid: "minted-uid" }); + refetchMember(); + + // The BUTTON is gone — the callable would refuse a second attempt from this caller… + expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument(); + // …and the alert is STILL the same node, not a re-created one: unmounting InviteAccess + // would have reset `error` to null and rendered nothing at all. + expect(screen.getByRole("alert")).toBe(alert); + expect(screen.getByRole("alert")).toHaveTextContent(/no se pudo enviar el correo/); + }); + + // The success half of the same sequence. Same unmount, same erasure — the delegate would be + // left unable to tell a completed invite from one that never ran. + it("BLOCKING: keeps the sent confirmation after `blocked` flips true", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + const { refetchMember } = renderPage(DELEGATE); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument(); + + memberQuery.data = member({ uid: "minted-uid" }); + refetchMember(); + + expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument(); + }); + + // The control: the gate is still a gate. A member who was ALREADY provisioned before the page + // loaded gets no button at all — `blocked` hides it on the first render too, not only after a + // flip, so moving it off the mount did not turn it into a no-op. + it("hides the button from the first render for an already-provisioned member", () => { + memberQuery.data = member({ uid: "existing-uid" }); + renderPage(DELEGATE); + expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument(); + }); + + // …and the perm gate is untouched: it still decides whether InviteAccess mounts AT ALL. + it("mounts nothing for a caller without create:MemberLogin", () => { + renderPage({ roles: ["Member"], perms: ["read:Member"] }); + expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument(); + }); + + // An ADMIN is never blocked, so the button stays offered as "Reenviar acceso" after the same + // flip — the branch that proves `blocked`, not merely `member.uid`, is what hides it. + it("keeps offering a resend to an Admin after the same flip", async () => { + provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + const { refetchMember } = renderPage(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument(); + + memberQuery.data = member({ uid: "minted-uid" }); + refetchMember(); + + expect(screen.getByRole("button", { name: "Reenviar acceso" })).toBeInTheDocument(); + expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument(); + }); +}); + // The four call sites all pass `allowReplacePowerCargo={isAdmin}` — a value the two form unit // tests receive as a prop and therefore cannot police. These cover the profile page's two, the // only place a delegate meets a seated member. diff --git a/apps/backstage/src/features/members/components/member-profile-page.tsx b/apps/backstage/src/features/members/components/member-profile-page.tsx index 2f20aeaa..ee9f9181 100644 --- a/apps/backstage/src/features/members/components/member-profile-page.tsx +++ b/apps/backstage/src/features/members/components/member-profile-page.tsx @@ -27,7 +27,7 @@ import { MemberPermissionsPanel } from "./member-permissions-panel"; import { MemberPositionHistory } from "./member-position-history"; import { MemberPointsSummary } from "./member-points-summary"; import { ParticipationLedger } from "./participation-ledger"; -import { effectiveRoles } from "../lib/member-permissions"; +import { effectiveRoles, isSelfMember } from "../lib/member-permissions"; import { memberEditMode } from "../lib/member-edit-gate"; import { provisionErrorMessage } from "../lib/provision-error"; import { memberProvisionBlocked } from "../lib/provision-gate"; @@ -109,7 +109,7 @@ export function MemberProfilePage() { const showPositionsOnly = editMode === "positions"; // Member editing is split across two rules lanes; point the caller at the other one // instead of leaving "where do I edit this" to depend on whose profile it is. - const isSelf = member.uid !== undefined && member.uid === uid; + const isSelf = isSelfMember(member, uid); // Fails closed while the catalog is still loading: an unresolvable cargo counts as // power-conferring, so a delegate sees the invite appear once positions land rather than // seeing it offered and then denied. An Admin is unaffected — the predicate short-circuits. @@ -140,8 +140,14 @@ export function MemberProfilePage() { mirrors every refusal the callable applies to a non-Admin, so a delegate is not shown a button that 403s on every click. Same predicate as the row menu and the invite drawer, deliberately. */} - - + {/* Gated on the PERM only. `inviteBlocked` goes to InviteAccess as a prop rather + than gating the mount, because a successful invite FLIPS it: beacon writes + member.uid, the next refetch makes memberProvisionBlocked true (hasLogin), and + unmounting here would destroy the component's own "enviada" / "no se pudo + enviar el correo" state mid-flight — deleting, for a delegate, the only notice + that the account exists with no password mail sent. */} + +
} @@ -238,7 +244,7 @@ export function MemberProfilePage() { ); } -function InviteAccess({ member }: { member: Member }) { +function InviteAccess({ member, blocked }: { member: Member; blocked: boolean }) { const provision = useProvisionMemberLogin(); const [link, setLink] = useState(null); const [sent, setSent] = useState(false); @@ -298,9 +304,14 @@ function InviteAccess({ member }: { member: Member }) { return ( <> - + {/* The BUTTON goes away when the callable would refuse; the feedback below does not. + `blocked` becomes true the moment this invite succeeds (the member now has a uid), + so gating the whole component on it would erase the result of the click that set it. */} + {!blocked && ( + + )} {error && (

{error} diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx new file mode 100644 index 00000000..bba2b073 --- /dev/null +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import type { Position } from "@luminova/types"; +import { cargoNoteIds } from "./no-assignable-cargos-note"; +import { MemberForm } from "./member-form"; +import { MemberPositionsForm } from "./member-positions-form"; + +// A catalog whose only cargo confers a role. Two states fall out of it and they are the two +// this file needs: +// cargoId null → every option filtered away for a non-delegate → the noCargos note, +// which is a SHARED component (both forms render the same element). +// cargoId set → positionsLockedForEditor → the locked note, which each form renders +// itself with its own wording. +const POWER_CARGO: Position = { + id: "pos-power", + title: "Secretario", + titleFemale: "Secretaria", + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, +}; + +/** + * The cargo Combobox's trigger, scoped to ONE form's container. + * + * Selected structurally rather than by id or label, because BOTH forms give their trigger the + * same `id="cargoId"` (each pairs with its own `

+

Ningún cargo del catálogo es asignable con tus permisos. Los cargos del Comité Ejecutivo Local y los que otorgan permisos requieren un administrador o el permiso « {permissionLabel("update:BoardSeat")}». @@ -27,14 +50,12 @@ export function NoAssignableCargosNote() { ); } -export const MINT_PENDING_NOTE_ID = "cargo-mint-pending-note"; - /** Why a cargo this editor CAN assign will not actually confer its permissions — see * `cargoGrantNeedsAdminAssigner`. Shared by both forms for the same reason as the note above: * the wording does not differ between them, so a second copy would only drift. */ -export function MintPendingNote() { +export function MintPendingNote({ id }: { id: string }) { return ( -

+

Este cargo otorga permisos, pero no se aplicarán hasta que un administrador confirme la asignación. El cargo sí queda registrado y visible.

diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index 2010ea21..66a208b7 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -10,13 +10,16 @@ * `positionTitle` from it for VALUE, so importing that file there throws. This module has ZERO * imports, which is the same trick `nav-equivalence.test.ts` documents for `nav-config.ts`. * - * Nothing changed shape: `assignable-cargo.ts` re-exports the two predicates it used to own - * (`positionsLockedForEditor`, `cargoTakedownOnly`) and wraps `cargoSlotsForEditor` back into - * `cargoOptionsForEditor`, so every call site and the existing unit test import exactly what - * they always did. What deliberately did NOT move: the render states - * (`noAssignableCargos`, `cargoNoteId`), the labelling itself, and - * `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust gate, not a rules predicate, - * so no rules parity test can hold it and it has no business here. + * Import `positionsLockedForEditor` / `cargoTakedownOnly` from HERE, not through + * `assignable-cargo.ts` — it used to re-export them, which read as a convenience and was + * really a barrel that erased the one distinction this split exists to make legible: a + * predicate's file says whether the emulator parity test holds it to `firestore.rules`. + * `cargoSlotsForEditor` is the exception, wrapped by `cargoOptionsForEditor` because the + * labelling needs `positionTitle`. + * + * What deliberately did NOT move: the render states (`noAssignableCargos`, `cargoNoteId`), the + * labelling itself, and `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust gate, + * not a rules predicate, so no rules parity test can hold it and it has no business here. */ /** @@ -65,6 +68,11 @@ export interface CargoLike { // from this raw predicate is how the two forms drifted apart in the first place. Exporting it // again would give that back. function cargoAssignableByNonAdmin(cargo: Pick): boolean { + // The "CEL" literal is compared against a structural `string` (see CargoLike's tradeoff), so + // a rename in POSITION_CATEGORIES would make this always-true and silently offer every CEL + // cargo to a non-delegate. `assignable-cargo.ts` holds the typed pin that fails to compile + // instead — it cannot live here, since this module may not import the union. If you change + // this literal, change it there too. return cargo.grants.length === 0 && cargo.category !== "CEL"; } diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts index 3183810a..77099acd 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts @@ -4,11 +4,13 @@ import { cargoGrantNeedsAdminAssigner, cargoNoteId, cargoOptionsForEditor, - cargoTakedownOnly, noAssignableCargos, - positionsLockedForEditor, type CargoOption, } from "./assignable-cargo"; +// From the rules-mirroring module directly. This test file covers BOTH halves, and which +// import line a predicate is on is the only thing that says whether the emulator parity test +// (tests/firestore-rules/cargo-assignment-parity.test.ts) also holds it to firestore.rules. +import { cargoTakedownOnly, positionsLockedForEditor } from "./assignable-cargo-core"; const cargo = ( category: Position["category"], diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index 96d959a2..d4dc871e 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -1,6 +1,21 @@ -import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types"; +import { + currentTermKey, + positionTitle, + type MemberGender, + type Position, + type PositionCategory, +} from "@luminova/types"; import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core"; +// The guard the CargoLike widening gave up, bought back here where Position IS importable. +// assignable-cargo-core compares `category !== "CEL"` against a structural `string`, so if +// POSITION_CATEGORIES ever renames or recases that literal, the comparison silently becomes +// always-true — the client would offer every CEL cargo to a non-delegate while the rules keep +// denying `category != 'CEL'`, on the publication boundary that module exists to mirror. With +// the literal typed, a rename fails to compile HERE instead. +const CEL: PositionCategory = "CEL"; +void CEL; + /** * The predicates that MIRROR firestore.rules — `positionsLockedForEditor`, * `cargoTakedownOnly` and the option ceiling — live in `./assignable-cargo-core`, an @@ -10,13 +25,17 @@ import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core" * THIS module would throw at load. Same trick `nav-equivalence.test.ts` documents for * `nav-config.ts`. * - * They are re-exported here, so this module stays the single import site for every call site - * and for `assignable-cargo.test.ts` — nothing outside the two files knows about the split. - * What stayed: the render states (`noAssignableCargos`, `cargoNoteId`), the labelling half of - * `cargoOptionsForEditor`, and `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust - * gate, not a rules predicate, so the rules-parity module is the wrong home for it. + * Every caller imports those two DIRECTLY from `./assignable-cargo-core`, and that is the + * point rather than an inconvenience: which file a predicate comes from is what says whether + * the emulator parity test holds it to `firestore.rules` or whether it is local render state. + * A pass-through re-export here erased exactly that distinction (and was a barrel besides — + * CLAUDE.md: import directly from the file). + * + * What lives HERE: the render states (`noAssignableCargos`, `cargoNoteId`), the labelling half + * of `cargoOptionsForEditor`, and `cargoGrantNeedsAdminAssigner` — which mirrors BEACON's trust + * gate, not a rules predicate, so the rules-parity module is the wrong home for it. Nothing in + * this file is covered by the parity test. */ -export { cargoTakedownOnly, positionsLockedForEditor } from "./assignable-cargo-core"; /** * A seat the editor may WRITE but whose grants will not be MINTED — the one outcome in this @@ -87,11 +106,21 @@ export function noAssignableCargos(input: { * would mint. * * The ids differ per form (the locked and takedown wordings legitimately differ between them), - * so they are passed in rather than owned here. + * so they are passed in rather than owned here. Build the argument with `cargoNoteIds(prefix)` + * (./../components/no-assignable-cargos-note) — never by hand: this signature is the only + * thing that would notice a form that forgot one, and it cannot notice a copy that has all + * four but points one at the OTHER form's element. */ +export interface CargoNoteIds { + noCargos: string; + locked: string; + takedown: string; + mintPending: string; +} + export function cargoNoteId( state: { noCargos: boolean; locked: boolean; takedown: boolean; mintPending: boolean }, - ids: { noCargos: string; locked: string; takedown: string; mintPending: string }, + ids: CargoNoteIds, ): string | undefined { if (state.noCargos) return ids.noCargos; if (state.locked) return ids.locked; diff --git a/apps/backstage/src/features/members/lib/member-permissions.test.ts b/apps/backstage/src/features/members/lib/member-permissions.test.ts index 7f3a0032..85b9f6ab 100644 --- a/apps/backstage/src/features/members/lib/member-permissions.test.ts +++ b/apps/backstage/src/features/members/lib/member-permissions.test.ts @@ -1,6 +1,43 @@ import { describe, expect, it } from "vitest"; import type { Position } from "@luminova/types"; -import { effectiveRoles } from "./member-permissions"; +import { effectiveRoles, isSelfMember } from "./member-permissions"; + +// The full truth table over (member.uid, caller uid), because the interesting cell is the one +// a bare `member.uid === uid` gets WRONG and no other cell notices. Three call sites had this +// typed by hand before it was extracted (both cargo editors and /me's self-edit gate). +describe("isSelfMember", () => { + it("is true only when a present uid matches the caller", () => { + expect(isSelfMember({ uid: "u1" }, "u1")).toBe(true); + }); + + it("is false for a different caller", () => { + expect(isSelfMember({ uid: "u1" }, "u2")).toBe(false); + }); + + // BLOCKING: the trap the function exists for. An UNPROVISIONED member has no uid — the + // commonest doc shape in the collection — and a caller whose auth has not resolved yet has + // no uid either, so `undefined === undefined` calls a stranger "yourself". That answer feeds + // `isSelfAssignment`, which decides whether a delegate is warned that seating this member + // mints nothing: get it wrong and the warning fires on the wrong person. + it("BLOCKING: is false when BOTH are undefined", () => { + expect(isSelfMember({ uid: undefined }, undefined)).toBe(false); + }); + + it("is false for a member with no uid and a resolved caller", () => { + expect(isSelfMember({ uid: undefined }, "u1")).toBe(false); + }); + + it("is false for a provisioned member and an unresolved caller", () => { + expect(isSelfMember({ uid: "u1" }, undefined)).toBe(false); + }); + + // A member doc that never carried the key at all, not just one holding `undefined`. Same + // answer, but it is the shape Firestore actually returns for an unlinked member. + it("is false when the member doc has no uid key at all", () => { + expect(isSelfMember({}, undefined)).toBe(false); + expect(isSelfMember({}, "u1")).toBe(false); + }); +}); const pos = (id: string, grants: Position["grants"]): Position => ({ id, diff --git a/apps/backstage/src/features/members/lib/member-permissions.ts b/apps/backstage/src/features/members/lib/member-permissions.ts index bacecb60..fd3a4967 100644 --- a/apps/backstage/src/features/members/lib/member-permissions.ts +++ b/apps/backstage/src/features/members/lib/member-permissions.ts @@ -1,5 +1,20 @@ import { ROLES, type Member, type Position, type Role } from "@luminova/types"; +/** + * Whether this member doc IS the signed-in user. + * + * The `uid !== undefined` half is the whole reason this is a function: an unprovisioned member + * has no `uid`, and so does a caller whose auth has not resolved yet, so a bare `===` reads + * `undefined === undefined` and calls a stranger "yourself". That answer feeds + * `isSelfAssignment`, which decides whether the delegate is warned that seating this member + * mints nothing — get it wrong and the warning fires on the wrong person, or not at all. + * + * Three call sites had this typed out by hand (both cargo editors and /me's self-edit gate). + */ +export function isSelfMember(member: Pick, uid: string | undefined): boolean { + return member.uid !== undefined && member.uid === uid; +} + // Cargo grants only — comisiones are chips-only, matching the claims-sync trigger, which // ignores comisión grants. This is the UPPER BOUND of what the trigger will mint, not an // exact mirror: resolveTrustedGrants (apps/beacon/src/claims-sync/sync.ts) additionally diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.test.ts b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts index 487cb8a3..552c7bff 100644 --- a/apps/backstage/src/lib/use-copy-to-clipboard.test.ts +++ b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts @@ -71,6 +71,30 @@ describe("useCopyToClipboard", () => { expect(result.current.copyState).toBe("idle"); }); + // BLOCKING: both callbacks are useCallback-wrapped, and the identity is the contract, not an + // optimization. The invite drawer captures `resetCopyState` in `reset()`, `reset()` in + // `close()`, and hands `close()` to the Sheet's `onOpenChange` — a fresh closure per render + // changes that prop on every render of the drawer. Asserted across a rerender AND across a + // state change, because a `useCallback(fn, [copyState])` would pass the first and fail the + // second while looking stable in casual use. + it("BLOCKING: keeps copy and resetCopyState referentially stable across renders", async () => { + stubClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + const { result, rerender } = renderHook(() => useCopyToClipboard()); + const firstCopy = result.current.copy; + const firstReset = result.current.resetCopyState; + + rerender(); + expect(result.current.copy).toBe(firstCopy); + expect(result.current.resetCopyState).toBe(firstReset); + + // …and still stable after the hook's own state moves, which is when a dependency-carrying + // callback would quietly get a new identity. + await act(async () => result.current.copy("x")); + expect(result.current.copyState).toBe("copied"); + expect(result.current.copy).toBe(firstCopy); + expect(result.current.resetCopyState).toBe(firstReset); + }); + // A retry after a failure has to be able to succeed: `copy` sets state on BOTH outcomes, so // nothing has to be reset in between. If it only ever set "failed" the button would stay // wrong for the rest of the session. diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.ts b/apps/backstage/src/lib/use-copy-to-clipboard.ts index 01007d6a..d36a176c 100644 --- a/apps/backstage/src/lib/use-copy-to-clipboard.ts +++ b/apps/backstage/src/lib/use-copy-to-clipboard.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; export type CopyState = "idle" | "copied" | "failed"; @@ -18,7 +18,10 @@ export function useCopyToClipboard(): { resetCopyState: () => void; } { const [copyState, setCopyState] = useState("idle"); - const copy = (text: string) => { + // Stable identities: the invite drawer captures `resetCopyState` in `reset()`, which + // `close()` captures, which is the Sheet's `onOpenChange` — so a fresh closure per render + // would change that prop on every render of the drawer. + const copy = useCallback((text: string) => { try { void navigator.clipboard .writeText(text) @@ -29,6 +32,7 @@ export function useCopyToClipboard(): { // rejected write: tell the user to select the text themselves. setCopyState("failed"); } - }; - return { copyState, copy, resetCopyState: () => setCopyState("idle") }; + }, []); + const resetCopyState = useCallback(() => setCopyState("idle"), []); + return { copyState, copy, resetCopyState }; } From c064ed0e00c3909ca8df0ed37315228fa8d4faff Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 12:16:44 -0400 Subject: [PATCH 14/25] fix(beacon): log the drop that erases the evidence; split anomaly from outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit claimed to make EVERY fail-closed claims-sync path say so. It missed the widest one, and the claim was the bug: parseMember runs upstream of every screen that commit instrumented, so on a malformed doc the new lines never fire — the shape was already erased before resolveTrustedGrants saw it. firestore.rules never type-checks comisionIds. A console or migration write storing it as a string drops the WHOLE term entry, including a valid cargoId and a genuine Admin assignedBy, so the sitting president's Admin is stripped on their next member write with no line anywhere. Same class for a non-map positions, a non-object term, a non-string cargoId, and a mixed-type roleIds — which also made the existing "roleIds entries cannot be a doc id" log unreachable for the shape it names, since the junk was replaced with [] upstream. Five bounded lines now, keyed on the DOC id rather than uid (uid is itself a field the parser can find missing, so a line keyed on it is blank for exactly the docs an operator must open), term drops aggregated into one sampled line because positions has no key cap. The context is a REQUIRED parameter, not optional. Optional is how a future call site silently reinstates the silent drop, which is the shape this branch keeps paying for. Severity split, because one of the six lines was not an anomaly at all: the trust gate's designed refusal fires when the feature works as specified, before the sameClaims short-circuit, so a member parked in that state emitted an ERROR on every write forever — including one per check-in, via the totalPoints mirror. That and the perms-cap breach are logWarn now; malformed shapes stay logError. Extended to the legacy no-assignedBy seat on the same argument: a pre-field doc shape is steady-state, not corruption. Both sinks are optional on the deps interface, so their presence in production rested on one factory with nothing asserting it — omitting logWarn would compile and pass every unit test, since the fakes supply their own. Pinned. readPositionGrants takes an injected sink instead of hard-coding console.error; it is the function deliberately shared by two ports, and hard-coding its sink is what a later change has to unpick twice. Its test drops the console spy. Every log mutation-tested 1:1, including the silence guards: neutralize one and exactly its own assertion goes red. Deliberately NOT changed: comisionIds: null still drops the term. Treating it as the empty array would keep the seat and mint its grants — fail-open on a path where the current behavior fails closed. Now logged, so it is diagnosable either way. Co-Authored-By: Claude Opus 5 (1M context) --- apps/beacon/src/callable-auth.test.ts | 13 +- .../src/claims-sync/firestore-deps.test.ts | 26 ++ apps/beacon/src/claims-sync/firestore-deps.ts | 21 +- .../src/claims-sync/parse-member.test.ts | 303 +++++++++++++++--- apps/beacon/src/claims-sync/parse-member.ts | 187 ++++++++++- apps/beacon/src/claims-sync/sync.test.ts | 71 ++-- apps/beacon/src/claims-sync/sync.ts | 67 ++-- apps/beacon/src/firestore-util.test.ts | 37 +++ apps/beacon/src/firestore-util.ts | 35 +- apps/beacon/src/index.ts | 6 +- apps/beacon/src/provision-deps.ts | 5 +- apps/beacon/src/read-position-grants.test.ts | 39 +-- apps/beacon/src/read-position-grants.ts | 20 +- apps/beacon/src/recompute-claims.ts | 4 +- 14 files changed, 658 insertions(+), 176 deletions(-) create mode 100644 apps/beacon/src/firestore-util.test.ts diff --git a/apps/beacon/src/callable-auth.test.ts b/apps/beacon/src/callable-auth.test.ts index e0dce395..f6769481 100644 --- a/apps/beacon/src/callable-auth.test.ts +++ b/apps/beacon/src/callable-auth.test.ts @@ -84,16 +84,9 @@ describe("requireAdminOrPerm", () => { }); it("keeps the two delegations independent", () => { - // A board-seat delegate is not a login provisioner and vice versa. Pinned because both - // codes ship together and the obvious future mistake is to conflate them. - // - // NOT subsumed by the manage:MemberLogin case above, which was the argument for deleting - // it once. That one is a same-subject WRONG-ACTION probe: it only pins that the gate - // compares the literal code instead of expanding an action wildcard. This one is a - // DIFFERENT-SUBJECT probe, over the exact pair of codes the feature ships. A widening - // that keyed on the subject family — anything accepting a related-subject perm, e.g. a - // "holds any MemberLogin/BoardSeat delegation" helper — passes every other case in this - // suite and fails only here. + // A board-seat delegate is not a login provisioner and vice versa. Pins the gate against a + // widening that keys on the subject FAMILY (e.g. "holds any MemberLogin/BoardSeat + // delegation") — every other case here is same-subject and would still pass. expect( codeOf(() => requireAdminOrPerm( diff --git a/apps/beacon/src/claims-sync/firestore-deps.test.ts b/apps/beacon/src/claims-sync/firestore-deps.test.ts index 9657bd47..28fc56ee 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.test.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.test.ts @@ -102,6 +102,32 @@ afterEach(() => { vi.restoreAllMocks(); }); +// Both sinks are OPTIONAL on ClaimsSyncDeps, so their presence in production rests entirely on +// this factory — an omission would compile, pass every unit test (the fakes supply their own), +// and silently drop the very lines the fail-closed screens were instrumented to emit. Pin that +// the real factory wires both, and that they are distinct: routing the designed refusal back +// into the error stream is the mistake the severity split exists to prevent. +describe("firestoreClaimsDeps log sinks", () => { + it("BLOCKING: supplies BOTH logError and logWarn, and they are not the same sink", () => { + const deps = firestoreClaimsDeps({} as unknown as Firestore, {} as unknown as Auth); + expect(typeof deps.logError).toBe("function"); + expect(typeof deps.logWarn).toBe("function"); + expect(deps.logWarn).not.toBe(deps.logError); + }); + + it("routes them to console.warn and console.error respectively", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const deps = firestoreClaimsDeps({} as unknown as Firestore, {} as unknown as Auth); + deps.logWarn?.("w", { a: 1 }); + deps.logError?.("e", { b: 2 }); + expect(warn).toHaveBeenCalledWith("w", { a: 1 }); + expect(error).toHaveBeenCalledWith("e", { b: 2 }); + warn.mockRestore(); + error.mockRestore(); + }); +}); + describe("getRoleDocsByBuiltInKeys coverage anomalies", () => { it("logs nothing for a well-formed built-in doc", async () => { const errors = captureErrors(); diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts index 875723b7..d3547995 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.ts @@ -3,7 +3,7 @@ import type { Firestore } from "firebase-admin/firestore"; import { isValidRole, type Role } from "@luminova/auth/roles"; import { isValidPermissionCode, type PermissionCode } from "@luminova/types/permission"; import { chunk } from "../chunk.js"; -import { isSafeDocId, truncateForLog } from "../firestore-util.js"; +import { isSafeDocId, truncateForLog, type LogSink } from "../firestore-util.js"; import { readPositionGrants } from "../read-position-grants.js"; import { isActiveRoleDoc, permsFromRoleDoc } from "./role-doc.js"; import type { LiveBuiltInRoleDoc } from "./resolve-member-perms.js"; @@ -27,7 +27,12 @@ function permsFromClaims( * `roleIds` is Admin-writable with no size or length cap in rules, so serializing every * rejected entry lets one junk-filled member doc exceed Cloud Logging's 256 KB per-entry * limit — the entry is then DROPPED, making the anomaly invisible at exactly the scale - * that matters. The count is the alertable signal; the sample is for diagnosis. */ + * that matters. The count is the alertable signal; the sample is for diagnosis. + * + * REACH: this bounds an ALL-STRINGS array carrying an id `isSafeDocId` rejects, and nothing + * else. A junk-FILLED `roleIds` (any non-string entry) never arrives — `parseMember` replaces + * a mixed-type array with `[]` upstream, and reports that drop itself. Do not read the screen + * below as covering both. */ const REJECTED_ID_SAMPLE = 10; function sampleRejectedIds(rejected: readonly string[]): string[] { @@ -181,6 +186,11 @@ export interface FirestoreClaimsDeps extends ClaimsSyncDeps { staleBuiltInRoleKeys(): Promise; } +/** Exported so the trigger/callable call sites can hand the SAME sink to `parseMember`, which + * runs before any deps instance exists. Defining a second `console.error` wrapper at each of + * those three call sites would be the copy this repo's guardrail #1 forbids. */ +export const logError: LogSink = (message, meta) => console.error(message, meta); + export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsDeps { const userCache = new Map>(); function loadUser(uid: string): Promise { @@ -275,7 +285,7 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD // the port-independent gate the test fakes inherit), but readPositionGrants screens // again at the site where an unscreened id becomes a permanent db.doc() throw, so this // does not rely on its caller. - const grants = await readPositionGrants(db, id); + const grants = await readPositionGrants(db, id, logError); return grants === null ? null : { grants }; }, getAssignerClaims: async (uid) => { @@ -341,6 +351,9 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD setClaims: async (uid, next) => { await auth.setCustomUserClaims(uid, next); }, - logError: (message, meta) => console.error(message, meta), + logError, + // Cloud Logging maps console.warn to WARNING, which is the point: the designed refusals + // must not share a severity with the malformed-doc screens an operator has to act on. + logWarn: (message, meta) => console.warn(message, meta), }; } diff --git a/apps/beacon/src/claims-sync/parse-member.test.ts b/apps/beacon/src/claims-sync/parse-member.test.ts index f598815b..d0df43e0 100644 --- a/apps/beacon/src/claims-sync/parse-member.test.ts +++ b/apps/beacon/src/claims-sync/parse-member.test.ts @@ -1,16 +1,42 @@ import { describe, expect, it } from "vitest"; -import { parseMember } from "./parse-member.js"; +import { parseMember, type MemberParseContext } from "./parse-member.js"; + +/** For tests exercising pure parse behavior with nothing to log to — `context` is required + * on `parseMember` precisely so a real call site can't get away with omitting one. */ +const silent = (): MemberParseContext => ({ memberId: "m-test", logError: () => {} }); + +/** A capturing LogSink plus the context that feeds it. Not `vi.fn()`: these assertions are + * about the CONTENT of the meta object (ids, types, counts — and nothing else), which reads + * better against a plain array than through mock-call indexing. */ +function recorder(): { + lines: { message: string; meta: Record }[]; + context: MemberParseContext; +} { + const lines: { message: string; meta: Record }[] = []; + return { + lines, + context: { + memberId: "member-1", + logError: (message, meta) => { + lines.push({ message, meta }); + }, + }, + }; +} describe("parseMember", () => { const NO_GRANTS = { grant: [], revoke: [] }; it("passes through a well-formed member (incl. assignedBy)", () => { - const result = parseMember({ - uid: "u1", - positions: { - "2026": { cargoId: "pos-pres", comisionIds: ["com-a", "com-b"], assignedBy: "admin-uid" }, + const result = parseMember( + { + uid: "u1", + positions: { + "2026": { cargoId: "pos-pres", comisionIds: ["com-a", "com-b"], assignedBy: "admin-uid" }, + }, }, - }); + silent(), + ); expect(result).toEqual({ uid: "u1", positions: { @@ -22,12 +48,15 @@ describe("parseMember", () => { }); it("extracts roleIds and filters override codes to the known vocabulary", () => { - const result = parseMember({ - uid: "u1", - positions: {}, - roleIds: ["custom-1", "custom-2"], - permissionOverrides: { grant: ["manage:Position", "bogus:Code"], revoke: ["read:Member"] }, - }); + const result = parseMember( + { + uid: "u1", + positions: {}, + roleIds: ["custom-1", "custom-2"], + permissionOverrides: { grant: ["manage:Position", "bogus:Code"], revoke: ["read:Member"] }, + }, + silent(), + ); expect(result.roleIds).toEqual(["custom-1", "custom-2"]); expect(result.permissionOverrides).toEqual({ grant: ["manage:Position"], @@ -36,34 +65,44 @@ describe("parseMember", () => { }); it("defaults roleIds to [] and overrides to empty when absent or malformed", () => { - expect(parseMember({ uid: "u1", positions: {}, roleIds: "nope" }).roleIds).toEqual([]); - expect(parseMember({ uid: "u1", positions: {} }).permissionOverrides).toEqual(NO_GRANTS); + expect(parseMember({ uid: "u1", positions: {}, roleIds: "nope" }, silent()).roleIds).toEqual( + [], + ); + expect(parseMember({ uid: "u1", positions: {} }, silent()).permissionOverrides).toEqual( + NO_GRANTS, + ); expect( - parseMember({ uid: "u1", positions: {}, permissionOverrides: { grant: "x" } }) + parseMember({ uid: "u1", positions: {}, permissionOverrides: { grant: "x" } }, silent()) .permissionOverrides, ).toEqual(NO_GRANTS); }); it("drops a term whose comisionIds is present but not a string array", () => { - const result = parseMember({ - uid: "u1", - positions: { - good: { cargoId: "p", comisionIds: ["c"] }, - bad: { cargoId: "p", comisionIds: "not-an-array" }, - alsoBad: { cargoId: "p", comisionIds: [1, 2] }, + const result = parseMember( + { + uid: "u1", + positions: { + good: { cargoId: "p", comisionIds: ["c"] }, + bad: { cargoId: "p", comisionIds: "not-an-array" }, + alsoBad: { cargoId: "p", comisionIds: [1, 2] }, + }, }, - }); + silent(), + ); expect(result.positions).toEqual({ good: { cargoId: "p", comisionIds: ["c"] } }); }); it("defaults an absent comisionIds to [] when cargoId is valid", () => { - const result = parseMember({ - uid: "u1", - positions: { - "2026": { cargoId: "pos-pres", assignedBy: "admin-uid" }, - nullCargo: { cargoId: null }, + const result = parseMember( + { + uid: "u1", + positions: { + "2026": { cargoId: "pos-pres", assignedBy: "admin-uid" }, + nullCargo: { cargoId: null }, + }, }, - }); + silent(), + ); expect(result.positions["2026"]).toEqual({ cargoId: "pos-pres", comisionIds: [], @@ -73,41 +112,215 @@ describe("parseMember", () => { }); it("yields empty positions when positions is an array", () => { - expect(parseMember({ uid: "u1", positions: [] }).positions).toEqual({}); - expect(parseMember({ uid: "u1", positions: ["x"] }).positions).toEqual({}); + expect(parseMember({ uid: "u1", positions: [] }, silent()).positions).toEqual({}); + expect(parseMember({ uid: "u1", positions: ["x"] }, silent()).positions).toEqual({}); }); it("yields empty positions when positions is a string", () => { - expect(parseMember({ uid: "u1", positions: "nope" }).positions).toEqual({}); + expect(parseMember({ uid: "u1", positions: "nope" }, silent()).positions).toEqual({}); }); it("returns undefined uid when missing or non-string", () => { - expect(parseMember({ positions: {} }).uid).toBeUndefined(); - expect(parseMember({ uid: 42, positions: {} }).uid).toBeUndefined(); + expect(parseMember({ positions: {} }, silent()).uid).toBeUndefined(); + expect(parseMember({ uid: 42, positions: {} }, silent()).uid).toBeUndefined(); }); it("preserves a null cargoId", () => { - const result = parseMember({ - uid: "u1", - positions: { "2026": { cargoId: null, comisionIds: [] } }, - }); + const result = parseMember( + { + uid: "u1", + positions: { "2026": { cargoId: null, comisionIds: [] } }, + }, + silent(), + ); expect(result.positions["2026"]).toEqual({ cargoId: null, comisionIds: [] }); }); it("drops a term whose cargoId is missing or non-string/non-null", () => { - const result = parseMember({ - uid: "u1", - positions: { - missing: { comisionIds: [] }, - numeric: { cargoId: 7, comisionIds: [] }, + const result = parseMember( + { + uid: "u1", + positions: { + missing: { comisionIds: [] }, + numeric: { cargoId: 7, comisionIds: [] }, + }, }, - }); + silent(), + ); expect(result.positions).toEqual({}); }); it("handles null/undefined raw input", () => { const empty = { uid: undefined, positions: {}, roleIds: [], permissionOverrides: NO_GRANTS }; - expect(parseMember(null)).toEqual(empty); - expect(parseMember(undefined)).toEqual(empty); + expect(parseMember(null, silent())).toEqual(empty); + expect(parseMember(undefined, silent())).toEqual(empty); + }); + + it("logs a present-but-non-string uid — the member is skipped by both fan-outs", () => { + const { lines, context } = recorder(); + expect(parseMember({ uid: 42, positions: {} }, context).uid).toBeUndefined(); + expect(lines).toHaveLength(1); + expect(lines[0]?.message).toContain("uid is present but not a string"); + expect(lines[0]?.meta).toEqual({ memberId: "member-1", uidType: "number" }); + }); + + it("logs a positions that is not a map, naming the shape", () => { + const cases: { positions: unknown; positionsType: string }[] = [ + { positions: [], positionsType: "array" }, + { positions: ["x"], positionsType: "array" }, + { positions: "nope", positionsType: "string" }, + // Falsy non-objects used to take the same silent branch as an ABSENT positions. + { positions: false, positionsType: "boolean" }, + ]; + for (const { positions, positionsType } of cases) { + const { lines, context } = recorder(); + expect(parseMember({ uid: "u1", positions }, context).positions).toEqual({}); + expect(lines).toHaveLength(1); + expect(lines[0]?.message).toContain("positions is not a map"); + expect(lines[0]?.meta).toEqual({ memberId: "member-1", positionsType }); + } + }); + + it("logs every dropped term entry in ONE bounded line, naming the term and the shape", () => { + const { lines, context } = recorder(); + // The reachable case this log exists for: `comisionIds` is a string, so the WHOLE 2026 + // entry goes — cargoId and assignedBy with it — and the sitting president reads as + // holding no seat everywhere downstream. firestore.rules type-checks none of this. + const result = parseMember( + { + uid: "pres", + positions: { + "2022": ["x"], + "2023": "nope", + "2024": { comisionIds: [] }, + "2025": { cargoId: 7, comisionIds: [] }, + "2026": { cargoId: "pos-presidente", assignedBy: "admin-uid", comisionIds: "COM-1" }, + kept: { cargoId: null, comisionIds: [] }, + }, + }, + context, + ); + expect(result.positions).toEqual({ kept: { cargoId: null, comisionIds: [] } }); + expect(lines).toHaveLength(1); + expect(lines[0]?.message).toContain("position entries are malformed"); + // Integer-like keys iterate numerically first, then insertion order — hence 2022…2026, kept. + expect(lines[0]?.meta).toEqual({ + memberId: "member-1", + droppedCount: 5, + dropped: [ + { term: "2022", reason: "term-entry-not-a-map:array" }, + { term: "2023", reason: "term-entry-not-a-map:string" }, + { term: "2024", reason: "cargo-id-not-a-string:undefined" }, + { term: "2025", reason: "cargo-id-not-a-string:number" }, + { term: "2026", reason: "comision-ids-not-a-string-array:string" }, + ], + }); + + // BOUNDED, in the same line: the count stays exact while the sample caps, because a + // console or migration write can author arbitrarily many term keys and an over-large + // Cloud Logging entry is dropped whole. + const many = recorder(); + const term = (i: number) => `t${i}-${"x".repeat(80)}`; + const positions = Object.fromEntries( + Array.from({ length: 25 }, (_, i) => [term(i), { cargoId: 7 }]), + ); + expect(parseMember({ uid: "u1", positions }, many.context).positions).toEqual({}); + expect(many.lines).toHaveLength(1); + expect(many.lines[0]?.meta).toEqual({ + memberId: "member-1", + droppedCount: 25, + // Ten rows, and every named term truncated to the shared 64-char log cap. + dropped: Array.from({ length: 10 }, (_, i) => ({ + term: `${term(i).slice(0, 64)}…`, + reason: "cargo-id-not-a-string:number", + })), + }); + }); + + it("logs a roleIds that is not an array of strings — valid entries go with it", () => { + const { lines, context } = recorder(); + expect( + parseMember({ uid: "u1", positions: {}, roleIds: ["ok", 7, null] }, context).roleIds, + ).toEqual([]); + expect(lines).toHaveLength(1); + expect(lines[0]?.message).toContain("roleIds is not an array of strings"); + expect(lines[0]?.meta).toEqual({ + memberId: "member-1", + roleIdsType: "array", + entryCount: 3, + nonStringCount: 2, + }); + + const scalar = recorder(); + parseMember({ uid: "u1", positions: {}, roleIds: "nope" }, scalar.context); + expect(scalar.lines[0]?.meta).toEqual({ + memberId: "member-1", + roleIdsType: "string", + entryCount: null, + nonStringCount: null, + }); + }); + + it("logs dropped permissionOverrides — the map shape, a non-array arm, unknown codes", () => { + const notAMap = recorder(); + parseMember({ uid: "u1", positions: {}, permissionOverrides: ["manage:all"] }, notAMap.context); + expect(notAMap.lines).toHaveLength(1); + expect(notAMap.lines[0]?.message).toContain("permissionOverrides entries are malformed"); + expect(notAMap.lines[0]?.meta).toEqual({ + memberId: "member-1", + problems: [{ field: "permissionOverrides", reason: "not-a-map", valueType: "array" }], + }); + + const arms = recorder(); + const result = parseMember( + { + uid: "u1", + positions: {}, + permissionOverrides: { grant: "update:BoardSeat", revoke: ["read:Member", "bogus:Code"] }, + }, + arms.context, + ); + expect(result.permissionOverrides).toEqual({ grant: [], revoke: ["read:Member"] }); + expect(arms.lines).toHaveLength(1); + // Counts, never the rejected code itself: an unknown code is by definition unbounded free + // text off a console edit. + expect(arms.lines[0]?.meta).toEqual({ + memberId: "member-1", + problems: [ + { field: "grant", reason: "not-an-array", valueType: "string" }, + { field: "revoke", reason: "unknown-code", count: 1 }, + ], + }); + }); + + it("stays SILENT on every ordinary member shape", () => { + // The paired negative for all five screens above. These are the shapes of nearly every + // member write — an unprovisioned member, a rank-and-file member with no seat, the + // explicit nulls the rules' unchanged()/touched() gap admits. Logging any of them would + // fire constantly and bury the anomalies, which is the whole reason the screens in + // sync.ts guard on `rejectedCargoId !== null`. + const ordinary: unknown[] = [ + null, + undefined, + {}, + { uid: "u1" }, + { uid: null, positions: {} }, + { uid: "u1", positions: null }, + { positions: { "2026": { cargoId: null, comisionIds: [] } } }, + { uid: "u1", positions: { "2026": { cargoId: "pos-pres" } } }, + { uid: "u1", positions: { "2026": { cargoId: "p", comisionIds: ["c"], assignedBy: "a" } } }, + { uid: "u1", roleIds: null }, + { uid: "u1", roleIds: [] }, + { uid: "u1", roleIds: ["custom-1"] }, + { uid: "u1", permissionOverrides: null }, + { uid: "u1", permissionOverrides: {} }, + { uid: "u1", permissionOverrides: { grant: null, revoke: undefined } }, + { uid: "u1", permissionOverrides: { grant: ["manage:Position"], revoke: ["read:Member"] } }, + ]; + for (const shape of ordinary) { + const { lines, context } = recorder(); + parseMember(shape, context); + expect(lines).toEqual([]); + } }); }); diff --git a/apps/beacon/src/claims-sync/parse-member.ts b/apps/beacon/src/claims-sync/parse-member.ts index 319793be..f824364d 100644 --- a/apps/beacon/src/claims-sync/parse-member.ts +++ b/apps/beacon/src/claims-sync/parse-member.ts @@ -1,5 +1,6 @@ import type { TermPositions } from "@luminova/types"; import { isValidPermissionCode, type PermissionCode } from "@luminova/types/permission"; +import { truncateForLog, type LogSink } from "../firestore-util.js"; export interface SafeMember { uid?: string; @@ -11,6 +12,31 @@ export interface SafeMember { /** Member fields the claims-sync needs — used to project member-collection scans. */ export const MEMBER_SYNC_FIELDS = ["uid", "positions", "roleIds", "permissionOverrides"] as const; +/** Who to blame and where to say it, for the drops below. + * + * REQUIRED, not optional: an optional sink compiles fine at a future call site that forgets + * it, silently reinstating the exact silent-drop bug this parameter was added to close. Tests + * that only exercise pure parse behavior pass a no-op `silent()` context (see + * parse-member.test.ts) instead of getting a free pass to omit one. */ +export interface MemberParseContext { + /** The Firestore doc id (`members/{id}`), NOT `member.uid`. Both are handles on the same + * doc, but only the doc id survives what is being reported here: `uid` is itself one of the + * fields this parser can find absent or non-string, so a line keyed on it would be blank + * for exactly the docs an operator has to open. Every call site has the doc id + * unconditionally (`event.params.id` / `doc.id`); none has a trustworthy uid yet. Matches + * the `memberId` field the sibling fan-out lines in index.ts already use. */ + memberId: string; + logError: LogSink; +} + +/** Malformed entries named in one line, per collection. Same rationale as REJECTED_ID_SAMPLE + * in firestore-deps.ts, one axis over: `positions` is a map with no key-count cap in + * firestore.rules, so a console or migration write can produce arbitrarily many bad term + * entries. One line per entry would be arbitrarily many Cloud Logging entries per member + * write; serializing all of them into one would push that entry past the 256 KB limit and get + * it DROPPED. The count is the alertable signal, the sample is for diagnosis. */ +const DROPPED_ENTRY_SAMPLE = 10; + function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every((x) => typeof x === "string"); } @@ -19,40 +45,171 @@ function permissionCodes(v: unknown): PermissionCode[] { return Array.isArray(v) ? v.filter((x): x is PermissionCode => isValidPermissionCode(x)) : []; } +/** `typeof`, but distinguishing the two shapes that matter most here and that `typeof` calls + * `"object"`: an array (a `positions` written as a list) and an explicit null. */ +function shapeOf(v: unknown): string { + return Array.isArray(v) ? "array" : v === null ? "null" : typeof v; +} + +/** ABSENT, for every field below: `undefined` or an explicit `null`. Both are the ordinary + * shape of an ordinary member — and null specifically is admitted by the rules' + * unchanged()/touched() gap (see hasDirectGrants in provision-member-login.ts), so it reaches + * here on real docs. Neither is an anomaly, and logging them would fire on nearly every + * member write, burying the anomalies in the noise. Same reason the `rejectedCargoId !== null` + * guard in sync.ts exists. */ +function isAbsent(v: unknown): boolean { + return v === undefined || v === null; +} + /** Extract a structurally-safe member from raw Firestore data. Malformed term * entries are dropped (not thrown) so a bad doc can't cause a retry storm. * An absent comisionIds defaults to [] (the cargo grant is still honored); * a present-but-malformed comisionIds drops the entry. roleIds defaults to [] - * when absent/malformed; override codes are filtered to the known vocabulary. */ -export function parseMember(raw: unknown): SafeMember { + * when absent/malformed; override codes are filtered to the known vocabulary. + * + * DROPPED IS NOT SILENT (guardrail #4). Every drop below is a fail-closed decision taken on + * data no client could have written — firestore.rules type-checks none of these fields — and + * the consequences are load-bearing: dropping one malformed `comisionIds` discards the whole + * term entry, `cargoId` and `assignedBy` with it, which reads downstream as "holds no seat" + * and strips a sitting president's Admin role from their claims on their next member write. + * Nothing further down can report that, because by then the shape is gone. So each drop emits + * one bounded line here, keyed on the doc id, carrying ids/types/counts only — never the doc, + * never PII. `context` is required — see MemberParseContext for why. */ +export function parseMember(raw: unknown, context: MemberParseContext): SafeMember { const data = (raw ?? {}) as { uid?: unknown; positions?: unknown; roleIds?: unknown; permissionOverrides?: unknown; }; + const report = (message: string, meta: Record): void => { + context.logError(message, { memberId: truncateForLog(context.memberId), ...meta }); + }; + const uid = typeof data.uid === "string" ? data.uid : undefined; + if (uid === undefined && !isAbsent(data.uid)) { + // Reported, not just dropped: both fan-outs skip a member with no uid via `if + // (!member.uid) continue`, so a non-string uid removes that member from the sync + // ENTIRELY — the one drop here that costs a member every grant at once. + report( + "claims-sync: member uid is present but not a string — the member reads as unprovisioned, so their claims are never synced", + { + uidType: shapeOf(data.uid), + }, + ); + } + const positions: Record = {}; - if (data.positions && typeof data.positions === "object" && !Array.isArray(data.positions)) { - for (const [term, value] of Object.entries(data.positions as Record)) { - const v = value as { cargoId?: unknown; comisionIds?: unknown; assignedBy?: unknown }; - if (!v || typeof v !== "object") continue; - const cargoId = - typeof v.cargoId === "string" ? v.cargoId : v.cargoId === null ? null : undefined; - if (cargoId === undefined) continue; - if (v.comisionIds !== undefined && !isStringArray(v.comisionIds)) continue; - positions[term] = { - cargoId, - comisionIds: isStringArray(v.comisionIds) ? v.comisionIds : [], - ...(typeof v.assignedBy === "string" ? { assignedBy: v.assignedBy } : {}), - }; + const droppedTerms: { term: string; reason: string }[] = []; + if (!isAbsent(data.positions)) { + if (typeof data.positions !== "object" || Array.isArray(data.positions)) { + report( + "claims-sync: member positions is not a map — every term entry is dropped, so no cargo grants are minted", + { + positionsType: shapeOf(data.positions), + }, + ); + } else { + for (const [term, value] of Object.entries(data.positions as Record)) { + const v = value as { cargoId?: unknown; comisionIds?: unknown; assignedBy?: unknown }; + // `Array.isArray` folded in: `typeof [] === "object"`, so a term written as a list + // used to fall through to the cargoId screen and be dropped there under a reason that + // named the wrong field. Same drop, honest reason. + if (!v || typeof v !== "object" || Array.isArray(v)) { + droppedTerms.push({ term, reason: `term-entry-not-a-map:${shapeOf(value)}` }); + continue; + } + const cargoId = + typeof v.cargoId === "string" ? v.cargoId : v.cargoId === null ? null : undefined; + if (cargoId === undefined) { + // `cargoId: null` is the ordinary no-seat shape and is KEPT, not dropped — it never + // reaches here. An ABSENT cargoId does: the client mapper always writes the key + // (member-mapper.ts), so a term entry without it is a console/migration artifact. + droppedTerms.push({ term, reason: `cargo-id-not-a-string:${shapeOf(v.cargoId)}` }); + continue; + } + if (v.comisionIds !== undefined && !isStringArray(v.comisionIds)) { + droppedTerms.push({ + term, + reason: `comision-ids-not-a-string-array:${shapeOf(v.comisionIds)}`, + }); + continue; + } + positions[term] = { + cargoId, + comisionIds: isStringArray(v.comisionIds) ? v.comisionIds : [], + ...(typeof v.assignedBy === "string" ? { assignedBy: v.assignedBy } : {}), + }; + } } } + if (droppedTerms.length > 0) { + report( + "claims-sync: member position entries are malformed — each is dropped WHOLE, so its cargoId and assignedBy mint no grants", + { + droppedCount: droppedTerms.length, + dropped: droppedTerms + .slice(0, DROPPED_ENTRY_SAMPLE) + .map((d) => ({ term: truncateForLog(d.term), reason: d.reason })), + }, + ); + } + const roleIds = isStringArray(data.roleIds) ? data.roleIds : []; + if (!isAbsent(data.roleIds) && !isStringArray(data.roleIds)) { + // The WHOLE array goes, valid entries included — which is also why the + // "roleIds entries cannot be a doc id" screen in firestore-deps.ts can never see a + // mixed-type array: it is replaced by [] here, upstream of it. + report( + "claims-sync: member roleIds is not an array of strings — the WHOLE array is dropped, so no custom role grants any perms", + { + roleIdsType: shapeOf(data.roleIds), + entryCount: Array.isArray(data.roleIds) ? data.roleIds.length : null, + nonStringCount: Array.isArray(data.roleIds) + ? data.roleIds.filter((x) => typeof x !== "string").length + : null, + }, + ); + } + const rawOverrides = (data.permissionOverrides ?? {}) as { grant?: unknown; revoke?: unknown }; const permissionOverrides = { grant: permissionCodes(rawOverrides.grant), revoke: permissionCodes(rawOverrides.revoke), }; + const overrideProblems: { field: string; reason: string; valueType?: string; count?: number }[] = + []; + if (!isAbsent(data.permissionOverrides)) { + if (typeof data.permissionOverrides !== "object" || Array.isArray(data.permissionOverrides)) { + overrideProblems.push({ + field: "permissionOverrides", + reason: "not-a-map", + valueType: shapeOf(data.permissionOverrides), + }); + } else { + for (const field of ["grant", "revoke"] as const) { + const value = rawOverrides[field]; + if (isAbsent(value)) continue; + if (!Array.isArray(value)) { + overrideProblems.push({ field, reason: "not-an-array", valueType: shapeOf(value) }); + continue; + } + const rejected = value.filter((x) => !isValidPermissionCode(x)).length; + if (rejected > 0) overrideProblems.push({ field, reason: "unknown-code", count: rejected }); + } + } + } + if (overrideProblems.length > 0) { + // At most three rows (the map itself, then grant and revoke), so no sampling needed. Codes + // are counted, never serialized: a rejected code is by definition outside the vocabulary, + // so its VALUE is unbounded free text off a console edit. + report( + "claims-sync: member permissionOverrides entries are malformed — dropped, so those grants and revocations are not applied", + { + problems: overrideProblems, + }, + ); + } + return { uid, positions, roleIds, permissionOverrides }; } diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts index 3890450a..92e37051 100644 --- a/apps/beacon/src/claims-sync/sync.test.ts +++ b/apps/beacon/src/claims-sync/sync.test.ts @@ -42,6 +42,7 @@ function fakeDeps(opts: { builtInDocs?: RoleDefinition[]; customRoles?: Record; logError?: (message: string, meta: Record) => void; + logWarn?: (message: string, meta: Record) => void; }) { const writes: Record = {}; const deps: ClaimsSyncDeps = { @@ -88,6 +89,7 @@ function fakeDeps(opts: { writes[uid] = claims; }, logError: opts.logError, + logWarn: opts.logWarn, }; return { deps, writes }; } @@ -201,19 +203,23 @@ describe("syncMemberClaims", () => { expect(writes["delegate-uid"]).toBeUndefined(); }); - it("BLOCKING: logs the DESIGNED refusal too, naming which half denied it", async () => { - // The likeliest real cause of "they're on the Directiva with no permissions". Unlike the - // two shape screens, this branch is reached on an ORDINARY delegate path, so it was the - // one silent outcome an operator could actually hit. The meta must distinguish the two - // reasons — self-assignment vs an Admin-granting cargo — because the remedy differs: an - // Admin re-saves the slot in the first case, and only an Admin may seat it in the second. + it("BLOCKING: WARNS (never errors) on the DESIGNED refusal, naming which half denied it", async () => { + // The likeliest real cause of "they're on the Directiva with no permissions", so it is + // logged — but on the warn sink. This branch is the feature working as specified and it + // re-fires on every write to that member (including the totalPoints mirror awardPoints + // does per check-in), so an ERROR here would out-volume the malformed-doc screens that + // sink exists for. The meta must distinguish the two reasons — self-assignment vs an + // Admin-granting cargo — because the remedy differs: an Admin re-saves the slot in the + // first case, and only an Admin may seat it in the second. const logged: { message: string; meta: Record }[] = []; + const errors: string[] = []; const { deps } = fakeDeps({ positions: { "pos-presi": { grants: ["Admin"] } }, userRoles: { "delegate-uid": ["Member"] }, userPerms: { "delegate-uid": ["update:BoardSeat"] }, existing: { "target-uid": { roles: ["Member"] } }, - logError: (message, meta) => logged.push({ message, meta }), + logError: (message) => errors.push(message), + logWarn: (message, meta) => logged.push({ message, meta }), }); await syncMemberClaims( deps, @@ -227,6 +233,9 @@ describe("syncMemberClaims", () => { ); expect(logged).toHaveLength(1); expect(logged[0]?.message).toMatch(/NOT minted/); + // The severity split, asserted rather than assumed: moving this line back onto logError + // must fail here, not just change which stream it lands on. + expect(errors).toEqual([]); expect(logged[0]?.meta).toMatchObject({ uid: "target-uid", cargoId: "pos-presi", @@ -236,16 +245,17 @@ describe("syncMemberClaims", () => { }); }); - it("stays quiet when the grants ARE minted", async () => { + it("stays quiet on BOTH sinks when the grants ARE minted", async () => { // The paired negative: a log on the success path would bury the refusals it exists to // surface, since every ordinary seating writes claims. - const logged: { message: string; meta: Record }[] = []; + const logged: string[] = []; const { deps } = fakeDeps({ positions: { "pos-sec": { grants: ["Secretary"] } }, userRoles: { "delegate-uid": ["Member"] }, userPerms: { "delegate-uid": ["update:BoardSeat"] }, existing: { "target-uid": { roles: ["Member"] } }, - logError: (message, meta) => logged.push({ message, meta }), + logError: (message) => logged.push(message), + logWarn: (message) => logged.push(message), }); await syncMemberClaims( deps, @@ -634,27 +644,33 @@ describe("syncMemberClaims", () => { }); it("logs the legacy no-assignedBy power seat too, and stays quiet off the power path", async () => { - // A power cargo with no attribution is an anomaly whatever its provenance, so the - // undefined case logs alongside the malformed ones above. But the screen sits BEHIND the - // grants check, so a grant-free cargo with no assignedBy — the ordinary shape of every - // rank-and-file seat — never reaches it and must stay quiet. - for (const [cargoId, expected] of [ + // A power cargo with no attribution still says so, but at WARN, not ERROR: an absent + // assignedBy is a legacy doc shape that predates the field and is re-emitted on every + // write to that member forever, so putting it in the same bucket as corruption is how the + // error stream stops being read. The screen sits BEHIND the grants check, so a grant-free + // cargo with no assignedBy — the ordinary shape of every rank-and-file seat — never + // reaches it and must stay quiet on BOTH sinks. + for (const [cargoId, expectedWarns] of [ ["pos-pres", 1], ["pos-plain", 0], ] as const) { - const logged: string[] = []; + const warns: string[] = []; + const errors: string[] = []; const { deps } = fakeDeps({ positions: { "pos-pres": { grants: ["Admin"] }, "pos-plain": { grants: [] } }, userRoles: {}, existing: { "target-uid": { roles: ["Member"] } }, - logError: (message) => logged.push(message), + logError: (message) => errors.push(message), + logWarn: (message) => warns.push(message), }); await syncMemberClaims( deps, { uid: "target-uid", positions: { "2026": { cargoId, comisionIds: [] } } }, "2026", ); - expect(logged).toHaveLength(expected); + expect(warns).toHaveLength(expectedWarns); + // BLOCKING: never the error sink — that is the whole point of the split. + expect(errors).toEqual([]); } }); @@ -915,8 +931,9 @@ describe("syncMemberClaims", () => { }); }); - it("fail-closed: writes empty perms (not stale claims) when effective perms exceed the cap", async () => { + it("fail-closed: writes empty perms (not stale claims) and WARNS when effective perms exceed the cap", async () => { const errors: string[] = []; + const warnings: string[] = []; const { deps, writes } = fakeDeps({ positions: {}, userRoles: {}, @@ -924,6 +941,7 @@ describe("syncMemberClaims", () => { existing: { "target-uid": { roles: ["Admin", "Member"], perms: ["manage:all"] } }, customRoles: { big: customRole("big", distinctCodes(31)) }, logError: (message) => errors.push(message), + logWarn: (message) => warnings.push(message), }); await syncMemberClaims( deps, @@ -936,7 +954,9 @@ describe("syncMemberClaims", () => { "2026", ); expect(writes["target-uid"]).toEqual({ roles: ["Member"], perms: [] }); - expect(errors[0]).toMatch(/cap/i); + // An expected ceiling, not a malformed doc: same severity split as the trust gate. + expect(warnings[0]).toMatch(/cap/i); + expect(errors).toEqual([]); }); it("propagates and writes nothing when a dependency rejects", async () => { @@ -972,10 +992,13 @@ describe("syncMemberClaims", () => { userRoles: { "membership-uid": ["Membership", "Member"] }, existing: { "target-uid": { roles: ["Member"] } }, }); - const member = parseMember({ - uid: "target-uid", - positions: { "2026": { cargoId: "pos-pres", assignedBy: "membership-uid" } }, - }); + const member = parseMember( + { + uid: "target-uid", + positions: { "2026": { cargoId: "pos-pres", assignedBy: "membership-uid" } }, + }, + { memberId: "m-test", logError: () => {} }, + ); await syncMemberClaims(deps, member, "2026"); // No Admin escalation: recomputed to a plain Member claim. expect(writes["target-uid"]).toEqual({ roles: ["Member"], perms: permsFor(["Member"]) }); diff --git a/apps/beacon/src/claims-sync/sync.ts b/apps/beacon/src/claims-sync/sync.ts index 5eeaad33..36ab079d 100644 --- a/apps/beacon/src/claims-sync/sync.ts +++ b/apps/beacon/src/claims-sync/sync.ts @@ -1,7 +1,7 @@ import type { Role } from "@luminova/auth/roles"; import type { TermPositions, PermissionCode } from "@luminova/types"; import { PERMISSION_CAP } from "@luminova/types/permission"; -import { isSafeDocId, truncateForLog } from "../firestore-util.js"; +import { isSafeDocId, truncateForLog, type LogSink } from "../firestore-util.js"; import { computeMemberRoles } from "./compute-roles.js"; import { resolveMemberPerms, type RolePermsDeps } from "./resolve-member-perms.js"; @@ -23,8 +23,16 @@ export interface ClaimsSyncDeps extends RolePermsDeps { /** The target member's existing custom claims. */ getExistingClaims(uid: string): Promise<{ roles: Role[]; perms?: PermissionCode[] }>; setClaims(uid: string, claims: MemberClaims): Promise; - /** Structured error sink (defaults to console.error in the Firestore impl). */ - logError?(message: string, meta: Record): void; + /** Malformed input the rules allow but this code cannot use — someone must edit the doc. + * Defaults to console.error in the Firestore impl. */ + logError?: LogSink; + /** Outcomes that are working as designed but leave a member without claims they look like + * they should have — a refused trust gate, a perms-cap breach. Separated from `logError` + * because both are steady-state: `onMemberWritten` fires on ANY member write (awardPoints + * mirrors `totalPoints` on every check-in) and this runs before the idempotency + * short-circuit, so one seat in that state would emit an ERROR per write forever and train + * the operator to filter the sink out. Defaults to console.warn (Cloud Logging: WARNING). */ + logWarn?: LogSink; } type MemberLike = { @@ -139,16 +147,24 @@ async function resolveTrustedGrants( // is not a path segment ("/" and reserved forms are legal in one), and the 128-char cap // isSafeDocId lacks is the half that actually bites here. Untrusted shape → no grants. if (typeof assignedBy !== "string" || assignedBy.length === 0 || assignedBy.length > 128) { - // Same silence as the cargoId screen above, and it bites harder: this branch is reached - // ONLY on a power-granting cargo, i.e. exactly the members whose missing claims matter. - // A legacy doc with no `assignedBy` lands here too and is logged on purpose — a seat that - // confers power with no attribution is the anomaly, not the routine case. - deps.logError?.("claims-sync: assignedBy is not a usable uid — minting no cargo grants", { - uid: memberUid, - cargoId, - assignedByType: typeof assignedBy, - assignedByLength: typeof assignedBy === "string" ? assignedBy.length : null, - }); + // Reached ONLY on a power-granting cargo, i.e. exactly the members whose missing claims + // matter. Split by severity for the same reason the designed refusal is a warning: an + // ABSENT assignedBy is a legacy doc shape that predates the field, steady-state, and + // re-emitted on every write to that member forever; a present-but-malformed one is + // corruption a person must go edit. + const legacyMissing = assignedBy === undefined || assignedBy === null; + const sink = legacyMissing ? deps.logWarn : deps.logError; + sink?.( + legacyMissing + ? "claims-sync: power seat has no assignedBy — minting no cargo grants" + : "claims-sync: assignedBy is not a usable uid — minting no cargo grants", + { + uid: memberUid, + cargoId: truncateForLog(cargoId), + assignedByType: typeof assignedBy, + assignedByLength: typeof assignedBy === "string" ? assignedBy.length : null, + }, + ); return []; } const assigner = await deps.getAssignerClaims(assignedBy); @@ -161,15 +177,14 @@ async function resolveTrustedGrants( ? assignerIsAdmin : assignerIsAdmin || assigner.perms.includes("update:BoardSeat"); if (!trusted) { - // The DESIGNED refusal, not a malformed-input one — and the likeliest real cause of the - // support question this feature will generate: "they're on the Directiva with no - // permissions". It is reached on an ordinary delegate path (an Admin-granting cargo, or a - // self-assignment) and on a stale one (the assigner has since lost update:BoardSeat), and - // the outcome is identical either way. The client warns before the click, but nothing - // server-side said which of the two happened, or that anything happened at all. - deps.logError?.("claims-sync: cargo grants NOT minted — assigner is not trusted for them", { + // WARN, not ERROR: the DESIGNED refusal, reached on an ordinary delegate path (an + // Admin-granting cargo, or a self-assignment) and on a stale one (the assigner has since + // lost update:BoardSeat). Still logged, because it is the likeliest real cause of the + // support question this feature will generate — "they're on the Directiva with no + // permissions" — and nothing server-side said which of the two happened. + deps.logWarn?.("claims-sync: cargo grants NOT minted — assigner is not trusted for them", { uid: memberUid, - cargoId, + cargoId: truncateForLog(cargoId), selfAssigned, grantsAdmin: position.grants.includes("Admin"), assignerIsAdmin, @@ -225,10 +240,12 @@ export async function syncMemberClaims( // keep a stale grant while dropping a revoke). We still write the recomputed // `roles` + empty `perms` so a concurrent role revocation always lands — // never leave the member on stale, possibly-elevated claims. - // Note for update:BoardSeat holders: this takes their delegation with it, silently. A - // delegate over the cap keeps seating (their cached token still passes the rules) while - // this function stops honoring the grants — the seat publishes, no claim is minted. - deps.logError?.("effective perms exceed cap; writing empty perms (fail-closed)", { + // WARN for the same reason as the trust gate above: an expected ceiling, not malformed + // input, and it persists across every write to that member. Note for update:BoardSeat + // holders: this takes their delegation with it. A delegate over the cap keeps seating + // (their cached token still passes the rules) while this function stops honoring the + // grants — the seat publishes, no claim is minted. + deps.logWarn?.("effective perms exceed cap; writing empty perms (fail-closed)", { uid: member.uid, count: perms.length, cap: PERMISSION_CAP, diff --git a/apps/beacon/src/firestore-util.test.ts b/apps/beacon/src/firestore-util.test.ts new file mode 100644 index 00000000..cb47a14b --- /dev/null +++ b/apps/beacon/src/firestore-util.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { truncateForLog } from "./firestore-util.js"; + +// A shared primitive across three claims-sync log sites, previously exercised only through +// them — so its boundary was never asserted anywhere. It exists because `isSafeDocId` tolerates +// 1500 bytes and Cloud Logging drops an over-large entry ENTIRELY, which loses the anomaly +// exactly when it is biggest. +describe("truncateForLog", () => { + it("returns a short value unchanged, with no marker", () => { + expect(truncateForLog("pos-presidente")).toBe("pos-presidente"); + expect(truncateForLog("")).toBe(""); + }); + + it("BLOCKING: bounds anything longer, keeping the head and marking the cut", () => { + const long = "x".repeat(1500); + const out = truncateForLog(long); + expect(out).toHaveLength(65); // 64 + the ellipsis + expect(out.startsWith("x".repeat(64))).toBe(true); + expect(out.endsWith("…")).toBe(true); + }); + + it("does not truncate at exactly the cap, and does one char past it", () => { + // The off-by-one that a `>=` would introduce: a 64-char id is representable in full, so + // marking it as cut would misreport the anomaly it exists to describe. + expect(truncateForLog("y".repeat(64))).toBe("y".repeat(64)); + expect(truncateForLog("y".repeat(65))).toBe(`${"y".repeat(64)}…`); + }); + + it("stays serializable when the cut lands mid-surrogate-pair", () => { + // `.slice` can split an astral pair into a lone surrogate. JSON.stringify has been + // well-formed since ES2019 and escapes it, so the log entry survives — this pins that the + // helper never produces something the structured sink would reject. + const astral = "𝒳".repeat(40); // 2 UTF-16 units each, so the 64-char cut lands mid-pair + expect(() => JSON.stringify({ id: truncateForLog(astral) })).not.toThrow(); + expect(JSON.parse(JSON.stringify({ id: truncateForLog(astral) }))).toBeTruthy(); + }); +}); diff --git a/apps/beacon/src/firestore-util.ts b/apps/beacon/src/firestore-util.ts index dc0fdea6..32a34755 100644 --- a/apps/beacon/src/firestore-util.ts +++ b/apps/beacon/src/firestore-util.ts @@ -10,16 +10,24 @@ export function hasToMillis(v: unknown): v is Timestamp { /** Whether `id` is safe to interpolate into a `collection/${id}` doc-path template. * - * The rejected shapes do NOT all fail at the same point, and an earlier version of this - * comment flattened them into "`db.doc()` throws": only the empty and "/"-bearing cases - * throw synchronously in `db.doc()` (wrong path-segment count). `.`, `..` and `__x__` build - * a reference fine and fail LATER, at `get()`, with a permanent INVALID_ARGUMENT from the - * server. Either way the failure is permanent rather than transient, which is the property - * that matters: on a retry:true trigger it is a redelivery loop, and on a claims read it - * fails that member's sync forever (every later write re-throws, because the offending id - * persists in the doc) until someone edits it out. - * Screen instead — the id contributing nothing fails closed. - * Extracted from currentCargoId, which was the first path to need it. */ + * The rejected shapes do not all fail at the same point: the empty and "/"-bearing cases + * throw synchronously in `db.doc()` (wrong path-segment count), while `.`, `..` and `__x__` + * build a reference fine and fail LATER, at `get()`, with a permanent INVALID_ARGUMENT from + * the server. Either way the failure is permanent rather than transient, which is the + * property that matters: on a retry:true trigger it is a redelivery loop, and on a claims + * read it fails that member's sync forever (every later write re-throws, because the + * offending id persists in the doc) until someone edits it out. + * Screen instead — the id contributing nothing fails closed. */ +export function isSafeDocId(id: unknown): id is string { + if (typeof id !== "string" || id.length === 0 || id.includes("/")) return false; + if (id === "." || id === "..") return false; + if (id.startsWith("__") && id.endsWith("__")) return false; + return UTF8.encode(id).length <= 1500; +} + +/** A structured log sink, injected so a shared fail-closed read is not welded to `console`. */ +export type LogSink = (message: string, meta: Record) => void; + const LOG_ID_MAX_CHARS = 64; /** An id bounded for a structured-log field. The values screened by `isSafeDocId` run to @@ -29,10 +37,3 @@ const LOG_ID_MAX_CHARS = 64; export function truncateForLog(value: string): string { return value.length > LOG_ID_MAX_CHARS ? `${value.slice(0, LOG_ID_MAX_CHARS)}…` : value; } - -export function isSafeDocId(id: unknown): id is string { - if (typeof id !== "string" || id.length === 0 || id.includes("/")) return false; - if (id === "." || id === "..") return false; - if (id.startsWith("__") && id.endsWith("__")) return false; - return UTF8.encode(id).length <= 1500; -} diff --git a/apps/beacon/src/index.ts b/apps/beacon/src/index.ts index 459ee4da..ca143fe6 100644 --- a/apps/beacon/src/index.ts +++ b/apps/beacon/src/index.ts @@ -28,7 +28,7 @@ import { PUBLIC_PROFILE_DEFAULT, } from "./showcase/default-public-profile.js"; import type { ShowcasePerson } from "@luminova/types/engine"; -import { firestoreClaimsDeps } from "./claims-sync/firestore-deps.js"; +import { firestoreClaimsDeps, logError } from "./claims-sync/firestore-deps.js"; import { syncMemberClaims } from "./claims-sync/sync.js"; import { roleClaimsChanged } from "./claims-sync/role-change.js"; import { builtInKeyFromRoleDoc } from "./claims-sync/role-doc.js"; @@ -229,7 +229,7 @@ export const onActivityWritten = onDocumentWritten("activities/{id}", async (eve export const onMemberWritten = onDocumentWritten("members/{id}", async (event) => { const after = event.data?.after; if (!after?.exists) return; // deletes leave the Auth user untouched - const member = parseMember(after.data()); + const member = parseMember(after.data(), { memberId: event.params.id, logError }); if (!member.uid) return; // not provisioned → no Auth user to claim await syncMemberClaims(firestoreClaimsDeps(db(), getAuth()), member, currentTermKey()); }); @@ -313,7 +313,7 @@ export const onRoleWritten = onDocumentWritten( let scanned = 0; let failed = 0; for (const doc of docs) { - const member = parseMember(doc.data()); + const member = parseMember(doc.data(), { memberId: doc.id, logError }); if (!member.uid) continue; scanned += 1; try { diff --git a/apps/beacon/src/provision-deps.ts b/apps/beacon/src/provision-deps.ts index 5deef27d..4d6ff575 100644 --- a/apps/beacon/src/provision-deps.ts +++ b/apps/beacon/src/provision-deps.ts @@ -1,8 +1,11 @@ import type { Auth } from "firebase-admin/auth"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; +import type { LogSink } from "./firestore-util.js"; import type { ProvisionDeps } from "./provision-member-login.js"; +const logError: LogSink = (message, meta) => console.error(message, meta); + // Null only for the "account does not exist" outcome — a transient Auth error // must propagate, not read as deleted (the relink guard trusts that contract). function nullIfUserNotFound(err: unknown): null { @@ -32,6 +35,6 @@ export function firestoreProvisionDeps(db: Firestore, auth: Auth): ProvisionDeps await db.doc(`members/${id}`).update({ uid }); }, passwordResetLink: (email) => auth.generatePasswordResetLink(email), - getPositionGrants: (cargoId) => readPositionGrants(db, cargoId), + getPositionGrants: (cargoId) => readPositionGrants(db, cargoId, logError), }; } diff --git a/apps/beacon/src/read-position-grants.test.ts b/apps/beacon/src/read-position-grants.test.ts index 8a310ebe..9db58155 100644 --- a/apps/beacon/src/read-position-grants.test.ts +++ b/apps/beacon/src/read-position-grants.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; @@ -16,16 +16,6 @@ function fakeDb(docs: Record | undefined>): Fire } describe("readPositionGrants", () => { - // Every null return logs (see below), so the anomaly cases here would otherwise spray - // stderr across the run. Stubbed for all of them; the two tests that assert on it read - // this same spy. - beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it("returns the valid roles from a well-formed cargo", async () => { const db = fakeDb({ "positions/p1": { grants: ["Admin", "Membership"] } }); await expect(readPositionGrants(db, "p1")).resolves.toEqual(["Admin", "Membership"]); @@ -51,19 +41,20 @@ describe("readPositionGrants", () => { await expect(readPositionGrants(db, 42)).resolves.toBeNull(); }); - it("logs every null — none of them is visible to either caller otherwise", async () => { + it("logs every null through the injected sink — none is visible to either caller otherwise", async () => { // Guardrail #4. `null` is fail-closed in BOTH directions (grant-free to the claims trust // gate, power-conferring to the provisioning guard) and neither throws: the trust gate // just mints nothing and the member is published on the Directiva with no roles. Every // shape here is an anomaly no legitimate flow produces — a routine grant-free cargo // returns [], not null — so an operator gets one line per occurrence or nothing at all. - const spy = vi.mocked(console.error); + const logged: { message: string; meta: Record }[] = []; + const log = (message: string, meta: Record) => logged.push({ message, meta }); const db = fakeDb({ "positions/str": { grants: "Admin" } }); - await readPositionGrants(db, "a/b"); - await readPositionGrants(db, 42); - await readPositionGrants(db, "ghost"); - await readPositionGrants(db, "str"); - expect(spy.mock.calls.map(([message]) => message)).toEqual([ + await readPositionGrants(db, "a/b", log); + await readPositionGrants(db, 42, log); + await readPositionGrants(db, "ghost", log); + await readPositionGrants(db, "str", log); + expect(logged.map((l) => l.message)).toEqual([ expect.stringMatching(/not a usable doc id/), expect.stringMatching(/not a usable doc id/), expect.stringMatching(/missing/), @@ -71,18 +62,18 @@ describe("readPositionGrants", () => { ]); // Ids, never the doc — and bounded, because Cloud Logging drops an over-large entry // whole and isSafeDocId tolerates 1500 bytes. - await readPositionGrants(db, "x".repeat(1501)); - const [, meta] = spy.mock.calls[4]; - expect(String((meta as { cargoId: string }).cargoId).length).toBeLessThanOrEqual(65); + await readPositionGrants(db, "x".repeat(1501), log); + expect(String(logged[4].meta.cargoId).length).toBeLessThanOrEqual(65); }); it("stays quiet on the paths that resolve", async () => { // The paired negative: a well-formed cargo — grant-bearing or grant-free — is the routine // case on every member write, and logging it would drown the anomalies above. + const logged: string[] = []; const db = fakeDb({ "positions/p1": { grants: ["Admin"] }, "positions/p2": {} }); - await readPositionGrants(db, "p1"); - await readPositionGrants(db, "p2"); - expect(console.error).not.toHaveBeenCalled(); + await readPositionGrants(db, "p1", (message) => logged.push(message)); + await readPositionGrants(db, "p2", (message) => logged.push(message)); + expect(logged).toEqual([]); }); it("BLOCKING: returns null instead of THROWING on a non-array grants field", async () => { diff --git a/apps/beacon/src/read-position-grants.ts b/apps/beacon/src/read-position-grants.ts index d510e58e..01362ffd 100644 --- a/apps/beacon/src/read-position-grants.ts +++ b/apps/beacon/src/read-position-grants.ts @@ -1,6 +1,6 @@ import type { Firestore } from "firebase-admin/firestore"; import { isValidRole, type Role } from "@luminova/auth/roles"; -import { isSafeDocId, truncateForLog } from "./firestore-util.js"; +import { isSafeDocId, truncateForLog, type LogSink } from "./firestore-util.js"; /** A cargo's trusted grants, or null when the id is unusable or the doc is missing. * @@ -12,14 +12,22 @@ import { isSafeDocId, truncateForLog } from "./firestore-util.js"; * `null` is the answer for an unusable id, a missing doc, or a malformed `grants` field, and * it is fail-closed for BOTH callers: an unreadable cargo is treated as * power-conferring by the guard and as grant-free by the trust gate, which is the safe - * direction in each. */ -export async function readPositionGrants(db: Firestore, id: unknown): Promise { + * direction in each. + * + * `logError` is injected rather than hard-coded to `console.error` so this shared read has + * one sink per port, the same way claims-sync's `deps.logError` does — a later change to + * where these lines go should not have to be made twice. */ +export async function readPositionGrants( + db: Firestore, + id: unknown, + logError?: LogSink, +): Promise { // Every `null` below is logged (guardrail #4). Each one is an anomaly no legitimate flow // produces — a routine grant-free cargo returns `[]`, not null — and each is invisible // downstream: the trust gate mints nothing and the provisioning guard refuses, with no // throw and no metric either side. Ids only, bounded — never the doc. if (!isSafeDocId(id)) { - console.error("positions: cargo id is not a usable doc id — cargo unresolvable", { + logError?.("positions: cargo id is not a usable doc id — cargo unresolvable", { cargoIdType: typeof id, cargoId: typeof id === "string" ? truncateForLog(id) : null, cargoIdLength: typeof id === "string" ? id.length : null, @@ -28,7 +36,7 @@ export async function readPositionGrants(db: Firestore, id: unknown): Promise Date: Fri, 28 Aug 2026 12:17:05 -0400 Subject: [PATCH 15/25] fix(backstage): reset the invite panel per member; make the refusal the headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from /code-review on this PR, both in fixes made earlier in this same audit. The InviteAccess mount gate was `!inviteBlocked`, and moving that flag to a prop fixed the bug where a successful invite erased its own result — but the gate had ALSO been the thing resetting the component between members, and nothing replaced it. TanStack Router renders the same MemberProfilePage instance across /members/A → /members/B (no key on Match), and the isLoading unmount is skipped whenever B is warm in cache. So B's header could show A's "Invitación enviada" under B's name — and with the dialog left open, A's password-reset link, a bearer credential, rendered on B's page one click from being copied. `key={member.id}` is stable across the refetch that sets member.uid, so it does not reintroduce the flip-erases-its-own-result bug. Routing the drawer's refusals through provisionErrorMessage put the Spanish in the "Detalle:" slot UNDER a headline that still said "Podrás invitarlo desde el menú de su fila" — and for reprovision-requires-admin that row action really is offered, since memberProvisionBlocked keys hasLogin on member.uid and a just-created doc has none: beacon refused on the Auth directory, which the client cannot see. So the fix for the infinite-retry loop still sent the operator into it, in larger type. The refusal is the headline now. Also corrects two claims of mine that the code did not have: - useCopyToClipboard's comment described a causal chain — reset → close → Sheet's onOpenChange — that does not exist: those are unmemoized functions and an inline arrow, so nothing downstream observes the stability. Kept the memoization as a cheap property of a shared hook; rewrote the reason to be true and dropped the test's BLOCKING label. - "the uniqueness is structural instead of circumstantial" was false. Module-scope ids make the two form TYPES disjoint, not two instances of one form; that is still safe, but only because of memberEditMode and Radix unmounting closed portals — facts in other files, which is what the sentence claimed to have eliminated. Names the assumptions and points at useId for when they stop holding. Drops a tautological test added in the same round: cargoNoteIds("member") equals itself passes for every implementation short of one embedding a counter. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/member-invite-drawer.test.tsx | 8 ++++ .../components/member-invite-drawer.tsx | 43 +++++++++++-------- .../components/member-profile-page.tsx | 10 ++++- .../no-assignable-cargos-note.test.tsx | 4 -- .../components/no-assignable-cargos-note.tsx | 10 +++-- .../members/lib/assignable-cargo-core.ts | 43 ++++++++----------- .../features/members/lib/assignable-cargo.ts | 17 +------- .../features/members/lib/provision-error.ts | 18 ++++++-- .../src/lib/use-copy-to-clipboard.test.ts | 2 +- .../src/lib/use-copy-to-clipboard.ts | 7 +-- 10 files changed, 88 insertions(+), 74 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx index 30acfe51..541ff45b 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx @@ -433,6 +433,14 @@ describe("MemberInviteDrawer", () => { /Ya existe un acceso para este correo\. Pídele a un administrador que lo reenvíe o lo vincule\./, ), ).toBeInTheDocument(); + // BLOCKING: the refusal must be the HEADLINE, not small print under a contradiction. + // The default fallback tells the operator to invite from the row menu — and that item IS + // offered here, because memberProvisionBlocked keys `hasLogin` on member.uid and this + // just-created doc has none: beacon refused on the Auth directory, which the client cannot + // see. Demoting the real reason to "Detalle:" therefore sends them into exactly the + // infinite retry this mapping exists to end. + expect(screen.queryByText(/desde el menú de su fila/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Detalle:/)).not.toBeInTheDocument(); // The raw prose must be GONE, not merely accompanied — it is the thing being replaced. expect(screen.queryByText(/this member already has a login/)).not.toBeInTheDocument(); // The member was still created, so the done screen is guidance, not a create failure. diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx index d508cca8..dff924c9 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx @@ -5,7 +5,7 @@ import { MemberForm } from "./member-form"; import { actionMessage } from "../lib/member-display"; import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; import { draftProvisionBlocked } from "../lib/provision-gate"; -import { provisionErrorMessage } from "../lib/provision-error"; +import { provisionRefusalMessage } from "../lib/provision-error"; import { useCopyToClipboard } from "../../../lib/use-copy-to-clipboard"; import { useCan } from "../../../lib/authz/use-can"; @@ -29,6 +29,12 @@ interface DoneState { provisioned: boolean; emailSent: boolean; actionLink: string | null; + /** The callable's own explanation, when it refused ON PURPOSE. Drives the HEADLINE, not the + * small print: these refusals ("ya existe un acceso para este correo") contradict the + * default "invítalo desde el menú de su fila", and the row action really is offered — + * memberProvisionBlocked keys `hasLogin` on member.uid, which a just-created doc lacks, + * because beacon refused on the Auth directory the client cannot see. */ + refusalMessage: string | null; errorDetail: string | null; } @@ -78,6 +84,7 @@ export function MemberInviteDrawer({ let provisioned = false; let emailSent = false; let actionLink: string | null = null; + let refusalMessage: string | null = null; let errorDetail: string | null = null; // beacon refuses a non-Admin provisioning a member seated on a granting cargo (the // power-seat guard). The rules DO let that member be created, so without this check the @@ -113,12 +120,12 @@ export function MemberInviteDrawer({ } } catch (err) { console.error("No se pudo aprovisionar el acceso del miembro", err); - // Route the callable's REFUSALS through the same table the row menu and the profile - // header use — this was the third entry point and the only one still surfacing the - // server's raw English prose ("this member already has a login; only an Admin…"). - // The raw message stays as the FALLBACK, so an untagged failure (App Check, quota, - // config) still shows the one diagnostic we get. - errorDetail = provisionErrorMessage(err, err instanceof Error ? err.message : String(err)); + // A deliberate refusal becomes the headline; anything else keeps its raw message as + // the only diagnostic we get (App Check, quota, config). + refusalMessage = provisionRefusalMessage(err); + if (refusalMessage === null) { + errorDetail = err instanceof Error ? err.message : String(err); + } } } setDone({ @@ -129,6 +136,7 @@ export function MemberInviteDrawer({ provisioned, emailSent, actionLink, + refusalMessage, errorDetail, }); }; @@ -186,18 +194,17 @@ export function MemberInviteDrawer({ ) : ( <> - {/* Only promise the row action to someone who will actually SEE it. The row item - is gated on `canProvisionLogin && !provisionBlocked` (member-row-menu), so - both conjuncts have to be answered here — see provisionBlocked's doc-comment - above for why it, and not blockedByCargo, is the right flag for the second. - A `create:Member` holder WITHOUT `create:MemberLogin` reaches this drawer too: - the trigger only asks `Can I="create" a="Member"`. */} + {/* Only promise the row action to someone who will actually see it — the row + item is gated on `canProvisionLogin && !provisionBlocked`, so both conjuncts + are answered here, and a server refusal outranks both. */}

- {done.provisionBlocked - ? "Aún no tiene acceso a la app. Su cargo otorga permisos, así que un administrador debe enviarle el acceso." - : canProvisionLogin - ? "Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila." - : "Aún no tiene acceso a la app. Pídele a un administrador que le envíe el acceso."} + {done.refusalMessage + ? `Aún no tiene acceso a la app. ${done.refusalMessage}` + : done.provisionBlocked + ? "Aún no tiene acceso a la app. Su cargo otorga permisos, así que un administrador debe enviarle el acceso." + : canProvisionLogin + ? "Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila." + : "Aún no tiene acceso a la app. Pídele a un administrador que le envíe el acceso."}

{done.errorDetail && (

Detalle: {done.errorDetail}

diff --git a/apps/backstage/src/features/members/components/member-profile-page.tsx b/apps/backstage/src/features/members/components/member-profile-page.tsx index ee9f9181..d60fd535 100644 --- a/apps/backstage/src/features/members/components/member-profile-page.tsx +++ b/apps/backstage/src/features/members/components/member-profile-page.tsx @@ -147,7 +147,15 @@ export function MemberProfilePage() { enviar el correo" state mid-flight — deleting, for a delegate, the only notice that the account exists with no password mail sent. */} - + {/* key: the mount gate used to be `!inviteBlocked`, which ALSO happened to reset + this component between members. It no longer does, and TanStack Router renders + the same MemberProfilePage instance across a /members/A → /members/B + navigation (no key on Match), while `isLoading` skips the unmount whenever B is + warm in cache. Without this, B's header shows A's "Invitación enviada" — and if + the dialog was left open, A's password-reset link, a bearer credential, one + click from being copied on B's page. Stable across the refetch that sets + member.uid, so it does not reintroduce the flip-erases-its-own-result bug. */} + } diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx index bba2b073..56ed41ca 100644 --- a/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx @@ -89,10 +89,6 @@ describe("cargoNoteIds", () => { const positions = Object.values(cargoNoteIds("positions")); expect(new Set([...member, ...positions]).size).toBe(member.length + positions.length); }); - - it("is stable — the same prefix always yields the same ids", () => { - expect(cargoNoteIds("member")).toEqual(cargoNoteIds("member")); - }); }); // The unit assertions above pin the STRINGS. These pin that the strings the forms actually diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx index 02284ab1..0bd2d6c0 100644 --- a/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx @@ -14,9 +14,13 @@ import { permissionLabel } from "../../permissions/lib/permission-matrix"; * notes render identical copy in both forms, which is why the components are shared — but a * shared component owning a fixed DOM id means two mounted forms emit the same id, and * `aria-describedby` then resolves to whichever rendered first, i.e. the OTHER form's note. - * Nothing mounts both cargo editors today (the profile page picks one via `memberEditMode`), - * but that fact lives in another file, so the shared notes take their id as a prop and the - * uniqueness is structural instead of circumstantial. + * This makes the two form TYPES disjoint. It does NOT make two instances of the SAME form + * disjoint — the call is at module scope, so every `MemberForm` shares one set. That is still + * safe, but only because of two facts in other files: the profile page picks one editor via + * `memberEditMode`, and the two Sheets on /members are Radix portals that unmount when closed. + * If either changes, reach for `useId()` (the repo idiom — see `search-input.tsx`) rather than + * another prefix; instance-scoped ids would also fix the `cargoId`/`comisionIds` collision the + * two Comboboxes already have, which this helper does not address. */ export function cargoNoteIds(prefix: string): CargoNoteIds { return { diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index 66a208b7..e91ed6c0 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -1,3 +1,5 @@ +import type { PositionCategory } from "@luminova/types"; + /** * The rules-mirroring half of `assignable-cargo.ts`: every predicate that answers a * `firestore.rules` question about a cargo, and nothing that renders one. @@ -5,10 +7,10 @@ * Split out for ONE reason, and it is load-bearing: `tests/firestore-rules/` drives these * predicates and the real emulator from the same fixture * (`cargo-assignment-parity.test.ts`), and that package cannot resolve `@luminova/types` at - * runtime — it has no such dependency, and the rules suite deliberately keeps its dependency - * surface to the emulator harness. `assignable-cargo.ts` imports `currentTermKey` and - * `positionTitle` from it for VALUE, so importing that file there throws. This module has ZERO - * imports, which is the same trick `nav-equivalence.test.ts` documents for `nav-config.ts`. + * RUNTIME — it has no such dependency. `assignable-cargo.ts` imports `currentTermKey` and + * `positionTitle` from it for VALUE, so importing that file there throws. This module has no + * runtime imports; the one `import type` above is erased before it can be resolved, which is + * the same trick `nav-equivalence.test.ts` documents for `nav-config.ts`'s `IconKey`. * * Import `positionsLockedForEditor` / `cargoTakedownOnly` from HERE, not through * `assignable-cargo.ts` — it used to re-export them, which read as a convenience and was @@ -23,27 +25,23 @@ */ /** - * The cargo fields these predicates read — structural, deliberately NOT `Pick`, - * so this module stays import-free (see the header). Every real `Position` satisfies it, so - * callers pass their `Position` objects unchanged and `cargoSlotsForEditor` returns the very - * objects it was handed (it is generic in `P`), never a lossy copy. - * - * ACCEPTED TRADEOFF: `category` and `grants` are wider here than `Position`'s literal unions, - * because narrowing them would mean either importing those unions (which is the one thing this - * module may not do) or re-declaring them, and a re-declared union drifts. So a hand-built - * FIXTURE could pass `category: "cel"` and get a wrong answer with no compile error. Every - * production caller passes a real `Position`, and `cargoSlotsForEditor` being generic in - * `P extends CargoLike` means `assignable-cargo.ts` handing it `Position[]` already enforces - * `Position extends CargoLike` — which is the direction that can actually break. + * The cargo fields these predicates read — structural rather than `Pick`, so a + * caller may pass anything cargo-shaped, and `cargoSlotsForEditor` (generic in `P`) hands back + * the very objects it was given rather than a lossy copy. * - * Note the parity test cannot cover this either, and it is the one gap in it: the same fixture - * is fed to the predicate AND seeded into the emulator, so a miscased `category` makes both - * sides agree — wrongly, and green. Its factory pins the literal union for that reason. + * `category` carries `Position`'s real union, NOT a widened `string`. That matters more than it + * looks: `cargoAssignableByNonAdmin` compares it against the literal `"CEL"`, and against a + * `string` a rename in POSITION_CATEGORIES would quietly make that comparison always-true — + * offering every CEL cargo to a non-delegate while the rules kept denying, on the publication + * boundary this module exists to mirror. Neither the compiler nor the parity test would catch + * that: the same fixture feeds the predicate and the emulator, so both sides would agree, + * wrongly and green. The `import type` costs nothing — it is erased before the rules-test + * package could fail to resolve it. */ export interface CargoLike { id: string; grants: readonly string[]; - category: string; + category: PositionCategory; term: number | null; active: boolean; } @@ -68,11 +66,6 @@ export interface CargoLike { // from this raw predicate is how the two forms drifted apart in the first place. Exporting it // again would give that back. function cargoAssignableByNonAdmin(cargo: Pick): boolean { - // The "CEL" literal is compared against a structural `string` (see CargoLike's tradeoff), so - // a rename in POSITION_CATEGORIES would make this always-true and silently offer every CEL - // cargo to a non-delegate. `assignable-cargo.ts` holds the typed pin that fails to compile - // instead — it cannot live here, since this module may not import the union. If you change - // this literal, change it there too. return cargo.grants.length === 0 && cargo.category !== "CEL"; } diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index d4dc871e..7d8a2896 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -1,21 +1,6 @@ -import { - currentTermKey, - positionTitle, - type MemberGender, - type Position, - type PositionCategory, -} from "@luminova/types"; +import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types"; import { cargoConfersPower, cargoSlotsForEditor } from "./assignable-cargo-core"; -// The guard the CargoLike widening gave up, bought back here where Position IS importable. -// assignable-cargo-core compares `category !== "CEL"` against a structural `string`, so if -// POSITION_CATEGORIES ever renames or recases that literal, the comparison silently becomes -// always-true — the client would offer every CEL cargo to a non-delegate while the rules keep -// denying `category != 'CEL'`, on the publication boundary that module exists to mirror. With -// the literal typed, a rename fails to compile HERE instead. -const CEL: PositionCategory = "CEL"; -void CEL; - /** * The predicates that MIRROR firestore.rules — `positionsLockedForEditor`, * `cargoTakedownOnly` and the option ceiling — live in `./assignable-cargo-core`, an diff --git a/apps/backstage/src/features/members/lib/provision-error.ts b/apps/backstage/src/features/members/lib/provision-error.ts index ab25fdca..16791d69 100644 --- a/apps/backstage/src/features/members/lib/provision-error.ts +++ b/apps/backstage/src/features/members/lib/provision-error.ts @@ -29,12 +29,24 @@ const MESSAGES: Readonly> = { // The literal buys exhaustiveness against the union; the Map buys a safe lookup. const REASON_MESSAGES = new Map(Object.entries(MESSAGES)); -export function provisionErrorMessage(err: unknown, fallback: string): string { +/** The callable's own explanation for a refusal, or null when it did not give one (a + * transient failure — App Check, quota, config — or a reason this build does not know). + * + * Separate from `provisionErrorMessage` because the two answer different questions. A caller + * that only needs text takes the message; a caller that must also decide WHAT TO SAY NEXT + * needs to know whether the server refused on purpose. The invite drawer needs the second: + * its headline otherwise tells the operator to retry from the row menu on a refusal only an + * Admin can clear, with the real explanation demoted to small print underneath. */ +export function provisionRefusalMessage(err: unknown): string | null { const details = (err as { details?: unknown } | null | undefined)?.details; const reason = typeof details === "object" && details !== null ? (details as { reason?: unknown }).reason : undefined; - if (typeof reason !== "string") return fallback; - return REASON_MESSAGES.get(reason) ?? fallback; + if (typeof reason !== "string") return null; + return REASON_MESSAGES.get(reason) ?? null; +} + +export function provisionErrorMessage(err: unknown, fallback: string): string { + return provisionRefusalMessage(err) ?? fallback; } diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.test.ts b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts index 552c7bff..712944a2 100644 --- a/apps/backstage/src/lib/use-copy-to-clipboard.test.ts +++ b/apps/backstage/src/lib/use-copy-to-clipboard.test.ts @@ -77,7 +77,7 @@ describe("useCopyToClipboard", () => { // changes that prop on every render of the drawer. Asserted across a rerender AND across a // state change, because a `useCallback(fn, [copyState])` would pass the first and fail the // second while looking stable in casual use. - it("BLOCKING: keeps copy and resetCopyState referentially stable across renders", async () => { + it("keeps copy and resetCopyState referentially stable across renders", async () => { stubClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); const { result, rerender } = renderHook(() => useCopyToClipboard()); const firstCopy = result.current.copy; diff --git a/apps/backstage/src/lib/use-copy-to-clipboard.ts b/apps/backstage/src/lib/use-copy-to-clipboard.ts index d36a176c..9c647888 100644 --- a/apps/backstage/src/lib/use-copy-to-clipboard.ts +++ b/apps/backstage/src/lib/use-copy-to-clipboard.ts @@ -18,9 +18,10 @@ export function useCopyToClipboard(): { resetCopyState: () => void; } { const [copyState, setCopyState] = useState("idle"); - // Stable identities: the invite drawer captures `resetCopyState` in `reset()`, which - // `close()` captures, which is the Sheet's `onOpenChange` — so a fresh closure per render - // would change that prop on every render of the drawer. + // Stable identities as a cheap property of a shared hook, NOT because a consumer depends on + // it today: both call sites wrap these in unmemoized handlers and pass inline arrows, so + // nothing downstream currently observes the difference. Kept so a future memoized consumer + // is not defeated by the hook itself. const copy = useCallback((text: string) => { try { void navigator.clipboard From 78e2d7e93c9059763a0db82748163b00b5d75a61 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 12:17:13 -0400 Subject: [PATCH 16/25] chore: reviews Reviews: cadf1aacef0b8f7a796a7a3069a4d97983ba18a0 security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher From 1787ab191f9e774d8b7ecb5e40bf6acc958232c8 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 16:21:34 -0400 Subject: [PATCH 17/25] fix(backstage): the invite mail cannot be dropped by a component unmounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review max on #225 found three correctness bugs in member-profile-page.tsx alone, all in code this branch rewrote. 1. The reset MAIL was sent from a component-scoped `provision.mutate(id, {onSuccess})` callback. TanStack Query v5 runs those only `if (this.#mutateOptions && this.hasListeners())` — so an operator who navigated away, or merely switched to another member (this branch's own `key={member.id}` made that an unmount), got the Auth account created and the uid linked with NO mail sent and no error anywhere. The member then has a login nobody told them about, and memberProvisionBlocked hides the retry from the delegate who caused it. Provisioning and the mail are one mutationFn now, which has no such condition. 2. The Admin's action link was invalidated by the mail fired right after it. Firebase keeps only the most recent password-reset oobCode valid, and the dialog's new copy offered that link as the fallback for "si no le llega el correo" — dead on exactly the branch that promised it works. The hook returns `fallbackLink` only when the mail did NOT go out, so no call site can re-derive which of two secrets is live. 3. MemberForm and MemberPositionsForm had no `key={member.id}` while a display-only banner got one. RHF reads defaultValues once at mount and the page is not remounted across a /members/A -> /members/B param change when B is warm in cache, so "Guardar cambios" wrote A's name/email/status onto B's document. Also: the hook is the only members mutation with no invalidateQueries, while the `blocked`-as-a-prop design is justified by "the next refetch makes it true". With a 5-minute staleTime that flip never happened in-session, so a second click 403'd on the adoption guard. And the invite drawer set `done` unconditionally after two awaits — close it mid-submit and the next "Invitar miembro" opened on the previous member's done screen, action link included. usePositions' isError is now read on both pages (guardrail #3): the invite gate fails CLOSED on an unresolvable cargo, so a failed catalog query silently removed the affordance from every seated member with nothing said. The profile-page test now runs the REAL hook with only its two edges mocked. A hand-written fake that invokes opts.onSuccess unconditionally models a mutation that always has listeners, which is precisely the thing that is not true. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/member-invite-drawer.test.tsx | 78 ++++---- .../components/member-invite-drawer.tsx | 35 ++-- .../components/member-profile-page.test.tsx | 155 ++++++++++------ .../components/member-profile-page.tsx | 168 +++++++++--------- .../members/components/members-page.tsx | 28 ++- .../hooks/use-provision-member-login.test.tsx | 107 +++++++++++ .../hooks/use-provision-member-login.ts | 62 ++++++- 7 files changed, 430 insertions(+), 203 deletions(-) create mode 100644 apps/backstage/src/features/members/hooks/use-provision-member-login.test.tsx diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx index 541ff45b..68f4cf73 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.test.tsx @@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement, ReactNode } from "react"; import type { Position } from "@luminova/types"; +import type { AuthClaims } from "@luminova/auth/roles"; +import type { InviteResult } from "../hooks/use-provision-member-login"; import { MemberInviteDrawer } from "./member-invite-drawer"; import { AbilityProvider } from "../../../lib/authz/ability-context"; import { pickDate } from "../../../test/pick-date"; @@ -29,23 +31,39 @@ const powerCargoCatalog: Position[] = [ // available, and parameterize for the delegation cases below. function renderWithAbility( ui: ReactElement, - claims: { roles: string[]; perms?: string[] } = { roles: ["Admin"], perms: ["manage:all"] }, + // AuthClaims, not `{ roles: string[]; perms?: string[] }` + `as never`. This file's whole + // subject is EXACT-permission-code gating, and free strings made every negative fixture + // vacuous: a typo'd or renamed code silently leaves the principal with no perms at all, so + // "hides it from a manage:all holder" would pass because the caller is unprivileged rather + // than because the exact-code gate works. + claims: AuthClaims = { roles: ["Admin"], perms: ["manage:all"] } as AuthClaims, ) { return render(ui, { wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), }); } -vi.mock("../../../lib/auth/request-password-reset", () => ({ - requestPasswordReset: vi.fn().mockResolvedValue(undefined), -})); - -import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; -const mockedRequestPasswordReset = vi.mocked(requestPasswordReset); +/** The two invite outcomes, as `useProvisionMemberLogin` reports them. The MAIL is sent inside + * that hook now (a component-scoped onSuccess was dropped whenever the caller unmounted first), + * so this drawer never calls `requestPasswordReset` and the fixtures say what happened instead + * of mocking the mail module. `fallbackLink` is non-null ONLY on the failure branch: the mail, + * when it goes out, invalidates the oobCode the link carries. */ +const mailed = (email: string): InviteResult => ({ + email, + emailSent: true, + fallbackLink: null, + mailError: null, +}); +const mailFailed = (email: string, link: string | null): InviteResult => ({ + email, + emailSent: false, + fallbackLink: link, + mailError: "network error", +}); async function fill() { fireEvent.change(screen.getByLabelText(/Nombre/), { target: { value: "Ana Gómez" } }); @@ -57,7 +75,6 @@ async function fill() { describe("MemberInviteDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - mockedRequestPasswordReset.mockResolvedValue(undefined); }); it("blocks submit and stays on the form when required fields are empty", async () => { @@ -68,7 +85,7 @@ describe("MemberInviteDrawer", () => { positions={[]} onClose={() => {}} onCreate={onCreate} - onProvision={async () => ({ email: "", actionLink: "" })} + onProvision={async () => mailed("")} />, ); fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); @@ -80,9 +97,7 @@ describe("MemberInviteDrawer", () => { it("creates the member then provisions login when access is checked, reaching done", async () => { const onCreate = vi.fn().mockResolvedValue("new-id"); - const onProvision = vi - .fn() - .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo")); renderWithAbility( { await waitFor(() => expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument()); expect(onCreate).toHaveBeenCalledTimes(1); expect(onProvision).toHaveBeenCalledWith("new-id"); - expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo"); expect(screen.getByText(/Invitación enviada a ana@jci\.bo/)).toBeInTheDocument(); expect(screen.getByText(/recibirá un correo/i)).toBeInTheDocument(); }); it("skips provisioning when access is unchecked", async () => { - const onProvision = vi - .fn() - .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo")); renderWithAbility( { fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" })); await waitFor(() => expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument()); expect(onProvision).not.toHaveBeenCalled(); - expect(mockedRequestPasswordReset).not.toHaveBeenCalled(); expect(screen.getByText(/Aún no tiene acceso/)).toBeInTheDocument(); }); - it("shows email-sent copy when requestPasswordReset resolves", async () => { + it("shows email-sent copy when the invite reports the mail went out", async () => { renderWithAbility( {}} onCreate={async () => "id3"} - onProvision={async () => ({ email: "ana@jci.bo", actionLink: "https://example.com/link" })} + onProvision={async () => mailed("ana@jci.bo")} />, ); await fill(); @@ -143,18 +154,14 @@ describe("MemberInviteDrawer", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - it("shows warning and copy-link button when requestPasswordReset rejects", async () => { - mockedRequestPasswordReset.mockRejectedValue(new Error("network error")); + it("shows warning and copy-link button when the invite reports the mail failed", async () => { renderWithAbility( {}} onCreate={async () => "id4"} - onProvision={async () => ({ - email: "ana@jci.bo", - actionLink: "https://example.com/action-link", - })} + onProvision={async () => mailFailed("ana@jci.bo", "https://example.com/action-link")} />, ); await fill(); @@ -172,17 +179,13 @@ describe("MemberInviteDrawer", () => { // `.catch()` never ran, `copyState` never became "failed", and the select-all `` that // exists precisely for this never rendered. Routed through useCopyToClipboard now. it("BLOCKING: falls back to a selectable link when the clipboard API is unavailable", async () => { - mockedRequestPasswordReset.mockRejectedValue(new Error("network error")); renderWithAbility( {}} onCreate={async () => "idClip"} - onProvision={async () => ({ - email: "ana@jci.bo", - actionLink: "https://example.com/action-link", - })} + onProvision={async () => mailFailed("ana@jci.bo", "https://example.com/action-link")} />, ); await fill(); @@ -195,9 +198,7 @@ describe("MemberInviteDrawer", () => { // --- create:MemberLogin delegation --- - const drawer = ( - onProvision = vi.fn().mockResolvedValue({ email: "a@b.co", actionLink: "l" }), - ) => ({ + const drawer = (onProvision = vi.fn().mockResolvedValue(mailed("a@b.co"))) => ({ node: ( { // inviting a board member would be told "solo un administrador puede enviarle el acceso", // self-contradictory copy, suite green. it("BLOCKING: an ADMIN inviting a member on a power-granting cargo still provisions", async () => { - const onProvision = vi - .fn() - .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo")); renderWithAbility( { // beacon withholds the action link from a non-Admin caller (it is a bearer credential for // the account), so a delegate whose reset mail then fails has NO manual fallback — the copy // must send them to an Admin rather than to a copy button that would copy nothing. Only - // reachable as delegate + provision succeeded + requestPasswordReset rejected. + // reachable as delegate + provision succeeded + the mail rejected. it("BLOCKING: tells a delegate to ask an administrator when there is no action link to share", async () => { - mockedRequestPasswordReset.mockRejectedValue(new Error("network error")); renderWithAbility( {}} onCreate={async () => "idNoLink"} - onProvision={async () => ({ email: "ana@jci.bo", actionLink: "" })} + onProvision={async () => mailFailed("ana@jci.bo", null)} />, { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] }, ); diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx index dff924c9..a94db88a 100644 --- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx +++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Checkbox, Sheet } from "@luminova/ui"; import { type MemberInput, type Position } from "@luminova/types"; import { MemberForm } from "./member-form"; import { actionMessage } from "../lib/member-display"; -import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; +import type { InviteResult } from "../hooks/use-provision-member-login"; import { draftProvisionBlocked } from "../lib/provision-gate"; import { provisionRefusalMessage } from "../lib/provision-error"; import { useCopyToClipboard } from "../../../lib/use-copy-to-clipboard"; @@ -14,7 +14,7 @@ interface MemberInviteDrawerProps { positions: Position[]; onClose: () => void; onCreate: (data: MemberInput) => Promise; - onProvision: (memberId: string) => Promise<{ email: string; actionLink: string }>; + onProvision: (memberId: string) => Promise; } interface DoneState { @@ -68,7 +68,14 @@ export function MemberInviteDrawer({ if (open) setSendAccess(canProvisionLogin); }, [open, canProvisionLogin]); + // Bumped by every submit AND by every reset, so a submit that is still in flight when the + // operator dismisses the Sheet (the X, Escape, the overlay — only the submit BUTTON is + // disabled while sending) cannot land its done screen afterwards. Without it, reopening + // «Invitar miembro» showed the previous member's done screen, action link included. + const attempt = useRef(0); + const reset = () => { + attempt.current += 1; setDone(null); setSendAccess(canProvisionLogin); resetCopyState(); @@ -80,6 +87,8 @@ export function MemberInviteDrawer({ }; const handleSubmit = async (data: MemberInput) => { + const mine = attempt.current + 1; + attempt.current = mine; const id = await onCreate(data); let provisioned = false; let emailSent = false; @@ -108,16 +117,14 @@ export function MemberInviteDrawer({ // would invite a duplicate-create retry. Surface the real cause (App Check, // quota, config) instead of swallowing it — this is the only diagnostic we get. try { + // The mail is part of onProvision (use-provision-member-login), not a second step this + // component arranges: doing it here left one caller able to provision without mailing, + // and the action link is only valid when the mail did NOT go out. const result = await onProvision(id); provisioned = true; - actionLink = result.actionLink || null; - try { - await requestPasswordReset(data.email); - emailSent = true; - } catch (err) { - console.error("No se pudo enviar el correo de acceso", err); - errorDetail = err instanceof Error ? err.message : String(err); - } + emailSent = result.emailSent; + actionLink = result.fallbackLink; + errorDetail = result.mailError; } catch (err) { console.error("No se pudo aprovisionar el acceso del miembro", err); // A deliberate refusal becomes the headline; anything else keeps its raw message as @@ -128,6 +135,9 @@ export function MemberInviteDrawer({ } } } + // Closed or reset while this was in flight — the member IS created either way (the toast + // on the page behind reports that), but this drawer no longer owns the screen. + if (attempt.current !== mine) return; setDone({ blockedByCargo: sendAccess && provisionBlocked, provisionBlocked, @@ -235,6 +245,9 @@ export function MemberInviteDrawer({ allowPowerGrants={canAssignBoardSeat} allowReplacePowerCargo={isAdmin} assignerIsAdmin={isAdmin} + // A member being CREATED is never the caller: the create lane forbids `uid` to a + // non-Admin, so no seat written here can be the author's own. + isSelfAssignment={false} defaultValues={{ joinDate: today(), status: "Activo", cargoId: null, comisionIds: [] }} onSubmit={handleSubmit} > diff --git a/apps/backstage/src/features/members/components/member-profile-page.test.tsx b/apps/backstage/src/features/members/components/member-profile-page.test.tsx index 6dcdc6a9..95b31d1c 100644 --- a/apps/backstage/src/features/members/components/member-profile-page.test.tsx +++ b/apps/backstage/src/features/members/components/member-profile-page.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -50,7 +50,7 @@ const POWER_CARGO: Position = { active: true, deletedAt: null, }; -const positionsQuery = { data: [POWER_CARGO] as Position[] }; +const positionsQuery = { data: [POWER_CARGO] as Position[] | undefined, isError: false }; vi.mock("../hooks/use-member", () => ({ useMember: () => memberQuery })); vi.mock("../../positions/hooks/use-positions", () => ({ usePositions: () => positionsQuery })); @@ -80,13 +80,14 @@ vi.mock("../../../lib/auth/request-password-reset", () => ({ requestPasswordReset: vi.fn().mockResolvedValue(undefined), })); -// The callable's result is what the component branches on, so it is the knob each case turns. -// vi.hoisted because the factory runs at import time, before any plain top-level const here -// has been evaluated. -const { provisionMutate } = vi.hoisted(() => ({ provisionMutate: vi.fn() })); -vi.mock("../hooks/use-provision-member-login", () => ({ - useProvisionMemberLogin: () => ({ mutate: provisionMutate, isPending: false }), -})); +// The REAL useProvisionMemberLogin runs here, with only its two edges mocked: the callable and +// the reset mail. Faking the hook itself is what let the "mail sent from a component-scoped +// onSuccess" bug survive — a hand-written fake that invokes `opts.onSuccess` unconditionally +// models a TanStack mutation that always has listeners, which is precisely the thing that is +// not true. vi.hoisted because the factories run at import time. +const { callable } = vi.hoisted(() => ({ callable: vi.fn() })); +vi.mock("firebase/functions", () => ({ httpsCallable: () => callable })); +vi.mock("@luminova/firebase/functions", () => ({ getFunctionsService: () => ({}) })); import { MemberProfilePage } from "./member-profile-page"; import { AbilityProvider } from "../../../lib/authz/ability-context"; @@ -94,12 +95,10 @@ import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; const mockedRequestPasswordReset = vi.mocked(requestPasswordReset); -/** Drive the mocked mutation's onSuccess with whatever beacon is pretending to return. */ +/** What beacon is pretending to return. The MAIL's outcome is the other knob + * (`mockedRequestPasswordReset`); together they decide whether a fallback link exists. */ function provisionResolvesWith(result: { email: string; actionLink: string }) { - provisionMutate.mockImplementation((...args: unknown[]) => { - const opts = args[1] as { onSuccess?: (r: typeof result) => void } | undefined; - opts?.onSuccess?.(result); - }); + callable.mockResolvedValue({ data: result }); } function pageTree(claims: AuthClaims, queryClient: QueryClient) { @@ -146,8 +145,13 @@ describe("MemberProfilePage — InviteAccess", () => { await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo")); expect(mockedRequestPasswordReset).toHaveBeenCalledTimes(1); expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument(); - // …and the link is still offered as the manual fallback. - expect(screen.getByRole("button", { name: /Copiar enlace/ })).toBeInTheDocument(); + // BLOCKING, and the opposite of what this line used to assert: the link is NOT offered + // once the mail goes out. Firebase keeps only the most recent password-reset oobCode + // valid, so `sendPasswordResetEmail` above invalidated the one `actionLink` carries — + // offering it under "si no le llega el correo" hands the Admin a link that fails with + // auth/invalid-action-code, on the branch where the copy promises it works. + expect(screen.queryByRole("button", { name: /Copiar enlace/ })).not.toBeInTheDocument(); + expect(screen.queryByText("https://example.com/link")).not.toBeInTheDocument(); }); it("sends the reset mail when beacon withholds the link (delegate caller)", async () => { @@ -159,24 +163,26 @@ describe("MemberProfilePage — InviteAccess", () => { expect(screen.queryByRole("button", { name: /Copiar enlace/ })).not.toBeInTheDocument(); }); - // BLOCKING: the mail failure is now rendered TWICE — once in the header and once inside the - // dialog — and the second copy is the load-bearing one. The dialog opens as soon as the link - // arrives, before the mail settles; while it is open its modal `aria-hidden` takes the header - // alert out of the accessibility tree, so a screen-reader user heard only the dialog's - // "comparte este enlace" and never learned the mail had failed at all. - it("BLOCKING: repeats the mail failure INSIDE the dialog, not only in the header", async () => { + // BLOCKING: the dialog now exists ONLY on the mail-failure branch, so it must say so itself. + // Its modal `aria-hidden` takes the header alert out of the accessibility tree while it is + // open, and a dialog that only said "comparte este enlace" left a screen-reader user with no + // way to learn the mail had failed at all. + it("BLOCKING: the link dialog states the mail failed, not only the header", async () => { provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); mockedRequestPasswordReset.mockRejectedValue(new Error("network")); renderPage(); await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); - const copy = - "Se creó el acceso, pero no se pudo enviar el correo. Comparte el enlace manualmente."; - // getAllByText, not getByText: two renders of the same sentence is the point of the fix. - await waitFor(() => expect(screen.getAllByText(copy)).toHaveLength(2)); - const dialog = screen.getByRole("dialog"); - expect(within(dialog).getByRole("alert")).toHaveTextContent(copy); - // The header copy stays for after the dialog is dismissed — it is not moved, it is echoed. - expect(within(dialog).getAllByText(copy)).toHaveLength(1); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(/No se pudo enviar el correo/)).toBeInTheDocument(); + expect(within(dialog).getByText("https://example.com/link")).toBeInTheDocument(); + // The header keeps its own copy for after the dialog is dismissed. getByText, not + // getByRole("alert"): the open modal's aria-hidden takes that alert out of the + // accessibility tree — which is the whole reason the dialog has to say it too. + expect( + screen.getByText( + "Se creó el acceso, pero no se pudo enviar el correo. Comparte el enlace manualmente.", + ), + ).toBeInTheDocument(); }); it("points at an administrator when the mail fails and there is no link to share", async () => { @@ -189,13 +195,12 @@ describe("MemberProfilePage — InviteAccess", () => { ); }); - // BLOCKING: the reset MAIL is a floating promise the mutation does not track, so - // `provision.isPending` goes false the moment the CALLABLE resolves — long before the mail - // settles. The button therefore re-enabled mid-flight and a second click could interleave: - // attempt one's mail resolving into `sent` while attempt two's rejected into `error`, leaving - // the header asserting both at once (or, with the other ordering, a real failure silently - // overwritten by a stale success). `sending` is what closes that window; it is the guard, so - // it is what gets asserted rather than the unreachable race it prevents. + // BLOCKING: the mail is INSIDE the mutation, so `isPending` covers it. It used to be a + // floating promise the mutation did not track, so the button re-enabled the moment the + // CALLABLE resolved and a second click could interleave — attempt one's mail resolving into + // `sent` while attempt two's rejected into `error`, leaving the header asserting both. The + // window is closed structurally now rather than by a `sending` flag beside it, and this is + // what pins that: the callable has already resolved here and the button is still disabled. it("BLOCKING: stays disabled until the reset mail settles, not just the callable", async () => { provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); let settleMail = () => {}; @@ -206,16 +211,15 @@ describe("MemberProfilePage — InviteAccess", () => { }), ); renderPage(); - const button = screen.getByRole("button", { name: "Invitar acceso" }); - await userEvent.click(button); - // The callable already resolved (the mock calls onSuccess synchronously) and isPending is - // hardcoded false, so ONLY `sending` can be holding this. - const pendingButton = screen.getByRole("button", { name: "Generando…" }); - expect(pendingButton).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); + await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalled()); + expect(screen.getByRole("button", { name: "Generando…" })).toBeDisabled(); await act(async () => { settleMail(); }); - expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeEnabled(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeEnabled(), + ); expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument(); }); @@ -237,13 +241,12 @@ describe("MemberProfilePage — InviteAccess", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - // BLOCKING: `open` is no longer separate state — the dialog is `open={link !== null}`, and - // dismissing it clears `link`. Keeping the two in sync by hand is what left `link` out of - // invite()'s reset in the first place; deriving one from the other means a dismiss that did - // not clear `link` would make the dialog impossible to close at all. So closing it, and - // having it STAY closed, is the assertion that pins the derivation. - it("BLOCKING: closes the link dialog by clearing the link, and it stays closed", async () => { + // Dismissing the dialog must make it STAY dismissed. It is `open={link !== null && !dismissed}` + // — derived from the mutation's own data plus one flag — so a dismiss that did not set the + // flag would leave a dialog impossible to close at all. + it("BLOCKING: closes the link dialog, and it stays closed", async () => { provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + mockedRequestPasswordReset.mockRejectedValue(new Error("network")); renderPage(); await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); expect(await screen.findByRole("dialog")).toBeInTheDocument(); @@ -253,19 +256,19 @@ describe("MemberProfilePage — InviteAccess", () => { expect(screen.queryByText("https://example.com/link")).not.toBeInTheDocument(); }); - // The other half of that state collapse: invite() now resets `link` alongside `sent`, - // `error` and the copy state. A second invite that comes back WITHOUT a link (beacon - // withholds it from a non-Admin caller) must not re-open the dialog on the previous - // attempt's credential — an action link is a bearer credential for the account. - it("BLOCKING: a later linkless invite does not resurrect the previous action link", async () => { + // A second attempt must not re-open the dialog on the FIRST attempt's credential — an action + // link is a bearer credential for the account. The mutation replaces `data` wholesale, and + // `dismissed` is reset per click, so the only link that can render is the current one's. + it("BLOCKING: a later successful invite does not resurrect the previous action link", async () => { provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" }); + mockedRequestPasswordReset.mockRejectedValue(new Error("network")); renderPage(); await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); expect(await screen.findByRole("dialog")).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); - provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" }); + mockedRequestPasswordReset.mockResolvedValue(undefined); await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" })); await waitFor(() => expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument(), @@ -275,6 +278,46 @@ describe("MemberProfilePage — InviteAccess", () => { }); }); +// Guardrail #3: loading, error and absent are three states. This page reads `positions` for two +// unrelated jobs — the cargo editors and `memberProvisionBlocked` — and BOTH treat "absent" as +// "keep waiting". On a failed query nothing ever lands, so the page silently loses the form and +// the invite button with no error and no retry anywhere. +describe("MemberProfilePage — the positions query failed", () => { + beforeEach(() => { + vi.clearAllMocks(); + memberQuery.data = member(); + positionsQuery.data = undefined; + positionsQuery.isError = true; + }); + + afterEach(() => { + positionsQuery.data = [POWER_CARGO]; + positionsQuery.isError = false; + }); + + it("BLOCKING: says the catalog failed instead of rendering no form at all", () => { + renderPage(); + expect(screen.getByText(/No se pudo cargar el catálogo de cargos/)).toBeInTheDocument(); + }); + + // `memberProvisionBlocked` fails CLOSED on an unresolvable cargo, which is the right + // direction — but "we could not check" must not render as "not allowed", silently. + it("BLOCKING: tells a delegate why the invite affordance is missing", () => { + renderPage({ roles: ["Member"], perms: ["read:Member", "create:MemberLogin"] }); + expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument(); + expect( + screen.getByText(/no podemos verificar si este miembro puede recibir acceso/), + ).toBeInTheDocument(); + }); + + // An Admin is subject to none of those refusals, so the failed catalog cannot mislead them — + // the button stays, and they get the form-level notice only. + it("still offers the invite to an Admin", () => { + renderPage(); + expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeInTheDocument(); + }); +}); + // A successful invite makes `memberProvisionBlocked` TRUE — beacon writes `member.uid`, and // `hasLogin` is the first clause of the gate. So the flag that decides whether to offer the // button flips as a RESULT of pressing it. It therefore cannot gate the mount. diff --git a/apps/backstage/src/features/members/components/member-profile-page.tsx b/apps/backstage/src/features/members/components/member-profile-page.tsx index d60fd535..96f41e73 100644 --- a/apps/backstage/src/features/members/components/member-profile-page.tsx +++ b/apps/backstage/src/features/members/components/member-profile-page.tsx @@ -1,5 +1,5 @@ import { Link, getRouteApi } from "@tanstack/react-router"; -import { lazy, Suspense, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; import { Badge, Button, Card, Dialog, type BadgeTone } from "@luminova/ui"; import { currentTermKey, type Member, type MemberInput, type MemberStatus } from "@luminova/types"; import { ActionGate } from "../../../lib/authz/action-gate"; @@ -16,7 +16,6 @@ import { useActivitiesByTerm } from "../../activities/hooks/use-activities-by-te import { useInitiativesByTerm } from "../../initiatives/hooks/use-initiatives-by-term"; import { summarizeParticipations } from "../lib/participation-summary"; import { pointsRank } from "../../../lib/points-rank"; -import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; import { useProvisionMemberLogin } from "../hooks/use-provision-member-login"; import { useUpdateMember } from "../hooks/use-update-member"; import { useSetMemberPositions } from "../hooks/use-set-member-positions"; @@ -58,7 +57,11 @@ export function MemberProfilePage() { const gate = useCan(); const uid = useAuth().user?.uid; const { data: member, isLoading, isError, error, refetch } = useMember(memberId); - const { data: positions } = usePositions(); + // isError, not just data: `memberProvisionBlocked` fails CLOSED on an unresolvable cargo, so + // a failed catalog query (a rules regression, permission-denied — which TanStack does not + // retry) silently removes the invite affordance from every seated member with nothing said. + // Guardrail #3: loading, error and absent are three states, and only one of them is "wait". + const { data: positions, isError: positionsFailed } = usePositions(); const { data: points } = useMemberPoints(memberId, termId); const { data: participations } = useMemberParticipations(memberId, termId); const { data: allPoints } = useMemberPointsByTerm(termId); @@ -147,15 +150,24 @@ export function MemberProfilePage() { enviar el correo" state mid-flight — deleting, for a delegate, the only notice that the account exists with no password mail sent. */} - {/* key: the mount gate used to be `!inviteBlocked`, which ALSO happened to reset - this component between members. It no longer does, and TanStack Router renders - the same MemberProfilePage instance across a /members/A → /members/B - navigation (no key on Match), while `isLoading` skips the unmount whenever B is - warm in cache. Without this, B's header shows A's "Invitación enviada" — and if - the dialog was left open, A's password-reset link, a bearer credential, one - click from being copied on B's page. Stable across the refetch that sets - member.uid, so it does not reintroduce the flip-erases-its-own-result bug. */} - + {/* An Admin is subject to none of the refusals `inviteBlocked` mirrors, so the + failed catalog cannot mislead them. For everyone else it decides the + affordance, and "we could not check" must not render as "not allowed". */} + {positionsFailed && !gate.isAdmin ? ( +

+ No se pudo cargar el catálogo de cargos, así que no podemos verificar si este + miembro puede recibir acceso. Recarga la página. +

+ ) : ( + /* key: the mount gate used to be `!inviteBlocked`, which ALSO happened to reset + this component between members. It no longer does, and TanStack Router renders + the same MemberProfilePage instance across a /members/A → /members/B + navigation (no key on Match), while `isLoading` skips the unmount whenever B + is warm in cache. Without this, B's header shows A's "Invitación enviada". + Stable across the refetch that sets member.uid, so it does not reintroduce + the flip-erases-its-own-result bug. */ + + )}
} @@ -165,7 +177,13 @@ export function MemberProfilePage() {
{positions && canEdit && ( + {/* key, for the same reason as InviteAccess above and MemberDrawer's copy: RHF + reads `defaultValues` once at mount, and this page is NOT remounted across a + /members/A → /members/B param change when B is warm in cache. Without it the + form keeps A's name/email/status while `member.id` and `handleEdit` have moved + to B — «Guardar cambios» then writes A's identity onto B's document. */} )} + {/* Both editors below are gated on `positions` being present. Without this an editor + whose catalog query FAILED gets a page with no form and no explanation — the + absent/error conflation guardrail #3 names. */} + {positionsFailed && (canEdit || showPositionsOnly) && ( + +

+ No se pudo cargar el catálogo de cargos, así que el formulario no está disponible. + Recarga la página. +

+
+ )} + {isSelf && !canEdit && (

@@ -198,7 +228,10 @@ export function MemberProfilePage() {

Cargos

+ {/* Same reason as MemberForm above: without the key this form would save A's + cargo and comisiones onto B. */} (null); - const [sent, setSent] = useState(false); - const [error, setError] = useState(null); - // The MAIL is a floating promise the mutation does not track. Without this the button - // re-enables the instant the callable resolves, so a second click can interleave: attempt - // one's mail resolves and sets `sent` while attempt two's rejects and sets `error`, leaving - // the header claiming both — or, worse ordering, a real failure overwritten by a stale - // success. `attempt` makes every late setter check that it is still the current one. - const [sending, setSending] = useState(false); - const attempt = useRef(0); + const [dismissed, setDismissed] = useState(false); const { copyState, copy, resetCopyState } = useCopyToClipboard(); const label = member.uid ? "Reenviar acceso" : "Invitar acceso"; - const pending = provision.isPending || sending; + const result = provision.data; + // Only present when the mail did NOT go out — the hook nulls it otherwise, because sending + // the mail invalidates this oobCode. See InviteResult.fallbackLink. + const link = result?.fallbackLink ?? null; + const error = provision.isError + ? provisionErrorMessage(provision.error, "No se pudo generar el acceso.") + : result && !result.emailSent + ? "Se creó el acceso, pero no se pudo enviar el correo. " + + (result.fallbackLink + ? "Comparte el enlace manualmente." + : "Pídele a un administrador que lo reenvíe.") + : null; - // The reset MAIL is the delivery path for every new login, board member or not, Admin caller - // or delegate — it is `sendPasswordResetEmail`, which hands the secret to the mailbox owner. - // The action link beacon returns to an Admin (and withholds from a delegate, being a bearer - // credential for the account) is the manual FALLBACK on top, not a substitute: returning it - // used to short-circuit the mail, so this was the one surface where an Admin's invite sent - // nothing and the member waited for a mail that never came. const invite = () => { - const mine = ++attempt.current; - const current = () => attempt.current === mine; - setError(null); - setSent(false); - setLink(null); + setDismissed(false); resetCopyState(); - setSending(true); - provision.mutate(member.id, { - onSuccess: (result) => { - if (current() && result.actionLink) setLink(result.actionLink); - void requestPasswordReset(result.email) - .then(() => { - if (current()) setSent(true); - }) - .catch((err: unknown) => { - console.error("No se pudo enviar el correo de acceso", err); - if (!current()) return; - setError( - result.actionLink - ? "Se creó el acceso, pero no se pudo enviar el correo. Comparte el enlace manualmente." - : "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un administrador que lo reenvíe.", - ); - }) - .finally(() => { - if (current()) setSending(false); - }); - }, - onError: (err) => { - if (!current()) return; - setSending(false); - setError(provisionErrorMessage(err, "No se pudo generar el acceso.")); - }, - }); + provision.mutate(member.id); }; return ( <> {/* The BUTTON goes away when the callable would refuse; the feedback below does not. - `blocked` becomes true the moment this invite succeeds (the member now has a uid), - so gating the whole component on it would erase the result of the click that set it. */} + `blocked` becomes true the moment this invite succeeds (the member now has a uid and + the hook invalidates the query), so gating the whole component on it would erase the + result of the click that set it. */} {!blocked && ( - )} {error && ( @@ -325,37 +336,26 @@ function InviteAccess({ member, blocked }: { member: Member; blocked: boolean }) {error}

)} - {sent && ( + {result?.emailSent && (

Invitación enviada por correo.

)} - {/* `open` is not separate state: it was only ever set alongside `link`, and keeping the - two in sync by hand is what left `link` out of invite()'s reset. */} { - if (!o) setLink(null); + if (!o) setDismissed(true); }} title="Acceso de miembro" >
- {/* The dialog opens as soon as the link arrives, before the reset mail settles, so - its copy has to track that outcome. A fixed "ya le enviamos el correo" reads as a - flat contradiction of the failure alert behind it — and worse, the modal's - aria-hidden takes that alert out of the accessibility tree, so a screen-reader - user would hear ONLY the false sentence. For the same reason the mail FAILURE is - repeated inside the dialog rather than left to the header alert. */} + {/* This dialog exists ONLY on the mail-failure branch, so it never claims the member + was emailed. The mail failure is repeated here rather than left to the header + alert: the modal's aria-hidden takes that alert out of the accessibility tree. */}

- {sent - ? "Ya le enviamos el correo para crear su contraseña. Si no le llega, comparte este enlace con el miembro." - : "Comparte este enlace con el miembro para que cree su contraseña e inicie sesión."} + No se pudo enviar el correo. Comparte este enlace con el miembro para que cree su + contraseña e inicie sesión.

- {error && ( -

- {error} -

- )} {link} diff --git a/apps/backstage/src/features/members/components/members-page.tsx b/apps/backstage/src/features/members/components/members-page.tsx index 8de299bc..ad55d8a5 100644 --- a/apps/backstage/src/features/members/components/members-page.tsx +++ b/apps/backstage/src/features/members/components/members-page.tsx @@ -9,7 +9,6 @@ import { useSetMemberStatus } from "../hooks/use-set-member-status"; import { useUnpublishMember } from "../hooks/use-unpublish-member"; import { useProvisionMemberLogin } from "../hooks/use-provision-member-login"; import { provisionErrorMessage } from "../lib/provision-error"; -import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; import { MemberTable } from "./member-table"; import { MemberStatusFilter } from "./member-status-filter"; import { MemberFilterMeta } from "./member-filter-meta"; @@ -34,7 +33,12 @@ const NO_MEMBERS: Member[] = []; export function MembersPage() { const { data: members, isLoading, isError } = useMembers(); - const { data: positions } = usePositions(); + // isError, not just data: the row menu's "Invitar a la app" is gated on + // `memberProvisionBlocked`, which fails CLOSED on an unresolvable cargo — so a failed catalog + // query removes the affordance from every seated member's menu with nothing said, and the + // invite drawer's picker renders "ningún cargo es asignable con tus permisos", a permissions + // explanation for a failed query. Guardrail #3. + const { data: positions, isError: positionsFailed } = usePositions(); const addMember = useAddMember(); const updateMember = useUpdateMember(); const setMemberStatus = useSetMemberStatus(); @@ -92,13 +96,12 @@ export function MembersPage() { const handleProvision = async (member: Member) => { if (provision.isPending) return; try { - const { email } = await provision.mutateAsync(member.id); - try { - await requestPasswordReset(email); - setToast(actionMessage(member.name, "invited")); - } catch { - setToast("Acceso creado, pero el correo no se envió."); - } + const { emailSent } = await provision.mutateAsync(member.id); + setToast( + emailSent + ? actionMessage(member.name, "invited") + : "Acceso creado, pero el correo no se envió.", + ); } catch (err) { setToast(provisionErrorMessage(err, "No se pudo enviar la invitación.")); } @@ -182,6 +185,13 @@ export function MembersPage() { onClearAll={clearAll} /> + {positionsFailed && ( +

+ No se pudo cargar el catálogo de cargos. Los cargos no se muestran y las invitaciones no + están disponibles hasta que recargues la página. +

+ )} + {isError ? (

No se pudieron cargar los miembros. diff --git a/apps/backstage/src/features/members/hooks/use-provision-member-login.test.tsx b/apps/backstage/src/features/members/hooks/use-provision-member-login.test.tsx new file mode 100644 index 00000000..4a38268b --- /dev/null +++ b/apps/backstage/src/features/members/hooks/use-provision-member-login.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const callable = vi.fn(); +vi.mock("firebase/functions", () => ({ httpsCallable: () => callable })); +vi.mock("@luminova/firebase/functions", () => ({ getFunctionsService: () => ({}) })); +vi.mock("../../../lib/auth/request-password-reset", () => ({ + requestPasswordReset: vi.fn().mockResolvedValue(undefined), +})); + +import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; +import { useProvisionMemberLogin } from "./use-provision-member-login"; + +const mockedReset = vi.mocked(requestPasswordReset); + +function wrapper(client: QueryClient) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +function setup() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + const hook = renderHook(() => useProvisionMemberLogin(), { wrapper: wrapper(client) }); + return { ...hook, client, invalidate }; +} + +describe("useProvisionMemberLogin", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedReset.mockResolvedValue(undefined); + callable.mockResolvedValue({ + data: { email: "ana@jci.bo", actionLink: "https://example.com/link" }, + }); + }); + + it("provisions, then mails the member — and withholds the link that mail invalidated", async () => { + const { result } = setup(); + const invite = await result.current.mutateAsync("m1"); + expect(callable).toHaveBeenCalledWith({ memberId: "m1" }); + expect(mockedReset).toHaveBeenCalledWith("ana@jci.bo"); + // Firebase keeps only the most recent password-reset oobCode valid, so the mail above + // killed `actionLink`. Offering it as "por si no le llega" would hand the operator a link + // that fails with auth/invalid-action-code. + expect(invite).toEqual({ + email: "ana@jci.bo", + emailSent: true, + fallbackLink: null, + mailError: null, + }); + }); + + it("surfaces the link only when the mail did NOT go out", async () => { + mockedReset.mockRejectedValue(new Error("network error")); + const { result } = setup(); + const invite = await result.current.mutateAsync("m1"); + expect(invite.emailSent).toBe(false); + expect(invite.fallbackLink).toBe("https://example.com/link"); + expect(invite.mailError).toBe("network error"); + }); + + it("does not reject when only the mail fails: the account exists and the uid is linked", async () => { + mockedReset.mockRejectedValue(new Error("network error")); + const { result } = setup(); + await expect(result.current.mutateAsync("m1")).resolves.toBeDefined(); + }); + + // BLOCKING — the regression this hook was restructured for. The mail used to be sent from a + // component-scoped `provision.mutate(id, { onSuccess })`, and TanStack Query v5 runs those + // callbacks only while the observer still `hasListeners()`. An operator who navigated away + // (or, on the profile page, merely switched members — InviteAccess is keyed by member id) + // got the Auth account created and the uid linked with NO mail ever sent and no error + // anywhere. `mutationFn` has no such condition. + it("BLOCKING: sends the mail even when the caller unmounts before the callable resolves", async () => { + let resolveCallable: (v: unknown) => void = () => {}; + callable.mockReturnValue( + new Promise((resolve) => { + resolveCallable = resolve; + }), + ); + const { result, unmount } = setup(); + result.current.mutate("m1"); + unmount(); + resolveCallable({ data: { email: "ana@jci.bo", actionLink: "https://example.com/link" } }); + await waitFor(() => expect(mockedReset).toHaveBeenCalledWith("ana@jci.bo")); + }); + + // beacon writes members/{id}.uid. Without this the cached member keeps `uid: undefined` for + // the 5-minute default staleTime, so the invite button neither disappears nor relabels and a + // second click 403s on the adoption guard. + it("BLOCKING: invalidates the members cache, including after a failure", async () => { + const { result, invalidate } = setup(); + await result.current.mutateAsync("m1"); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["members"] }); + + invalidate.mockClear(); + callable.mockRejectedValue(new Error("boom")); + const second = setup(); + await expect(second.result.current.mutateAsync("m2")).rejects.toThrow("boom"); + expect(second.invalidate).toHaveBeenCalledWith({ queryKey: ["members"] }); + }); +}); diff --git a/apps/backstage/src/features/members/hooks/use-provision-member-login.ts b/apps/backstage/src/features/members/hooks/use-provision-member-login.ts index 0b21bcbc..350934a8 100644 --- a/apps/backstage/src/features/members/hooks/use-provision-member-login.ts +++ b/apps/backstage/src/features/members/hooks/use-provision-member-login.ts @@ -1,20 +1,76 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { httpsCallable } from "firebase/functions"; import { getFunctionsService } from "@luminova/firebase/functions"; +import { requestPasswordReset } from "../../../lib/auth/request-password-reset"; +import { memberKeys } from "./member-keys"; interface ProvisionResult { email: string; actionLink: string; } +/** What an invite actually produced. The MAIL is part of it, not a follow-up the caller + * arranges: every new login is delivered by `sendPasswordResetEmail`, board seat or not, Admin + * caller or delegate. */ +export interface InviteResult { + email: string; + emailSent: boolean; + /** The action link, and ONLY when the mail did not go out. + * + * It is `generatePasswordResetLink`'s oobCode URL, and Firebase keeps just the most recent + * password-reset code valid per user — so the mail this hook sends right after INVALIDATES + * it. Offering it as "por si no le llega el correo" alongside a mail that did go out hands + * the operator a link that fails with `auth/invalid-action-code`. Nulled here rather than at + * each call site: the three surfaces that render it cannot each be trusted to re-derive + * which of two secrets is the live one. */ + fallbackLink: string | null; + /** The mail failure's raw message — the only diagnostic for App Check / quota / config. */ + mailError: string | null; +} + +/** + * Provision a member's login AND deliver it. Both steps live in `mutationFn` on purpose. + * + * The mail used to be sent from a component-scoped `provision.mutate(id, { onSuccess })` + * callback. TanStack Query v5 runs those only `if (this.#mutateOptions && this.hasListeners())` + * (query-core `mutationObserver`), so an operator who navigated away — or, on the profile page, + * merely switched to another member, since `InviteAccess` is keyed by member id — got the Auth + * account created, the uid linked, and NO mail ever sent, with no error anywhere. The member + * then has a login they were never told about, and `memberProvisionBlocked` (hasLogin) hides + * the retry from the delegate who caused it. + * + * `mutationFn` has no such condition: it runs to completion regardless of who is still + * mounted. + */ export function useProvisionMemberLogin() { + const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (memberId: string) => { + mutationFn: async (memberId: string): Promise => { const fn = httpsCallable<{ memberId: string }, ProvisionResult>( getFunctionsService(), "provisionMemberLogin", ); - return (await fn({ memberId })).data; + const { email, actionLink } = (await fn({ memberId })).data; + // A mail failure is NOT a provisioning failure: the account exists and the uid is + // linked, and rejecting here would read as "nothing happened" and invite a retry the + // adoption guard refuses. + try { + await requestPasswordReset(email); + return { email, emailSent: true, fallbackLink: null, mailError: null }; + } catch (err) { + console.error("No se pudo enviar el correo de acceso", err); + return { + email, + emailSent: false, + fallbackLink: actionLink || null, + mailError: err instanceof Error ? err.message : String(err), + }; + } }, + // beacon writes `members/{id}.uid`; without this the cached member keeps `uid: undefined` + // for the 5-minute default staleTime, so the button neither disappears nor relabels and a + // second click 403s on the adoption guard. `settled`, not `success`: the callable can fail + // after linkUid. memberKeys.all is a prefix of memberKeys.detail, so one call covers both. + onSettled: () => queryClient.invalidateQueries({ queryKey: memberKeys.all }), }); } From c00fefe5eeefd27a105f50d238528cc393b9c4fc Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 16:21:56 -0400 Subject: [PATCH 18/25] fix(backstage,ui): fail closed on a cargo the editor cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positionsLockedForEditor took `Position | undefined` and asked only whether it confers power, so a held cargo whose id resolves to NOTHING read as "no cargo" and unlocked the picker. The rules have no such gap: currentCargoGrantsEmpty() get()s the real doc and a missing one errors the rule, which denies. So the client offered a full picker and an enabled Guardar for a write firestore.rules always rejects — the render-then-die shape this module exists to prevent, one input short. Reachable without a console edit. The catalog is parseDocs(positionDocSchema, …), which DROPS any doc failing the schema, so the very corruption that makes a cargo's power unknowable is what removes it from the array the client searches. `heldCargo(positions, cargoId)` is now the one way to build that input — it keeps the id alongside the lookup, so "seated on something unreadable" and "not seated" stop being the same value. It also replaces the `positions.find(...)` each form had typed for itself. `cargoSlotsForEditor` and `draftProvisionBlocked` lose their truthiness tests on the id for the same reason: "" is an id that resolves to nothing, not "no cargo", and beacon's readCargoIds manufactures exactly that value so its own guard refuses it. The provision-gate test enshrined that divergence as deliberate; the reasoning it gave ("the draft schema cannot produce one") makes the case unreachable, not the fail-open answer correct. MemberForm's four authority props are REQUIRED now. They were optional with `= false` defaults, which are not safe in the same direction: `isSelfAssignment = false` suppresses the mint-pending warning, `allowReplacePowerCargo = false` locks an Admin's picker, and a call site that forgot either compiled clean. The BLOCKING test named for the #224 flag conflation could not fail on flag conflation — one assertion duplicated an earlier test verbatim and the other was decided by the cargo alone. It now pins that the two flags DISAGREE for the same principal, which is the property collapsing them destroys. cargoTakedownOnly gets the truth table it never had. ui: MultiSelect takes aria-describedby, like Combobox. Both forms disable the comisiones picker on the same `locked` flag, and only the cargo picker was explaining itself — a screen-reader user met a dead control with no way to tell a permission ceiling from a broken widget. Mutation-tested: dropping the unresolvable clause turns exactly the two new BLOCKING rows red. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-form.test.tsx | 97 ++++++++++-- .../members/components/member-form.tsx | 35 +++-- .../components/member-positions-form.tsx | 14 +- .../no-assignable-cargos-note.test.tsx | 4 + .../members/lib/assignable-cargo-core.ts | 53 ++++++- .../members/lib/assignable-cargo.test.ts | 142 +++++++++++++----- .../members/lib/provision-gate.test.ts | 17 ++- .../features/members/lib/provision-gate.ts | 7 +- .../ui/src/components/multi-select-field.tsx | 7 + 9 files changed, 303 insertions(+), 73 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index 78b8adfc..9163e9b2 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -24,6 +24,16 @@ const BOARD_SEAT_LABEL = permissionLabel("update:BoardSeat"); // the sentence, which is the part both triggers share. const MINT_PENDING_COPY = /no se aplicarán hasta que un administrador confirme la asignación/i; +// The four authority props are REQUIRED on the component (a call site that forgets one used to +// compile clean and fail OPEN on `isSelfAssignment`). Spread FIRST in every render below so a +// case that cares about one still just names it — the explicit prop wins. +const FORM_AUTHORITY = { + allowPowerGrants: false, + allowReplacePowerCargo: false, + assignerIsAdmin: false, + isSelfAssignment: false, +} as const; + const positions: Position[] = [ { id: "pos-pres", @@ -91,7 +101,9 @@ const inactiveCargoPosition: Position = { describe("MemberForm", () => { it("blocks submit and shows an error when required fields are empty", async () => { const onSubmit = vi.fn(); - render(); + render( + , + ); await userEvent.click(screen.getByRole("button", { name: /crear/i })); expect(await screen.findAllByText("Mínimo 3 caracteres.")).not.toHaveLength(0); expect(onSubmit).not.toHaveBeenCalled(); @@ -99,7 +111,9 @@ describe("MemberForm", () => { it("renders the gender toggle and requires it on submit", async () => { const onSubmit = vi.fn(); - render(); + render( + , + ); expect(screen.getByRole("group", { name: "Género" })).toBeInTheDocument(); await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez"); await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo"); @@ -115,7 +129,13 @@ describe("MemberForm", () => { // authority that renders them all. it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => { render( - , + , ); await userEvent.click(screen.getByRole("button", { name: "Femenino" })); await userEvent.click(screen.getByLabelText("Cargo")); @@ -149,6 +169,7 @@ describe("MemberForm", () => { ]; const { unmount } = render( { // The delegate sees the very same catalog as assignable, and no note. render( - , + , ); expect(screen.queryByRole("note")).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Cargo")); @@ -176,6 +203,7 @@ describe("MemberForm", () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { const onSubmit = vi.fn(); render( { it("submits valid data with the chosen cargo and comisiones", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(); + render( + , + ); await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez"); await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo"); await userEvent.click(screen.getByRole("button", { name: "Femenino" })); @@ -257,7 +293,13 @@ describe("MemberForm", () => { it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( - , + , ); await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez"); await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo"); @@ -276,14 +318,21 @@ describe("MemberForm", () => { }); it("groups fields under section headers", () => { - render( {}} />); + render( + {}} + />, + ); expect(screen.getByText("Datos personales")).toBeInTheDocument(); expect(screen.getByText("Membresía")).toBeInTheDocument(); }); it("renders a children slot before the submit button", () => { render( - {}}> + {}}> extra-slot , ); @@ -293,6 +342,7 @@ describe("MemberForm", () => { it("shows inactive assigned cargo with (inactivo) suffix in combobox trigger", async () => { render( { // (createPositionsSafe applies the same predicate). Without it a non-Admin sees a // grant-free CEL cargo, picks 'Presidente', and the create 403s into a generic error. it("hides a grant-free CEL cargo from a non-Admin and keeps the JDL dirección", async () => { - render(); + render( + , + ); await userEvent.click(screen.getByLabelText("Cargo")); expect(await screen.findByText("Director de Área")).toBeInTheDocument(); expect(screen.queryByText("Presidente")).not.toBeInTheDocument(); @@ -314,7 +371,13 @@ describe("MemberForm", () => { it("shows a grant-free CEL cargo to an Admin", async () => { render( - , + , ); await userEvent.click(screen.getByLabelText("Cargo")); expect(await screen.findByText("Presidente")).toBeInTheDocument(); @@ -342,6 +405,7 @@ describe("MemberForm", () => { it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => { render( { it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => { render( { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => { render( { it("BLOCKING: locks for a board-seat DELEGATE on a member seated on a power-granting cargo", () => { render( { it("does NOT lock an Admin on that same power-granting seat", () => { render( { it("BLOCKING: warns a delegate seating THEMSELVES on a non-Admin power cargo", async () => { render( { // another member, so a note here would be false and would train users past the real one. render( { // refusal for them. Without this cell the fix could be "warn on any self-assignment". render( { it("defaults both new props to false rather than warning by accident", async () => { render( { it("BLOCKING: associates the takedown note with the trigger", () => { render( { it("renders comisión option as 'sigla — title' when sigla is present", async () => { render( ; @@ -48,20 +58,20 @@ interface MemberFormProps { * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`, applied by both * `createPositionsSafe` and `positionsAssignmentSafe`). Non-Admin sees only assignable * cargos plus the current selection. */ - allowPowerGrants?: boolean; + allowPowerGrants: boolean; /** Whether the editor may REPLACE a cargo that already confers power (rules' * `currentCargoGrantsEmpty`, the other conjunct). Admin role only — `update:BoardSeat` * deliberately does NOT lift this one, so it must not be folded into `allowPowerGrants`. * See positionsLockedForEditor(). */ - allowReplacePowerCargo?: boolean; + allowReplacePowerCargo: boolean; /** Whether the CALLER holds the Admin role, which is what beacon's `resolveTrustedGrants` * keys the mint on. Named after the minting authority, not after `allowReplacePowerCargo`, * which mirrors a different rules predicate and only happens to equal it today. */ - assignerIsAdmin?: boolean; + assignerIsAdmin: boolean; /** Whether the member being edited IS the caller. The trust gate refuses to mint a * self-assignment of any granting cargo from a non-Admin — confer power on others, never on * yourself — so the picker must say so before the click. */ - isSelfAssignment?: boolean; + isSelfAssignment: boolean; children?: ReactNode; } @@ -91,10 +101,10 @@ export function MemberForm({ onSubmit, showPreview, avatarSeed, - allowPowerGrants = false, - allowReplacePowerCargo = false, - assignerIsAdmin = false, - isSelfAssignment = false, + allowPowerGrants, + allowReplacePowerCargo, + assignerIsAdmin, + isSelfAssignment, children, }: MemberFormProps) { const [formError, setFormError] = useState(null); @@ -140,8 +150,8 @@ export function MemberForm({ // slot. A grant-free CEL seat is NOT locked: clearing it is deliberately allowed, so the // form stays open, the seat renders disabled (visible, not assignable) and "Quitar cargo" // makes the takedown reachable. See positionsLockedForEditor() / cargoTakedownOnly(). - const assignedCargo = positions.find((p) => p.id === assignedCargoId); - const positionsLocked = positionsLockedForEditor(assignedCargo, allowReplacePowerCargo); + const held = heldCargo(positions, assignedCargoId); + const positionsLocked = positionsLockedForEditor(held, allowReplacePowerCargo); const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants); const cargoOptions = cargoOptionsForEditor({ positions, @@ -324,6 +334,9 @@ export function MemberForm({ value={field.value} onChange={field.onChange} disabled={positionsLocked} + // Same flag disables it as the cargo picker, so it owes the same + // explanation — see the note in member-positions-form. + aria-describedby={positionsLocked ? NOTE_IDS.locked : undefined} /> )} /> diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx index 3ff393ea..26e272b2 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -12,7 +12,11 @@ import { } from "../lib/assignable-cargo"; // Directly from the rules-mirroring module, not through assignable-cargo.ts: the file a // predicate comes from is what says the emulator parity test holds it to firestore.rules. -import { cargoTakedownOnly, positionsLockedForEditor } from "../lib/assignable-cargo-core"; +import { + cargoTakedownOnly, + heldCargo, + positionsLockedForEditor, +} from "../lib/assignable-cargo-core"; import { cargoNoteIds, MintPendingNote, NoAssignableCargosNote } from "./no-assignable-cargos-note"; const NOTE_IDS = cargoNoteIds("positions"); @@ -71,8 +75,8 @@ export function MemberPositionsForm({ // denied, CLEARING it is allowed on purpose — so the form stays open, the seat renders as a // disabled option (the trigger must not claim "Sin cargo" for a seated member) and only the // takedown can be saved. See positionsLockedForEditor() / cargoTakedownOnly(). - const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId); - const locked = positionsLockedForEditor(assignedCargo, allowReplacePowerCargo); + const held = heldCargo(positions, defaultValues.cargoId); + const locked = positionsLockedForEditor(held, allowReplacePowerCargo); const cargoOptions = cargoOptionsForEditor({ positions, gender, @@ -150,6 +154,10 @@ export function MemberPositionsForm({ value={field.value} onChange={field.onChange} disabled={locked} + // Disabled by the same flag as the cargo picker, so it owes the same + // explanation: a11y-wise a dead control with no reason is indistinguishable + // from a broken one. `locked` is the only state that disables it. + aria-describedby={locked ? NOTE_IDS.locked : undefined} /> )} /> diff --git a/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx index 56ed41ca..d5d89a39 100644 --- a/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx +++ b/apps/backstage/src/features/members/components/no-assignable-cargos-note.test.tsx @@ -49,6 +49,10 @@ function renderMemberForm(cargoId: string | null) { positions={[POWER_CARGO]} defaultValues={{ cargoId }} submitLabel="Guardar" + allowPowerGrants={false} + allowReplacePowerCargo={false} + assignerIsAdmin={false} + isSelfAssignment={false} onSubmit={vi.fn()} />, ).container; diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index e91ed6c0..4f984479 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -82,6 +82,46 @@ export function cargoConfersPower(cargo: Pick | undefined): return cargo !== undefined && cargo.grants.length > 0; } +/** + * The cargo a member currently holds, resolved against the catalog — and, distinctly, whether + * they hold one AT ALL. Those are two different questions and collapsing them to a bare + * `Position | undefined` is what made the lock fail OPEN: an id that does not resolve read as + * "no cargo", so `cargoConfersPower(undefined)` was false and the slot unlocked. + * + * That is reachable without a console edit. The catalog is `parseDocs(positionDocSchema, …)`, + * which DROPS any doc failing the schema — a `grants` entry outside `ROLES`, a bad `category` — + * so the very corruption that makes a cargo's power unknowable is what removes it from this + * array. The rules have no such gap: `currentCargoGrantsEmpty()` `get()`s the real doc, and a + * missing one errors the rule, which denies. + * + * Built here rather than at each call site because both forms need it and `positions.find(…)` + * repeated per form is the shape these predicates already drifted through once. + */ +export interface HeldCargo

{ + /** What the member doc says, verbatim. `""` is NOT "no cargo" — it is an unresolvable id. */ + cargoId: string | null | undefined; + /** The catalog entry, or `undefined` when the id resolves to nothing. */ + cargo: P | undefined; +} + +export function heldCargo

( + positions: readonly P[], + cargoId: string | null | undefined, +): HeldCargo

{ + return { + cargoId, + cargo: + cargoId === null || cargoId === undefined + ? undefined + : positions.find((p) => p.id === cargoId), + }; +} + +/** Whether the member is seated on SOMETHING whose power this editor cannot establish. */ +function heldCargoUnresolvable(held: HeldCargo): boolean { + return held.cargoId !== null && held.cargoId !== undefined && held.cargo === undefined; +} + /** * Whether this editor is barred from touching the positions slot AT ALL, given the cargo the * member currently holds. NOT the negation of `cargoAssignableByNonAdmin` — the two rules @@ -90,6 +130,9 @@ export function cargoConfersPower(cargo: Pick | undefined): * grants.length > 0 → locked. `currentCargoGrantsEmpty()` gates the cargo being REPLACED, * so the editor can neither keep it (the save re-stamps it) nor clear * it. Nothing they can do here succeeds. + * unresolvable id → locked, for the same reason and by the same authority: the rules + * `get()` the doc and deny on a missing one, so an editor who cannot + * read the cargo cannot submit anything either. See HeldCargo. * grant-free CEL → NOT locked. `currentCargoGrantsEmpty()` is deliberately not * category-gated — firestore.rules says denying this "would strand a * takedown behind an Admin" — so clearing the seat is allowed even @@ -112,10 +155,11 @@ export function cargoConfersPower(cargo: Pick | undefined): * delegate silently unlocked the editor for a write the rules always deny. */ export function positionsLockedForEditor( - cargo: Pick | undefined, + held: HeldCargo>, allowReplacePowerCargo: boolean, ): boolean { - return !allowReplacePowerCargo && cargoConfersPower(cargo); + if (allowReplacePowerCargo) return false; + return cargoConfersPower(held.cargo) || heldCargoUnresolvable(held); } /** @@ -181,7 +225,10 @@ export function cargoSlotsForEditor

({ .filter((p) => allowPowerGrants || cargoAssignableByNonAdmin(p)) .map((p) => ({ position: p, retired: false, disabled: false })); - const held = assignedCargoId ? positions.find((p) => p.id === assignedCargoId) : undefined; + // heldCargo(), not `assignedCargoId ? find(…)`: a truthiness test reads `""` as "no cargo", + // and `""` is exactly the shape termPositionsDocSchema admits and beacon's readCargoIds + // manufactures on purpose so the guard refuses it. + const held = heldCargo(positions, assignedCargoId).cargo; if (held === undefined || slots.some((s) => s.position.id === held.id)) return slots; return [ ...slots, diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts index 77099acd..66d34df6 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.test.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.test.ts @@ -21,6 +21,20 @@ const POWER = cargo("CEL", ["Secretary"]); const CEL_FREE = cargo("CEL"); const JDL_FREE = cargo("JDL"); +/** A full catalog entry for the one assertion that has to go through the option list. */ +const POWER_POSITION: Position = { + id: "pos-power", + title: "Secretario", + titleFemale: "Secretaria", + category: "CEL", + grants: ["Secretary"], + term: null, + sigla: null, + description: "", + active: true, + deletedAt: null, +}; + // positionsLockedForEditor mirrors the OLD side of firestore.rules positionsAssignmentSafe() // (`currentCargoGrantsEmpty`, the cargo being REPLACED), which is Admin-ROLE only and is // deliberately NOT lifted by update:BoardSeat. Its flag is therefore NOT `allowPowerGrants`. @@ -28,35 +42,94 @@ const JDL_FREE = cargo("JDL"); // forms exercise the two diagonal cells, and the delegate cell is the one the old // `!allowPowerGrants && …` call sites got wrong. describe("positionsLockedForEditor", () => { + /** A seated member, id resolved against a one-cargo catalog. */ + const seated = (c: Pick) => ({ cargoId: "held", cargo: c }); + it("locks a power-granting cargo for anyone who may not replace one", () => { - expect(positionsLockedForEditor(POWER, false)).toBe(true); + expect(positionsLockedForEditor(seated(POWER), false)).toBe(true); }); it("does not lock a power-granting cargo for an Admin", () => { - expect(positionsLockedForEditor(POWER, true)).toBe(false); + expect(positionsLockedForEditor(seated(POWER), true)).toBe(false); }); it("never locks a grant-free cargo, CEL or JDL, either way", () => { // The asymmetric case: keeping a grant-free CEL seat is denied but CLEARING it is allowed // on purpose, so locking here would strand the takedown behind an Admin. for (const flag of [true, false]) { - expect(positionsLockedForEditor(CEL_FREE, flag)).toBe(false); - expect(positionsLockedForEditor(JDL_FREE, flag)).toBe(false); + expect(positionsLockedForEditor(seated(CEL_FREE), flag)).toBe(false); + expect(positionsLockedForEditor(seated(JDL_FREE), flag)).toBe(false); } }); it("never locks when there is no assigned cargo", () => { - expect(positionsLockedForEditor(undefined, false)).toBe(false); - expect(positionsLockedForEditor(undefined, true)).toBe(false); + expect(positionsLockedForEditor({ cargoId: null, cargo: undefined }, false)).toBe(false); + expect(positionsLockedForEditor({ cargoId: undefined, cargo: undefined }, true)).toBe(false); + }); + + // Fails CLOSED, and the direction is the whole point: `cargoConfersPower(undefined)` is + // false, so a seat whose id resolves to nothing used to read as "no cargo" and unlock the + // picker for a write firestore.rules always denies (`currentCargoGrantsEmpty()` get()s the + // real doc and a missing one errors the rule). Reachable without a console edit — the + // catalog is parseDocs(), which DROPS a position whose `grants`/`category` fails the schema, + // so the very corruption that hides the cargo's power is what removes it from the array. + it("BLOCKING: locks a seat whose cargo id does not resolve", () => { + expect(positionsLockedForEditor({ cargoId: "ghost", cargo: undefined }, false)).toBe(true); + // "" is an id, not "no cargo" — the shape termPositionsDocSchema admits and beacon's + // readCargoIds manufactures on purpose so its own guard refuses it. + expect(positionsLockedForEditor({ cargoId: "", cargo: undefined }, false)).toBe(true); + // An Admin may replace it, so the lock lifts for them exactly as it does for a real seat. + expect(positionsLockedForEditor({ cargoId: "ghost", cargo: undefined }, true)).toBe(false); }); - it("BLOCKING: the flag is honored independently of the NEW-side one", () => { - // The regression in one line. A board-seat delegate carries allowPowerGrants=true (the - // NEW side, which update:BoardSeat lifts) while allowReplacePowerCargo stays false (the - // OLD side, Admin-only). Folding the two into one flag unlocked a write the rules always - // deny; this asserts the OLD side is the ONLY input here. - expect(positionsLockedForEditor(POWER, false)).toBe(true); - expect(cargoTakedownOnly(POWER, true)).toBe(false); + // The #224 regression, and NOT just "false locks / true does not" restated — that pair is + // already the two tests above and would stay green with both flags collapsed into one. What + // makes this load-bearing is the third assertion: for the SAME principal the two flags must + // disagree, so a delegate may ASSIGN a power cargo (the NEW side, which update:BoardSeat + // lifts) while still being locked out of REPLACING the one a member already holds (the OLD + // side, Admin-role only). Collapse them either way and one of these three goes red. + it("BLOCKING: a delegate may assign a power cargo but not replace one", () => { + const delegate = { allowPowerGrants: true, allowReplacePowerCargo: false }; + expect(positionsLockedForEditor(seated(POWER), delegate.allowReplacePowerCargo)).toBe(true); + // The counterfactual: wiring the NEW-side flag in here is what unlocked the editor. + expect(positionsLockedForEditor(seated(POWER), delegate.allowPowerGrants)).toBe(false); + const offered = cargoOptionsForEditor({ + positions: [POWER_POSITION], + gender: "Masculino", + allowPowerGrants: delegate.allowPowerGrants, + assignedCargoId: null, + }); + expect(offered.map((o) => o.value)).toContain(POWER_POSITION.id); + }); +}); + +// The OTHER rules-mirroring predicate, and the one state where the client is stricter on one +// side and not the other: a non-delegate may not KEEP a grant-free CEL seat but must be able to +// CLEAR it. Its truth table was covered only by the parity test's single row, and the one +// assertion this file had for it was decided by the cargo alone. +describe("cargoTakedownOnly", () => { + it("is true only for a grant-free CEL seat held by a non-delegate", () => { + expect(cargoTakedownOnly(CEL_FREE, false)).toBe(true); + }); + + it("is false for a delegate: they may keep the seat, so there is nothing to take down", () => { + expect(cargoTakedownOnly(CEL_FREE, true)).toBe(false); + }); + + it("is false for a cargo the non-delegate may simply assign", () => { + expect(cargoTakedownOnly(JDL_FREE, false)).toBe(false); + }); + + it("is false for a power-granting cargo — that is `locked`, not takedown-only", () => { + // The two states are mutually exclusive on purpose: a power seat cannot be cleared either + // (currentCargoGrantsEmpty gates the OLD side), so offering "Quitar cargo" would promise a + // write the rules deny. + expect(cargoTakedownOnly(POWER, false)).toBe(false); + expect(cargoTakedownOnly(cargo("JDL", ["Secretary"]), false)).toBe(false); + }); + + it("is false when nothing is selected", () => { + expect(cargoTakedownOnly(undefined, false)).toBe(false); }); }); @@ -92,30 +165,27 @@ describe("noAssignableCargos", () => { ).toBe(false); }); - // The `!locked` clause is DEFENSIVE, not reachable through either form today, and this pins - // the invariant that makes it so — rather than deleting a clause whose redundancy depends on - // a coincidence between two other functions. + // The `!locked` clause carries the two locked states differently, and BOTH are asserted + // below because only one of them is redundant. // - // Both forms derive `locked` and `cargoOptions` from the SAME (positions, assignedCargoId) - // pair. locked === true therefore implies the id resolved (positionsLockedForEditor returns - // false for an unresolved cargo) and carries grants, so cargoOptionsForEditor appends it as a - // disabled option and the length clause alone already returns false. Break either half — give - // the forms independent inputs, or stop appending the held cargo — and `!locked` becomes the - // only thing keeping the locked note and the empty-catalog note from rendering together. - it("BLOCKING: locked implies a non-empty option list, which is why !locked is defensive", () => { - const held: Position = { - id: "pos-power", - title: "Secretario", - titleFemale: null, - category: "CEL", - grants: ["Secretary"], - term: null, - sigla: null, - description: "", - active: true, - deletedAt: null, - }; - const locked = positionsLockedForEditor(held, false); + // held cargo RESOLVES and confers power — `cargoOptionsForEditor` appends it as a disabled + // option, so the length clause alone already returns false. `!locked` is defensive here, + // and this pins the coincidence that makes it so rather than deleting a clause whose + // redundancy depends on two other functions agreeing. + // held cargo does NOT resolve — nothing is appended, so on a catalog with no assignable + // cargo the list really is empty while `locked` is true. `!locked` is the ONLY thing + // keeping the empty-catalog note ("ningún cargo es asignable con tus permisos", a + // permissions explanation) from rendering under a slot that is locked for a different + // reason. Not defensive at all since positionsLockedForEditor started failing closed. + it("BLOCKING: !locked suppresses the empty-catalog note for both locked states", () => { + const unresolved = { cargoId: "ghost", cargo: undefined }; + expect(positionsLockedForEditor(unresolved, false)).toBe(true); + expect(noAssignableCargos({ cargoOptions: [], allowPowerGrants: false, locked: true })).toBe( + false, + ); + + const held = POWER_POSITION; + const locked = positionsLockedForEditor({ cargoId: held.id, cargo: held }, false); expect(locked).toBe(true); const cargoOptions = cargoOptionsForEditor({ positions: [held], diff --git a/apps/backstage/src/features/members/lib/provision-gate.test.ts b/apps/backstage/src/features/members/lib/provision-gate.test.ts index 564d2f08..34b84f45 100644 --- a/apps/backstage/src/features/members/lib/provision-gate.test.ts +++ b/apps/backstage/src/features/members/lib/provision-gate.test.ts @@ -180,14 +180,15 @@ describe("draftProvisionBlocked", () => { expect(draftProvisionBlocked(undefined, catalog, false)).toBe(false); }); - // Deliberately NOT the member variant's answer, and pinned so the difference is a decision - // rather than a leftover. `memberProvisionBlocked` reads a STORED doc, where a malformed "" - // is reachable and must fail closed; this reads the draft the invite drawer is about to - // create, whose cargoId comes from `z.string().min(1).nullable()` — "" cannot be produced, - // and the create lane forbids a non-Admin the uid/roleIds/overrides halves anyway. If the - // draft schema ever stops guaranteeing that, this line is the one that has to move. - it("reads an empty-string draft cargoId as no cargo — the schema cannot produce one", () => { - expect(draftProvisionBlocked("", catalog, false)).toBe(false); + // The SAME answer as the member variant, which is the point. This used to read "" as "no + // cargo" and the test enshrined the divergence as deliberate — but a mirror whose two halves + // disagree about what "no cargo" means is precisely how these predicates drift, and the + // reasoning ("`z.string().min(1).nullable()` cannot produce one") makes the case UNREACHABLE, + // not the fail-open answer correct. "" is an id that resolves to nothing, both sides. + it("BLOCKING: treats an empty-string draft cargoId as unresolvable, like the member variant", () => { + expect(draftProvisionBlocked("", catalog, false)).toBe(true); + // …and an Admin is subject to none of it, on either side. + expect(draftProvisionBlocked("", catalog, true)).toBe(false); }); it("does not block a draft seated on a grant-free cargo", () => { diff --git a/apps/backstage/src/features/members/lib/provision-gate.ts b/apps/backstage/src/features/members/lib/provision-gate.ts index 5f606ea0..ccacd6d4 100644 --- a/apps/backstage/src/features/members/lib/provision-gate.ts +++ b/apps/backstage/src/features/members/lib/provision-gate.ts @@ -68,7 +68,7 @@ export function memberProvisionBlocked( // `grant` only, mirroring beacon's hasDirectGrants: a revoke-only override mints nothing, // so it is not a reason to withhold the invite. hasDirectGrants: - (member.roleIds?.length ?? 0) > 0 || (member.permissionOverrides?.grant.length ?? 0) > 0, + (member.roleIds?.length ?? 0) > 0 || (member.permissionOverrides?.grant?.length ?? 0) > 0, seatedCargos: cargoIds.map(cargo), }); } @@ -85,6 +85,9 @@ export function draftProvisionBlocked( return provisionBlockedForNonAdmin({ hasLogin: false, hasDirectGrants: false, - seatedCargos: cargoId ? [cargo(cargoId)] : [], + // Explicitly null/undefined, NOT truthiness — same rule its sibling states 25 lines up. + // `memberSchema` keeps "" out of this form today, so the divergence is latent; a mirror + // whose two halves disagree about what "no cargo" means is how they drift apart anyway. + seatedCargos: cargoId === null || cargoId === undefined ? [] : [cargo(cargoId)], }); } diff --git a/packages/ui/src/components/multi-select-field.tsx b/packages/ui/src/components/multi-select-field.tsx index a1ee4984..1e5a5718 100644 --- a/packages/ui/src/components/multi-select-field.tsx +++ b/packages/ui/src/components/multi-select-field.tsx @@ -16,6 +16,11 @@ interface MultiSelectProps { emptyText?: string; disabled?: boolean; id?: string; + /** Id of the element explaining this control — a permission note, a "why is this disabled" + * sentence. Same reason Combobox takes it: those notes sit AFTER the field in the DOM, so a + * screen-reader user reaching a disabled trigger otherwise hears a dead control with no + * explanation and cannot tell a permission ceiling from a broken widget. */ + "aria-describedby"?: string; } /** Multi-select + search on Radix Popover + cmdk; selected render as removable chips. */ @@ -28,6 +33,7 @@ export function MultiSelect({ emptyText = "Sin resultados", disabled, id, + "aria-describedby": describedBy, }: MultiSelectProps) { const [open, setOpen] = useState(false); const chosen = selectedOptions(options, value); @@ -41,6 +47,7 @@ export function MultiSelect({ disabled={disabled} aria-haspopup="listbox" aria-expanded={open} + aria-describedby={describedBy} className={cn( fieldControlClasses, "flex h-auto min-h-[52px] flex-wrap items-center gap-1.5 px-3 py-[7px] text-left disabled:opacity-60", From 499253fde2f384d031e3f6a73c591cb9930deed5 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 16:22:11 -0400 Subject: [PATCH 19/25] test(rules): make the parity test catch a rules LOOSENING, and stop mirroring buildCan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in this branch's headline deliverable. It asserted `client offers => rules allow` and nothing else, which catches a rules TIGHTENING and structurally cannot catch a LOOSENING. Delete `&& category != 'CEL'` from cargoAssignableByNonAdmin(), or widen boardSeatDelegate() from hasPerm('update:BoardSeat') to canDo('update','BoardSeat') so manage:all satisfies it, and all 429 lines stayed green — including the row named "the delegation is live", which reads as a rules property and asserts only about the client. The converse cannot be asserted wholesale (the client is deliberately stricter about comisiones, retired and inactive cargos, and flagging that curation would make the test an obstacle to it), but it can be for the three cargos the delegation is ABOUT, where the gap is not curation but the boundary. Both loosenings now turn those rows red — verified by making each mutation against firestore.rules and re-running the suite. And `gatesFor` hand-re-implemented buildCan's claims -> flags mapping: the exact mirror class this file exists to delete, of the exact flag whose widening caused the #224 regression. It would have kept agreeing with itself while use-can.ts drifted. The derivation is now `capabilityFlags()`, split out of use-can.ts (React, so this package cannot load it) and spread back into buildCan — one function, both sides. Also corrects a comment that justified the local category union by claiming CargoLike widens it to `string`. It does not; assignable-cargo-core declares PositionCategory and documents at length why. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/authz/capability-flags.ts | 66 +++++++++++++++++++ apps/backstage/src/lib/authz/use-can.ts | 64 ++---------------- .../cargo-assignment-parity.test.ts | 65 +++++++++++++----- 3 files changed, 122 insertions(+), 73 deletions(-) create mode 100644 apps/backstage/src/lib/authz/capability-flags.ts diff --git a/apps/backstage/src/lib/authz/capability-flags.ts b/apps/backstage/src/lib/authz/capability-flags.ts new file mode 100644 index 00000000..a80bf929 --- /dev/null +++ b/apps/backstage/src/lib/authz/capability-flags.ts @@ -0,0 +1,66 @@ +import { hasAnyRole, hasPerm, type AuthClaims } from "@luminova/auth/roles"; +import type { PermissionCode } from "@luminova/types"; + +/** + * The claims → capability-flag derivation, split out of `buildCan` (./use-can) for ONE reason: + * `tests/firestore-rules/cargo-assignment-parity.test.ts` needs the flags the member forms are + * actually wired from, and it cannot load `use-can.ts` (React) — so it re-implemented this + * mapping by hand, which is the mirror class that whole test exists to delete, applied to the + * very flag whose widening caused the #224 regression. + * + * No runtime `@luminova/types` import: `PermissionCode` is type-only and erased, the same trick + * `assignable-cargo-core.ts` and `nav-config.ts` document. Keep it that way — the rules-test + * package cannot resolve that package at runtime. + */ + +/** The shape every delegable capability gate takes: the Admin ROLE, or one exact permission + * code. Extracted at the third occurrence — `hasPerm` is deliberately NOT `abilityAllows`, + * and re-deriving that decision per flag is how one of them ends up looser than its rule. + * See `canFeatureInitiatives` below for the full reasoning it encodes. */ +function adminOrPerm(claims: AuthClaims, code: PermissionCode): boolean { + return hasAnyRole(claims, ["Admin"]) || hasPerm(claims, code); +} + +export interface CapabilityFlags { + /** Shorthand for the Admin role (not the `manage:all` perm). */ + readonly isAdmin: boolean; + /** May curate the public /programas page (rules' `canCurateFeatured`). */ + readonly canFeatureInitiatives: boolean; + /** May SEAT a member on a cargo the plain non-Admin lane refuses — a power-granting one or + * a CEL one. Mirrors firestore.rules' `boardSeatDelegate()` disjunct for disjunct. + * Governs the member CREATE and UPDATE lanes only. */ + readonly canAssignBoardSeat: boolean; + /** May AUTHOR the positions catalog. Split from `canAssignBoardSeat` and deliberately NOT + * widened by the delegation: re-unifying them would hand a seat delegate the catalog, and + * the catalog is the door round the back — mint a grant-free CEL 'Presidente', then seat + * yourself on it at public board rank 0. */ + readonly canEditCargoCatalog: boolean; + /** May run `provisionMemberLogin`. Mirrors beacon's + * `requireAdminOrPerm(request, "create:MemberLogin")`. Cargo-agnostic. NOT the invite email + * itself — `requestPasswordReset` is a client-side `sendPasswordResetEmail` any signed-in + * user can already call. */ + readonly canProvisionLogin: boolean; +} + +export function capabilityFlags(claims: AuthClaims): CapabilityFlags { + return { + isAdmin: hasAnyRole(claims, ["Admin"]), + // Mirrors canCurateFeatured() in firestore.rules disjunct for disjunct: Admin by ROLE + // (locked + undeactivatable, so its name carries none of the staleness this gate fixes), + // everyone else by the update:Showcase PERM — so deactivating a role revokes curation, + // which the surviving role NAME in the claim would not. + // + // `hasPerm` is the client mirror of the rules' own `hasPerm()` — an exact code test on + // the claim, deliberately NOT `abilityAllows(..., "update", "Showcase")`: CASL's + // `manage:all` wildcard would answer yes to the ability question. That would show the + // Destacar checkbox to a manage:all perm holder whose write firestore.rules then + // rejects — taking the whole save down with it. `probe.ts` does not help here: it + // narrows CONDITIONAL grants, and the divergence is the unconditional wildcard. + canFeatureInitiatives: adminOrPerm(claims, "update:Showcase"), + // Same exact-code discipline as canFeatureInitiatives above, for the same reason: a + // `manage:all` holder must not see an affordance firestore.rules then rejects. + canAssignBoardSeat: adminOrPerm(claims, "update:BoardSeat"), + canEditCargoCatalog: hasAnyRole(claims, ["Admin"]), + canProvisionLogin: adminOrPerm(claims, "create:MemberLogin"), + }; +} diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts index b6505fa0..98f400d8 100644 --- a/apps/backstage/src/lib/authz/use-can.ts +++ b/apps/backstage/src/lib/authz/use-can.ts @@ -1,8 +1,8 @@ import { useMemo } from "react"; -import { hasAnyRole, hasPerm, type AuthClaims, type Role } from "@luminova/auth/roles"; +import { hasAnyRole, type AuthClaims, type Role } from "@luminova/auth/roles"; import type { Action, AppAbility, Subject } from "@luminova/auth/ability"; -import type { PermissionCode } from "@luminova/types"; import type { ParticipationRole } from "@luminova/types/engine"; +import { capabilityFlags, type CapabilityFlags } from "./capability-flags"; import { isNavItemVisible, type NavItem } from "../../components/nav-config"; import { canRemoveEntry } from "../../features/check-in/lib/can-remove-entry"; import { isMemberOnly } from "./is-member-only"; @@ -13,7 +13,7 @@ import { abilityAllows, type SubjectFields } from "./probe"; * Firestore rules use: coarse `action:subject` perms (via the CASL ability) and * the built-in `roles` claim (Admin / ExecutiveCommittee / ProjectManager gates * that no perm expresses). Keeps the UI's affordances in lock-step with the rules. */ -export interface Can { +export interface Can extends CapabilityFlags { /** Perm gate. Without `on` this asks the COLLECTION-level question (unconditional * grants only); pass the document's fields to ask about one document. See * `abilityAllows` — a bare subject type would let a conditional own-doc grant @@ -26,46 +26,14 @@ export interface Can { navItemVisible(item: NavItem): boolean; /** May the caller undo THIS roster row? (features/check-in/lib/can-remove-entry) */ canRemoveCheckIn(entry: { role: ParticipationRole }): boolean; - /** Shorthand for the Admin role (not the `manage:all` perm). */ - readonly isAdmin: boolean; - /** May curate the public /programas page (rules' `canCurateFeatured`). Named here so the - * policy lives in one place, not scattered role-array literals at each call site. */ - readonly canFeatureInitiatives: boolean; - /** May SEAT a member on a cargo the plain non-Admin lane refuses — a power-granting one or - * a CEL one. Mirrors firestore.rules' `boardSeatDelegate()` disjunct for disjunct. - * Governs the member CREATE and UPDATE lanes only. */ - readonly canAssignBoardSeat: boolean; - /** May AUTHOR the positions catalog: create a board-surfacing cargo - * (`boardSurfacingCategory()`), and edit a stored cargo's `grants`, `category` or — on a - * board cargo — `title`/`titleFemale`. - * - * Split from `canAssignBoardSeat` and deliberately NOT widened by the delegation. These - * were one flag while both were `hasAnyRole(['Admin'])`; they are different authorities - * and firestore.rules now keys them on different predicates. Re-unifying them would hand - * a seat delegate the catalog, and the catalog is the door round the back: mint a - * grant-free CEL 'Presidente', then seat yourself on it at public board rank 0. */ - readonly canEditCargoCatalog: boolean; - /** May run `provisionMemberLogin` — create the member's Auth account, link their uid, get - * the password-reset link. Mirrors beacon's - * `requireAdminOrPerm(request, "create:MemberLogin")`. Cargo-agnostic: it applies to every - * new member, board seat or not. - * - * NOT the invite email itself — `requestPasswordReset` is a client-side - * `sendPasswordResetEmail` any signed-in user can already call. */ - readonly canProvisionLogin: boolean; } -/** The shape every delegable capability gate takes: the Admin ROLE, or one exact permission - * code. Extracted at the third occurrence — `hasPerm` is deliberately NOT `abilityAllows`, - * and re-deriving that decision per flag is how one of them ends up looser than its rule. - * See the canFeatureInitiatives comment below for the full reasoning it encodes. */ -function adminOrPerm(claims: AuthClaims, code: PermissionCode): boolean { - return hasAnyRole(claims, ["Admin"]) || hasPerm(claims, code); -} - -/** Pure builder — no React — so the gate logic is unit-testable. */ +/** Pure builder — no React — so the gate logic is unit-testable. The capability FLAGS come + * from `./capability-flags`, which the emulator parity test loads directly (this module pulls + * React and `@luminova/types` for value, so it cannot). */ export function buildCan(ability: AppAbility, claims: AuthClaims): Can { return { + ...capabilityFlags(claims), can: (action, subject, on) => abilityAllows(ability, action, subject, on), hasRole: (roles) => hasAnyRole(claims, roles), // A member-only user is bounced from `/` to `/me` by _app.index, so the Inicio @@ -77,24 +45,6 @@ export function buildCan(ability: AppAbility, claims: AuthClaims): Can { navItemVisible: (item) => isNavItemVisible(item, ability, claims) && !(item.to === "/" && isMemberOnly(claims)), canRemoveCheckIn: (entry) => canRemoveEntry(ability, claims, entry), - isAdmin: hasAnyRole(claims, ["Admin"]), - // Mirrors canCurateFeatured() in firestore.rules disjunct for disjunct: Admin by ROLE - // (locked + undeactivatable, so its name carries none of the staleness this gate fixes), - // everyone else by the update:Showcase PERM — so deactivating a role revokes curation, - // which the surviving role NAME in the claim would not. - // - // `hasPerm` is the client mirror of the rules' own `hasPerm()` — an exact code test on - // the claim, deliberately NOT `abilityAllows(..., "update", "Showcase")`: CASL's - // `manage:all` wildcard would answer yes to the ability question. That would show the - // Destacar checkbox to a manage:all perm holder whose write firestore.rules then - // rejects — taking the whole save down with it. `probe.ts` does not help here: it - // narrows CONDITIONAL grants, and the divergence is the unconditional wildcard. - canFeatureInitiatives: adminOrPerm(claims, "update:Showcase"), - // Same exact-code discipline as canFeatureInitiatives above, for the same reason: a - // `manage:all` holder must not see an affordance firestore.rules then rejects. - canAssignBoardSeat: adminOrPerm(claims, "update:BoardSeat"), - canEditCargoCatalog: hasAnyRole(claims, ["Admin"]), - canProvisionLogin: adminOrPerm(claims, "create:MemberLogin"), }; } diff --git a/tests/firestore-rules/cargo-assignment-parity.test.ts b/tests/firestore-rules/cargo-assignment-parity.test.ts index 65f63527..e0f19723 100644 --- a/tests/firestore-rules/cargo-assignment-parity.test.ts +++ b/tests/firestore-rules/cargo-assignment-parity.test.ts @@ -10,7 +10,7 @@ import { } from "@firebase/rules-unit-testing"; import { doc, setDoc, updateDoc, type Firestore } from "firebase/firestore"; import { buildAbility, type Action, type Subject } from "@luminova/auth/ability"; -import { hasAnyRole, hasPerm, ROLES, type AuthClaims } from "@luminova/auth/roles"; +import { ROLES, type AuthClaims } from "@luminova/auth/roles"; import { permsForRoles } from "../../tools/scripts/lib/role-seed.mjs"; // The SAME modules the two member forms render from. `assignable-cargo-core` is import-free // and `member-edit-gate` / `probe` pull only `@luminova/auth`, so all three load here — @@ -20,11 +20,13 @@ import { permsForRoles } from "../../tools/scripts/lib/role-seed.mjs"; import { cargoSlotsForEditor, cargoTakedownOnly, + heldCargo, positionsLockedForEditor, type CargoLike, } from "../../apps/backstage/src/features/members/lib/assignable-cargo-core"; import { memberEditMode } from "../../apps/backstage/src/features/members/lib/member-edit-gate"; import { abilityAllows } from "../../apps/backstage/src/lib/authz/probe"; +import { capabilityFlags } from "../../apps/backstage/src/lib/authz/capability-flags"; // The contract: for every (principal, member fixture, cargo) triple, IF the backstage cargo // editor would let this editor submit this cargo for this member, THEN the real rules engine @@ -120,20 +122,24 @@ interface Gates { /** The claims → form-props mapping the four call sites make (`member-profile-page.tsx`, * `member-drawer.tsx`, `member-invite-drawer.tsx`): `allowPowerGrants={canAssignBoardSeat}`, - * `allowReplacePowerCargo={isAdmin}`. `adminOrPerm` is re-derived from the same `hasAnyRole` / - * `hasPerm` primitives `buildCan` uses rather than imported, because `use-can.ts` is a React - * module this package cannot load; `member-profile-page.test.tsx` is what pins the props to - * those two flags. `hasPerm`, never the ability, is the point — `manage:all` must not answer a - * gate the rules key on an exact code. */ + * `allowReplacePowerCargo={isAdmin}`. + * + * Both flags come from `capabilityFlags` — the SAME function `buildCan` spreads — not from a + * local `hasAnyRole(...) || hasPerm(...)` re-derivation. That copy was the mirror class this + * whole test exists to delete, applied to the very flag whose widening caused the #224 + * regression: it would have kept agreeing with itself while `use-can.ts` drifted. The flags + * were split out of `use-can.ts` (a React module this package cannot load) for exactly this. + * `member-profile-page.test.tsx` is what pins the two PROPS to those two flags. */ function gatesFor(p: Principal): Gates { const claims = claimsOf(p); const ability = buildAbility(claims, p.uid); const can = (action: Action, subject: Subject) => abilityAllows(ability, action, subject); + const flags = capabilityFlags(claims); return { editMode: memberEditMode({ can }), canCreate: can("create", "Member"), - isAdmin: hasAnyRole(claims, ["Admin"]), - allowPowerGrants: hasAnyRole(claims, ["Admin"]) || hasPerm(claims, "update:BoardSeat"), + isAdmin: flags.isAdmin, + allowPowerGrants: flags.canAssignBoardSeat, }; } @@ -142,12 +148,13 @@ interface Cargo extends CargoLike { description: string; deletedAt: null; } -// `category` is a literal union HERE even though CargoLike widens it to `string`. This is the -// one error the parity test structurally cannot catch: the same fixture object is both fed to -// the client predicate and seeded into the emulator, so a `"cel"` typo would make the rules' -// `category != 'CEL'` and the client's `category !== "CEL"` agree with each other — and agree -// wrongly, on the publication boundary, with the suite green. The compiler is the only guard -// available for it, so give it one. +// `category` is a literal union HERE. `CargoLike.category` is `PositionCategory` — NOT the +// widened `string` this comment used to claim — so the compiler already rejects a `"cel"` typo +// through the interface; the local union is a second, independent statement of the same thing +// and costs nothing. What neither catches is a RENAME of the category itself in +// POSITION_CATEGORIES: the same fixture object feeds the client predicate and the emulator, so +// the rules' `category != 'CEL'` and the client's `category !== "CEL"` would agree with each +// other, and agree wrongly, on the publication boundary, with the suite green. const cargo = ( id: string, category: "CEL" | "JDL" | "Comision", @@ -202,7 +209,7 @@ const MEMBER_FIXTURES: MemberFixture[] = [ * which `cargoTakedownOnly` makes an explicit "Quitar cargo" action. A locked slot submits * nothing at all. */ function offeredCargoIds(g: Gates, assignedCargoId: string | null): (string | null)[] { - const held = cargoById(assignedCargoId); + const held = heldCargo(CATALOG, assignedCargoId); if (positionsLockedForEditor(held, g.isAdmin)) return []; const enabled = cargoSlotsForEditor({ positions: CATALOG, @@ -416,10 +423,36 @@ describe("cargo assignment ⟷ rules: every OFFERED cargo is a write the emulato // into `positionsLockedForEditor` — whose conjunct, `currentCargoGrantsEmpty()`, is Admin-ROLE // only — unlocks the slot for a delegate, and every write it would then submit is denied. This // is what "the two flags are not the same one" costs when it is got wrong. + // THE OTHER DIRECTION. Everything above is `client offers ⟹ rules allow`, which catches a + // rules TIGHTENING and structurally cannot catch a rules LOOSENING: delete + // `&& cargo.category != 'CEL'` from nonAdminAssignable(), or widen boardSeatDelegate() from + // hasPerm('update:BoardSeat') to canDo('update','BoardSeat') so `manage:all` satisfies it, + // and every triple above stays green — the client would merely be stricter than the rules, + // which this file treats as legal curation, and legal curation is most of why the converse + // cannot be asserted wholesale (comisión cargos, retired seats, inactive ones). + // + // It CAN be asserted for the three cargos the delegation is about, and there the gap is not + // curation but the boundary itself. Driven through the emulator, so it is the ruleset that + // answers and not another read of the same client predicate. + const WITHHELD = ["cel_free", "cel_power", "jdl_power"] as const; + for (const label of ["custom(update-Position)", "custom(update-Member)", "custom(manage-all)"]) { + const principal = PRINCIPALS.find((p) => p.label === label); + if (principal === undefined) continue; + const g = gatesFor(principal); + if (g.editMode === "none") continue; + for (const cargoId of WITHHELD) { + it(`BLOCKING: the rules DENY ${label} the ${cargoId} seat the client withholds`, async () => { + expect(offeredCargoIds(g, null)).not.toContain(cargoId); + const id = await seedMember({ key: "unseated", cargoId: null }); + await assertFails(writeUpdate(as(principal), id, cargoId, principal.uid)); + }); + } + } + it("wiring allowPowerGrants into the OLD-side flag would offer writes the rules deny", async () => { const delegate = PRINCIPALS.find((p) => p.label === "custom(position+boardseat)")!; const g = gatesFor(delegate); - const held = cargoById("jdl_power"); + const held = heldCargo(CATALOG, "jdl_power"); expect(positionsLockedForEditor(held, g.isAdmin)).toBe(true); expect(positionsLockedForEditor(held, g.allowPowerGrants)).toBe(false); const id = await seedMember({ key: "seated-jdl-power", cargoId: "jdl_power" }); From fec9b5655f121250af355c71f435221e89124b98 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 16:22:38 -0400 Subject: [PATCH 20/25] fix(beacon): tag the last untagged refusal; make two log guards falsifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "member has no email" was the one refusal thrown with no details.reason — so the UI degraded it to the generic "No se pudo…", verbatim the dead end PROVISION_BLOCK_REASONS was created to remove, and it SHADOWED the tagged member-email-malformed refusal for the empty-string case, which is the likelier of the two (memberDocSchema's email is a bare z.string()). Absent, empty and malformed have the same operator remedy, so they are one tagged check now. ADMIN_SDK_EMAIL_SHAPE was copied verbatim from the Admin SDK on the argument that tightening it would start rejecting addresses Firebase accepts. True in general, but `[^@]` matches \n, \r, \t, spaces and NUL, and Identity Toolkit rejects those server-side anyway — so "pres@jci.bo\n" passed this screen AND the SDK's own check, reached the API, and came back as an opaque `internal` with no reason. That IS the unprovisionable-with-no-hint failure the constant exists to prevent. \s and \p{C} are excluded now; the three SDK-accepted fixtures still pass. Two log guards could not fail: - The BLOCKING "bounds the log" test appended its one oversized id LAST in a 10,001 entry array, outside sampleRejectedIds' .slice(0, 10) window. The per-entry length assertion only ever saw 9-character ids, so deleting .map(truncateForLog) left it green — while a member whose FIRST junk roleId is 1,500 bytes serializes raw, the >256 KB entry-dropped failure the test's own comment names. It goes first now. - "stays serializable when the cut lands mid-surrogate-pair" could not produce a lone surrogate: an all-astral fixture is pairs on even indices and the cap is 64, so the cut always landed ON a boundary. Both its assertions were unconditionally true besides (JSON.stringify has not thrown on a lone surrogate since ES2019, and toBeTruthy() holds for every object). A leading BMP char shifts the pairs, and truncateForLog now drops the orphan rather than shipping an ill-formed log field. And provision-deps.ts held a byte-identical second copy of the logError sink whose sibling's doc comment reads "defining a second console.error wrapper at each of those call sites would be the copy guardrail #1 forbids". Both now come from firestore-util, so routing beacon's logs elsewhere is one edit that reaches every port. Mutation-tested: neutralizing the surrogate trim or the sample truncation turns exactly its own test red. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/claims-sync/firestore-deps.test.ts | 14 ++++++-- apps/beacon/src/claims-sync/firestore-deps.ts | 13 ++++---- apps/beacon/src/firestore-util.test.ts | 28 ++++++++++++---- apps/beacon/src/firestore-util.ts | 18 +++++++++- apps/beacon/src/provision-deps.ts | 4 +-- .../beacon/src/provision-member-login.test.ts | 30 ++++++++++++++--- apps/beacon/src/provision-member-login.ts | 33 ++++++++++++------- 7 files changed, 105 insertions(+), 35 deletions(-) diff --git a/apps/beacon/src/claims-sync/firestore-deps.test.ts b/apps/beacon/src/claims-sync/firestore-deps.test.ts index 28fc56ee..fcde16ec 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.test.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Auth } from "firebase-admin/auth"; import type { Firestore } from "firebase-admin/firestore"; import { firestoreClaimsDeps } from "./firestore-deps.js"; +import { truncateForLog } from "../firestore-util.js"; type RoleFixture = { id: string; data: Record }; @@ -272,15 +273,22 @@ describe("getRolesByIds id screening", () => { const { db } = fakeDb([]); const junk = Array.from({ length: 10_000 }, (_, i) => `bad/${i}`); const longId = `x/${"y".repeat(5_000)}`; - expect(await firestoreClaimsDeps(db, auth).getRolesByIds([...junk, longId])).toEqual([]); + // FIRST, not last. `sampleRejectedIds` takes `.slice(0, 10)`, so appending the one + // oversized id after 10,000 short ones put it outside the sampled window entirely: the + // per-entry length assertion below then only ever saw 9-character ids and could not fail. + // Deleting `.map(truncateForLog)` left this test green, which is the failure it exists to + // catch — a member doc whose FIRST junk roleId is 1,500 bytes serializes raw. + expect(await firestoreClaimsDeps(db, auth).getRolesByIds([longId, ...junk])).toEqual([]); const meta = errors[0]?.[1] as { rejectedCount: number; rejectedSample: string[]; }; expect(meta.rejectedCount).toBe(10_001); expect(meta.rejectedSample).toHaveLength(10); - // Every sampled entry is length-capped too, so one enormous id cannot blow the budget - // through the sample either. + // The oversized id is in the window, and truncated — so one enormous id cannot blow the + // budget through the sample either. + expect(meta.rejectedSample[0]).toBe(truncateForLog(longId)); + expect(meta.rejectedSample[0]).toHaveLength(65); for (const entry of meta.rejectedSample) expect(entry.length).toBeLessThanOrEqual(65); expect(JSON.stringify(meta).length).toBeLessThan(2_000); }); diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts index d3547995..840d20fd 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.ts @@ -3,7 +3,7 @@ import type { Firestore } from "firebase-admin/firestore"; import { isValidRole, type Role } from "@luminova/auth/roles"; import { isValidPermissionCode, type PermissionCode } from "@luminova/types/permission"; import { chunk } from "../chunk.js"; -import { isSafeDocId, truncateForLog, type LogSink } from "../firestore-util.js"; +import { isSafeDocId, logError, logWarn, truncateForLog } from "../firestore-util.js"; import { readPositionGrants } from "../read-position-grants.js"; import { isActiveRoleDoc, permsFromRoleDoc } from "./role-doc.js"; import type { LiveBuiltInRoleDoc } from "./resolve-member-perms.js"; @@ -186,10 +186,11 @@ export interface FirestoreClaimsDeps extends ClaimsSyncDeps { staleBuiltInRoleKeys(): Promise; } -/** Exported so the trigger/callable call sites can hand the SAME sink to `parseMember`, which - * runs before any deps instance exists. Defining a second `console.error` wrapper at each of - * those three call sites would be the copy this repo's guardrail #1 forbids. */ -export const logError: LogSink = (message, meta) => console.error(message, meta); +/** Re-exported so the trigger/callable call sites can hand the SAME sink to `parseMember`, + * which runs before any deps instance exists. The sink itself lives in `firestore-util.ts` + * alongside `LogSink` — a second `console.error` wrapper per adapter is the copy guardrail #1 + * forbids, and there WAS one in `provision-deps.ts` while this comment claimed otherwise. */ +export { logError }; export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsDeps { const userCache = new Map>(); @@ -354,6 +355,6 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD logError, // Cloud Logging maps console.warn to WARNING, which is the point: the designed refusals // must not share a severity with the malformed-doc screens an operator has to act on. - logWarn: (message, meta) => console.warn(message, meta), + logWarn, }; } diff --git a/apps/beacon/src/firestore-util.test.ts b/apps/beacon/src/firestore-util.test.ts index cb47a14b..474e3a06 100644 --- a/apps/beacon/src/firestore-util.test.ts +++ b/apps/beacon/src/firestore-util.test.ts @@ -26,12 +26,26 @@ describe("truncateForLog", () => { expect(truncateForLog("y".repeat(65))).toBe(`${"y".repeat(64)}…`); }); - it("stays serializable when the cut lands mid-surrogate-pair", () => { - // `.slice` can split an astral pair into a lone surrogate. JSON.stringify has been - // well-formed since ES2019 and escapes it, so the log entry survives — this pins that the - // helper never produces something the structured sink would reject. - const astral = "𝒳".repeat(40); // 2 UTF-16 units each, so the 64-char cut lands mid-pair - expect(() => JSON.stringify({ id: truncateForLog(astral) })).not.toThrow(); - expect(JSON.parse(JSON.stringify({ id: truncateForLog(astral) }))).toBeTruthy(); + // This case used to be asserted with an ALL-astral fixture, which cannot produce it: pairs + // are 2 units each and the cap is 64, an even index, so the cut always landed ON a boundary. + // Both of its assertions were unconditionally true besides — JSON.stringify has not thrown on + // a lone surrogate since ES2019, and `expect(JSON.parse(…)).toBeTruthy()` holds for every + // object. A leading BMP character is what shifts the pairs onto odd indices. + it("BLOCKING: never emits a lone surrogate, even when the cut lands mid-pair", () => { + const mixed = `a${"𝒳".repeat(40)}`; + // The premise, asserted rather than claimed: this fixture really does split a pair. + expect(mixed.slice(0, 64).isWellFormed()).toBe(false); + + const out = truncateForLog(mixed); + expect(out.isWellFormed()).toBe(true); + expect(out.endsWith("…")).toBe(true); + // Round-trips as the SAME string — an escaped orphan would come back as \uD835. + expect(JSON.parse(JSON.stringify({ id: out })).id).toBe(out); + }); + + it("keeps the full cap when the boundary is clean", () => { + // The orphan trim must cost one char only when there IS an orphan. + const astral = "𝒳".repeat(40); // pairs on even indices: the 64-char cut is a boundary + expect(truncateForLog(astral)).toBe(`${astral.slice(0, 64)}…`); }); }); diff --git a/apps/beacon/src/firestore-util.ts b/apps/beacon/src/firestore-util.ts index 32a34755..37deda88 100644 --- a/apps/beacon/src/firestore-util.ts +++ b/apps/beacon/src/firestore-util.ts @@ -28,6 +28,14 @@ export function isSafeDocId(id: unknown): id is string { /** A structured log sink, injected so a shared fail-closed read is not welded to `console`. */ export type LogSink = (message: string, meta: Record) => void; +/** The default sinks, HERE rather than one per adapter file. Routing beacon's structured logs + * anywhere else — a Cloud Logging client, a redaction wrapper, another severity split — is + * then one edit that reaches every port, which is the property `claims-sync/firestore-deps.ts` + * claimed while a byte-identical second copy lived in `provision-deps.ts` and silently kept + * `readPositionGrants`'s anomaly lines on the old path. */ +export const logError: LogSink = (message, meta) => console.error(message, meta); +export const logWarn: LogSink = (message, meta) => console.warn(message, meta); + const LOG_ID_MAX_CHARS = 64; /** An id bounded for a structured-log field. The values screened by `isSafeDocId` run to @@ -35,5 +43,13 @@ const LOG_ID_MAX_CHARS = 64; * loses the anomaly precisely when it is biggest. Shared so every screen's log line is * bounded the same way. */ export function truncateForLog(value: string): string { - return value.length > LOG_ID_MAX_CHARS ? `${value.slice(0, LOG_ID_MAX_CHARS)}…` : value; + if (value.length <= LOG_ID_MAX_CHARS) return value; + const cut = value.slice(0, LOG_ID_MAX_CHARS); + // A cut at a fixed UTF-16 index can land BETWEEN the halves of a surrogate pair, leaving a + // lone high surrogate — an ill-formed string. JSON.stringify escapes it rather than throwing + // (well-formed stringify, ES2019), so nothing here fails; the damage is downstream, in + // whatever reads the log field. Drop the orphan instead of shipping it. + const last = cut.charCodeAt(cut.length - 1); + const orphaned = last >= 0xd800 && last <= 0xdbff; + return `${orphaned ? cut.slice(0, -1) : cut}…`; } diff --git a/apps/beacon/src/provision-deps.ts b/apps/beacon/src/provision-deps.ts index 4d6ff575..b219ddff 100644 --- a/apps/beacon/src/provision-deps.ts +++ b/apps/beacon/src/provision-deps.ts @@ -1,11 +1,9 @@ import type { Auth } from "firebase-admin/auth"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; -import type { LogSink } from "./firestore-util.js"; +import { logError } from "./firestore-util.js"; import type { ProvisionDeps } from "./provision-member-login.js"; -const logError: LogSink = (message, meta) => console.error(message, meta); - // Null only for the "account does not exist" outcome — a transient Auth error // must propagate, not read as deleted (the relink guard trusts that contract). function nullIfUserNotFound(err: unknown): null { diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts index d72c87d6..3cd2a456 100644 --- a/apps/beacon/src/provision-member-login.test.ts +++ b/apps/beacon/src/provision-member-login.test.ts @@ -497,9 +497,17 @@ describe("provisionMember", () => { await expect( provisionMember(fakeDeps({ member: { email: "a@b.co", active: false } }).deps, "m1"), ).rejects.toMatchObject({ code: "failed-precondition" }); - await expect( - provisionMember(fakeDeps({ member: { active: true } }).deps, "m1"), - ).rejects.toMatchObject({ code: "failed-precondition" }); + // BLOCKING: an absent or empty email is TAGGED, like every other refusal. It used to throw + // bare ("member has no email"), so `provisionRefusalMessage` returned null and the operator + // got the generic "No se pudo…" — the dead end PROVISION_BLOCK_REASONS exists to remove — + // and it shadowed the tagged malformed-email refusal for the "" case, which is the likelier + // one (memberDocSchema's `email` is a bare z.string()). + for (const member of [{ active: true }, { active: true, email: "" }]) { + await expect(provisionMember(fakeDeps({ member }).deps, "m1")).rejects.toMatchObject({ + code: "failed-precondition", + details: { reason: "member-email-malformed" }, + }); + } }); it("BLOCKING: screens a malformed stored email instead of surfacing an opaque `internal`", async () => { @@ -509,7 +517,21 @@ describe("provisionMember", () => { // the fix is editing the member's stored email. firestore.rules does not shape-validate // email on the admin write lane, so this shape is reachable. const reached: string[] = []; - for (const email of ["not-an-email", "@b.co", "a@", "a@b@c.co", " "]) { + // The last four are the tightening over the SDK's own `/^[^@]+@[^@]+$/`: `[^@]` matches + // whitespace and control characters, so each of these passes that pattern AND the SDK's + // client-side check, reaches Identity Toolkit, and returns INVALID_EMAIL as an opaque + // `internal` — the exact failure this screen exists to prevent, one layer further out. + for (const email of [ + "not-an-email", + "@b.co", + "a@", + "a@b@c.co", + " ", + "pres@jci.bo\n", + "a b@jci.bo", + "a@b\t.bo", + "a@b.bo", + ]) { const { deps, calls } = fakeDeps({ member: { email, active: true } }); const spied: ProvisionDeps = { ...deps, diff --git a/apps/beacon/src/provision-member-login.ts b/apps/beacon/src/provision-member-login.ts index c33883b5..0f343c59 100644 --- a/apps/beacon/src/provision-member-login.ts +++ b/apps/beacon/src/provision-member-login.ts @@ -48,11 +48,19 @@ function provisionBlocked( return new HttpsError(code, message, { reason }); } -/** The Admin SDK's OWN email predicate (`validator.isEmail`: `/^[^@]+@[^@]+$/`), copied - * verbatim rather than tightened. The point is to refuse exactly what `getUserByEmail` / - * `createUser` would refuse — a stricter RFC-ish pattern would start rejecting addresses - * Firebase happily accepts, which is a worse failure than the one being fixed. */ -const ADMIN_SDK_EMAIL_SHAPE = /^[^@]+@[^@]+$/; +/** The Admin SDK's OWN email predicate (`validator.isEmail`: `/^[^@]+@[^@]+$/`), plus the one + * tightening that is strictly safe: no whitespace, no control characters. + * + * Deliberately not an RFC-ish pattern — a stricter one would start rejecting addresses + * Firebase happily accepts, which is a worse failure than the one being fixed. But `[^@]` + * matches `\n`, `\r`, `\t`, spaces and NUL, so `"pres@jci.bo\n"` and `"a b@jci.bo"` pass BOTH + * this screen and the SDK's client-side check, reach Identity Toolkit, and come back + * INVALID_EMAIL → `auth/invalid-email` → rethrown by `nullIfUserNotFound` as an opaque + * `internal` with no `details.reason`. That is exactly the unprovisionable-with-no-hint + * failure this constant exists to prevent, and `firestore.rules` never constrains + * `members.email`, so a CSV paste or any `update:Member` holder can store one. Rejecting them + * here costs nothing: the server rejects them anyway, and now with a reason the UI can name. */ +const ADMIN_SDK_EMAIL_SHAPE = /^[^@\s\p{C}]+@[^@\s\p{C}]+$/u; export interface ProvisionUser { uid: string; @@ -177,23 +185,26 @@ export async function provisionMember( const member = await deps.getMember(memberId); if (member === null) throw new HttpsError("not-found", "member not found"); if (member.active !== true) throw new HttpsError("failed-precondition", "member is not active"); - if (typeof member.email !== "string" || member.email.length === 0) { - throw new HttpsError("failed-precondition", "member has no email"); - } - const email = member.email; // Shape-screened BEFORE it reaches the Auth SDK, for the same reason cargoId and assignedBy // are screened in claims-sync: a stored value the SDK rejects throws a PERMANENT // auth/invalid-email, which nullIfUserNotFound rethrows and the caller receives as an opaque // `internal`. That member is then unprovisionable through this callable — with no hint why — // until someone edits the doc in the console. firestore.rules deliberately does not // shape-validate `email` on the admin write lane, so the shape reaches here unchecked. - if (!ADMIN_SDK_EMAIL_SHAPE.test(email)) { + // + // ONE check, not a separate untagged "member has no email" above it. That one threw with no + // `details.reason`, so the UI degraded it to the generic "no se pudo" — verbatim the dead end + // PROVISION_BLOCK_REASONS exists to remove — and it SHADOWED this tagged one for the + // empty-string case, which is the likelier of the two (memberDocSchema's `email` is a bare + // z.string()). Absent, empty and malformed all have the same operator remedy: fix the ficha. + if (typeof member.email !== "string" || !ADMIN_SDK_EMAIL_SHAPE.test(member.email)) { throw provisionBlocked( "failed-precondition", - "member's stored email is not a valid address; correct it before provisioning", + "member's stored email is missing or not a valid address; correct it before provisioning", "member-email-malformed", ); } + const email = member.email; const linkedUid = typeof member.uid === "string" && member.uid.length > 0 ? member.uid : null; let user = await deps.getUserByEmail(email); From 0ca459a071aa5269b93a9bbc0bad814ddce8e815 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 16:22:58 -0400 Subject: [PATCH 21/25] docs(plans): cite the shipped guards by symbol, not by line number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block added under "Confirmed against the shipped code, not restated from memory" had every one of its line citations invalidated by the same PR that wrote them — stale by 25-45 lines. The worst was `sync.ts:126-134`, cited as "the real trust computation, unchanged since", which now points at a logging block inserted afterwards: an auditor following the doc to verify the power-seat guard lands on unrelated code and can reasonably conclude the guard was removed. Symbols instead. Guardrail #6 is about claims that stay true, and a line number in a file under active change is not one. The pre-implementation refs elsewhere in the doc are left alone — they are labelled as such and are historically accurate. Co-Authored-By: Claude Opus 5 (1M context) --- docs/plans/board-seat-delegation.md | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/plans/board-seat-delegation.md b/docs/plans/board-seat-delegation.md index 64fcd694..d705609d 100644 --- a/docs/plans/board-seat-delegation.md +++ b/docs/plans/board-seat-delegation.md @@ -62,7 +62,8 @@ proposal was itself found to be a hole and closed before merge: URL **to the caller**, categorically unlike `sendPasswordResetEmail`, which delivers the secret to the mailbox owner. A delegate permitted to "re-provision an already-linked" member could name any member's id — including an Admin's — and receive a live reset link for that address. The -shipped guard in `provisionMember` (`apps/beacon/src/provision-member-login.ts:206`) is therefore: +shipped guard in `provisionMember` (the ADOPTION GUARD in +`apps/beacon/src/provision-member-login.ts`) is therefore: ```ts if (!callerHoldsAdminRole && (user !== null || linkedUid !== null)) { @@ -85,7 +86,7 @@ account creation/adoption + uid linking + claim writing. ### G2 — the trust gate must be non-reflexive on self-assignment — proposed fix REJECTED, do not implement as written `sync.ts:69` + `compute-roles.ts:8-12` (pre-implementation line refs; current shipped location is -`resolveTrustedGrants` in `apps/beacon/src/claims-sync/sync.ts:97-135`). A delegate self-seats +`resolveTrustedGrants` in `apps/beacon/src/claims-sync/sync.ts`). A delegate self-seats `Presidente` in one write on the positions-only lane; beacon mints `roles: ["Admin","Member"]`. Revoking `update:BoardSeat` then re-fires `onMemberWritten`, the gate reads their **live** claims, finds the `Admin` role the cargo just minted, and re-honors the grants. The claim satisfies the @@ -153,8 +154,11 @@ anti-lockout guard — not this one. ### Guards added after this section was written Later commits on this branch (`5f9408e`, `e02dbf1`, `d7cd564`, `2c328f5`, `58266d3`) found and -closed three more gaps this section does not mention. Confirmed against the shipped code, not -restated from memory: +closed three more gaps this section does not mention. Cited by SYMBOL, never by line number: +every numeric citation this block originally carried was stale by 25-45 lines within the same +PR that wrote them — one of them, "the real trust computation, unchanged since", ended up +pointing at a logging block added afterwards, which is exactly how an auditor concludes a guard +was removed. Guardrail #6 is about claims that stay true, and a line number does not. - **The power-seat guard in `provisionMember`.** G1 above stops a delegate from re-provisioning or adopting an ALREADY-linked account, but says nothing about an unlinked member who is already @@ -163,17 +167,16 @@ restated from memory: `onMemberWritten`, and `resolveTrustedGrants` would read the *stored* `assignedBy` (a genuine Admin) and mint the grants onto the account the delegate's call just created — a clean escalation the delegate never had to forge. The shipped guard - (`apps/beacon/src/provision-member-login.ts:213-251`) checks both claims-mint sources - `syncMemberClaims` reads: `hasDirectGrants()` (`:83-99`, `roleIds`/`permissionOverrides`) and a - per-term cargo read via `readCargoIds()` (`:101-139`, every term in `positions`, not just the - current one — a future-term slate is invisible to claims-sync today but not to this guard). -- **Fail-closed handling for a malformed `grants` or `positions` shape.** `readCargoIds()` - (`:119-139`) yields `""` — not skip — for a non-object term, a non-string `cargoId`, or one + (the `if (!callerHoldsAdminRole)` block in `apps/beacon/src/provision-member-login.ts`) checks + both claims-mint sources + `syncMemberClaims` reads: `hasDirectGrants()` (`roleIds`/`permissionOverrides`) and a per-term + cargo read via `readCargoIds()` (every term in `positions`, not just the current one — a future-term slate is invisible to claims-sync today but not to this guard). +- **Fail-closed handling for a malformed `grants` or `positions` shape.** `readCargoIds()` yields `""` — not skip — for a non-object term, a non-string `cargoId`, or one `isSafeDocId` rejects; the caller then refuses on `grants === null`, so a shape it cannot parse - is treated as power-seated, never as "no cargo". `hasDirectGrants()` (`:83-99`) is symmetric for + is treated as power-seated, never as "no cargo". `hasDirectGrants()` is symmetric for `roleIds` / `permissionOverrides`: a present-but-unparseable value reads as granted, and only a genuinely absent/null value reads as ungranted. -- **The narrowed `createUser` catch.** `apps/beacon/src/provision-deps.ts:25-29` swallows exactly +- **The narrowed `createUser` catch.** `createUser` in `apps/beacon/src/provision-deps.ts` swallows exactly `auth/email-already-exists` (a benign race with a concurrent create) and rethrows everything else — it does not swallow arbitrary Auth errors into a silent fallback. @@ -218,7 +221,7 @@ delegate whose write `firestore.rules` then rejects: the render-then-403 shape ` `checkIn:MemberPoints` / `checkIn:Notification` (20 chars). `checkIn:MemberLogin` is 19, `checkIn:BoardSeat` is 17. Worst case stays ~855 B. `PERMISSION_CAP` does not move. - Product consequence worth one spec line: two more codes compete for the same 30-slot effective-perm - budget, and a member breaching the cap gets `perms: []` fail-closed (`sync.ts:106-118`) — silently + budget, and a member breaching the cap gets `perms: []` fail-closed (the `perms.length > PERMISSION_CAP` block in `sync.ts`) — silently removing their `update:BoardSeat`. - `sync.test.ts:580` `distinctCodes(n)` walks `ACTIONS x SUBJECTS` in order; adding subjects changes which codes it picks, not their validity. No change. @@ -420,7 +423,8 @@ Rejected alternatives: **This snippet is superseded — it is missing the self-assignment / Admin-granting-cargo branch that G2 (above) required and that shipped.** The real trust computation, unchanged since, - reads (`apps/beacon/src/claims-sync/sync.ts:126-134`): + reads (the trust computation at the end of `resolveTrustedGrants`, + `apps/beacon/src/claims-sync/sync.ts`): ```ts const assignerIsAdmin = assigner.roles.includes("Admin"); From 3d933fe971cad976ff9bcae256b876b8467697ad Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 17:22:26 -0400 Subject: [PATCH 22/25] fix(beacon): tag Identity Toolkit's own invalid-email, not just the shape screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firebase-functions-reviewer, Medium. Tightening ADMIN_SDK_EMAIL_SHAPE narrowed the reason-less surface without closing it, because the screen is a SHAPE test and Identity Toolkit's is a SEMANTIC one. "a@.", ".a@b.co", "a..b@c.co" each carry one @, no whitespace and no control characters — so they pass this screen AND the Admin SDK's own isEmail, reach the API, and come back auth/invalid-email, which nullIfUserNotFound rethrew as an opaque `internal` with no details.reason. The operator got the generic "No se pudo…" and the member stayed unprovisionable with no hint: the same dead end the previous commit removed, reached by a different road. Tagged at the PORT rather than by chasing regex precision, so the class is closed however the pattern evolves — the regex is a cheap pre-filter now, not the sole guarantee. Both Auth entry points route through it (getUserByEmail and createUser), and one exported factory raises the refusal so the two layers cannot word it differently. Also retargets two comments that this branch made false (guardrail #6): - the "do NOT tighten this regex" test comment, written against the old pattern, now distinguishes the tightening that is safe (whitespace + \p{C}, which the server rejects anyway) from the one that is not (RFC structure). - docs/engineering-guardrails.md cited firestore-deps.ts:169 for getRolesByIds, which now lives at :310. Cited by symbol. NOT fixed, recorded instead: the power-seat loop reads one cargo per distinct term serially with no ceiling (needs a console-written positions map with hundreds of terms; rules are term-pinned, and it fails closed), no logWarn names the orphaned uid when linkUid fails after createUser, and no beacon callable sets enforceAppCheck. Co-Authored-By: Claude Opus 5 (1M context) --- apps/beacon/src/provision-deps.test.ts | 34 +++++++++++++++---- apps/beacon/src/provision-deps.ts | 23 ++++++++++--- .../beacon/src/provision-member-login.test.ts | 12 ++++--- apps/beacon/src/provision-member-login.ts | 23 ++++++++++--- docs/engineering-guardrails.md | 2 +- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/apps/beacon/src/provision-deps.test.ts b/apps/beacon/src/provision-deps.test.ts index 6212d2ee..5252e9ff 100644 --- a/apps/beacon/src/provision-deps.test.ts +++ b/apps/beacon/src/provision-deps.test.ts @@ -10,7 +10,11 @@ const db = {} as Firestore; * and justified: `UserRecord` carries a dozen fields (metadata, providerData, toJSON) that * nothing here reads, and fabricating them would assert nothing. A missing email throws the * real `auth/user-not-found`, which is what the live SDK does. */ -function fakeAuth(opts: { createError?: unknown; byEmail?: Record }) { +function fakeAuth(opts: { + createError?: unknown; + byEmailError?: unknown; + byEmail?: Record; +}) { const calls = { createUser: [] as string[], getUserByEmail: [] as string[] }; const auth = { createUser: async ({ email }: { email: string }) => { @@ -20,6 +24,7 @@ function fakeAuth(opts: { createError?: unknown; byEmail?: Record { calls.getUserByEmail.push(email); + if (opts.byEmailError !== undefined) throw opts.byEmailError; const user = opts.byEmail?.[email]; if (!user) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); return user; @@ -46,11 +51,7 @@ describe("firestoreProvisionDeps.createUser", () => { // account was ever created for, and nullIfUserNotFound turns THAT into a null the relink // guard reads as "the account was safely deleted". Wrong outcome, and the real cause was // gone from the log. The fallback must fire for exactly one code. - for (const code of [ - "auth/quota-exceeded", - "auth/operation-not-allowed", - "auth/invalid-email", - ]) { + for (const code of ["auth/quota-exceeded", "auth/operation-not-allowed"]) { const { auth, calls } = fakeAuth({ createError: authError(code) }); await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).rejects.toMatchObject({ code, @@ -59,6 +60,27 @@ describe("firestoreProvisionDeps.createUser", () => { } }); + // auth/invalid-email is the one non-collision code that does NOT stay raw. The shape screen + // in provisionMember is a pre-filter, not a guarantee: "a@.", ".a@b.co" and "a..b@c.co" each + // carry one @, no whitespace and no control characters, so they pass it AND the Admin SDK's + // own isEmail, and only Identity Toolkit rejects them. Rethrown raw that reaches the operator + // as an opaque `internal` with no details.reason — the generic "No se pudo…" dead end — on a + // member who is then unprovisionable with no hint. Tagged at the port closes the class + // however the regex evolves. + it("BLOCKING: tags an Identity-Toolkit invalid-email as the reason the UI can name", async () => { + for (const method of ["createUser", "getUserByEmail"] as const) { + const { auth } = fakeAuth( + method === "createUser" + ? { createError: authError("auth/invalid-email") } + : { byEmailError: authError("auth/invalid-email") }, + ); + await expect(firestoreProvisionDeps(db, auth)[method]("a@.")).rejects.toMatchObject({ + code: "failed-precondition", + details: { reason: "member-email-malformed" }, + }); + } + }); + it("rethrows a codeless throw too — an unrecognized shape is not a collision", async () => { const { auth, calls } = fakeAuth({ createError: new Error("socket hang up") }); await expect(firestoreProvisionDeps(db, auth).createUser("a@b.co")).rejects.toThrow( diff --git a/apps/beacon/src/provision-deps.ts b/apps/beacon/src/provision-deps.ts index b219ddff..6db6d88c 100644 --- a/apps/beacon/src/provision-deps.ts +++ b/apps/beacon/src/provision-deps.ts @@ -2,13 +2,28 @@ import type { Auth } from "firebase-admin/auth"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; import { logError } from "./firestore-util.js"; -import type { ProvisionDeps } from "./provision-member-login.js"; +import { memberEmailMalformed, type ProvisionDeps } from "./provision-member-login.js"; + +function authCode(err: unknown): unknown { + return (err as { code?: unknown } | null)?.code; +} + +/** Identity Toolkit rejects addresses the SHAPE screen cannot: `a@.`, `.a@b.co`, `a..b@c.co` + * each carry one `@`, no whitespace and no control characters, so they pass + * `ADMIN_SDK_EMAIL_SHAPE` and the Admin SDK's own isEmail alike and only fail server-side. + * Rethrown raw, that is an opaque `internal` with no `details.reason` and a member nobody can + * provision without knowing why. Tagged HERE rather than by chasing regex precision: this + * closes the class whatever the pattern does next. */ +function tagInvalidEmail(err: unknown): never { + if (authCode(err) === "auth/invalid-email") throw memberEmailMalformed(); + throw err; +} // Null only for the "account does not exist" outcome — a transient Auth error // must propagate, not read as deleted (the relink guard trusts that contract). function nullIfUserNotFound(err: unknown): null { - if ((err as { code?: unknown } | null)?.code === "auth/user-not-found") return null; - throw err; + if (authCode(err) === "auth/user-not-found") return null; + return tagInvalidEmail(err); } export function firestoreProvisionDeps(db: Firestore, auth: Auth): ProvisionDeps { @@ -25,7 +40,7 @@ export function firestoreProvisionDeps(db: Firestore, auth: Auth): ProvisionDeps // auth/user-not-found, destroying the diagnostic. createUser: (email) => auth.createUser({ email }).catch((err: unknown) => { - if ((err as { code?: unknown } | null)?.code !== "auth/email-already-exists") throw err; + if (authCode(err) !== "auth/email-already-exists") return tagInvalidEmail(err); return auth.getUserByEmail(email); }), setClaims: (uid, claims) => auth.setCustomUserClaims(uid, claims), diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts index 3cd2a456..c2b13844 100644 --- a/apps/beacon/src/provision-member-login.test.ts +++ b/apps/beacon/src/provision-member-login.test.ts @@ -552,10 +552,14 @@ describe("provisionMember", () => { }); it("does NOT refuse the unusual addresses the Admin SDK accepts", async () => { - // The screen is the SDK's own predicate (`/^[^@]+@[^@]+$/`), not an RFC validator: a - // plus-tag, a bare hostname and a non-ASCII local part all provision as before. Tightening - // this regex would make members with legitimate addresses unprovisionable — the exact - // failure the screen exists to prevent, pointed the other way. + // The screen is the SDK's own predicate plus a whitespace/control-character exclusion — + // NOT an RFC validator, and the distinction is the whole point of this row. Excluding + // characters Identity Toolkit rejects anyway costs nothing. Adding RFC STRUCTURE (dot + // placement, label rules, a TLD requirement) would start refusing addresses Firebase + // happily creates accounts for, which is the failure the screen exists to prevent pointed + // the other way — and that failure is now silent-proof from the other side too, since the + // port tags Identity Toolkit's own rejection rather than letting it surface as `internal`. + // A plus-tag, a bare hostname and a non-ASCII local part must all keep provisioning. for (const email of ["ana+jci@sub.example.co", "root@localhost", "añez@ejemplo.bo"]) { const { deps, calls } = fakeDeps({ member: { email, active: true } }); await expect(provisionMember(deps, "m1", true)).resolves.toEqual({ diff --git a/apps/beacon/src/provision-member-login.ts b/apps/beacon/src/provision-member-login.ts index 0f343c59..b4459154 100644 --- a/apps/beacon/src/provision-member-login.ts +++ b/apps/beacon/src/provision-member-login.ts @@ -48,6 +48,23 @@ function provisionBlocked( return new HttpsError(code, message, { reason }); } +/** The stored email is unusable — absent, empty, wrong shape, or rejected by Identity Toolkit + * itself. One factory because the refusal is raised from TWO layers and must read identically + * from both: this module screens the SHAPE up front, and the port (provision-deps) tags the + * SEMANTIC rejection the shape screen cannot anticipate. `ADMIN_SDK_EMAIL_SHAPE` is a cheap + * pre-filter, never the sole guarantee — "a@.", ".a@b.co" and "a..b@c.co" each carry one `@`, + * no whitespace and no control characters, so they pass it AND the Admin SDK's own isEmail, + * reach the API, and come back auth/invalid-email. Without the port tagging that, it surfaced + * as an opaque `internal` and the operator got the generic "No se pudo…" — the dead end + * PROVISION_BLOCK_REASONS exists to remove, reached by a different road. */ +export function memberEmailMalformed(): HttpsError { + return provisionBlocked( + "failed-precondition", + "member's stored email is missing or not a valid address; correct it before provisioning", + "member-email-malformed", + ); +} + /** The Admin SDK's OWN email predicate (`validator.isEmail`: `/^[^@]+@[^@]+$/`), plus the one * tightening that is strictly safe: no whitespace, no control characters. * @@ -198,11 +215,7 @@ export async function provisionMember( // empty-string case, which is the likelier of the two (memberDocSchema's `email` is a bare // z.string()). Absent, empty and malformed all have the same operator remedy: fix the ficha. if (typeof member.email !== "string" || !ADMIN_SDK_EMAIL_SHAPE.test(member.email)) { - throw provisionBlocked( - "failed-precondition", - "member's stored email is missing or not a valid address; correct it before provisioning", - "member-email-malformed", - ); + throw memberEmailMalformed(); } const email = member.email; const linkedUid = typeof member.uid === "string" && member.uid.length > 0 ? member.uid : null; diff --git a/docs/engineering-guardrails.md b/docs/engineering-guardrails.md index 13acd3ae..118b6376 100644 --- a/docs/engineering-guardrails.md +++ b/docs/engineering-guardrails.md @@ -146,7 +146,7 @@ splatting an unbounded ref list — fine at 40 docs, a cost/timeout bomb at 4,00 | Site | State | |---|---| | Spotlight `fetchFeatured` pulled the whole `showcase` collection, filtered client-side | Fixed (item 12): server-side `where("featured", "==", true)` — `apps/spotlight/src/showcase/showcase-firestore.ts:23` | -| `getRolesByIds` did `db.getAll(...refs)` on an uncapped `roleIds` list | Fixed (#145): chunk-at-300 via `chunk()` — `apps/beacon/src/claims-sync/firestore-deps.ts:169` batches over `apps/beacon/src/chunk.ts` | +| `getRolesByIds` did `db.getAll(...refs)` on an uncapped `roleIds` list | Fixed (#145): chunk-at-300 via `chunk()` — `getRolesByIds` in `apps/beacon/src/claims-sync/firestore-deps.ts` batches over `apps/beacon/src/chunk.ts` | | Beacon `onRoleWritten` built-in-role branch scans **all** members — `apps/beacon/src/index.ts:281` | Bounded (#145) by `roleClaimsChanged` early-return: a metadata-only edit skips the scan entirely; a real permission change still scans every holder **by design** — do NOT cap with `.limit()`, which would strand members beyond the cap with stale claims | | Backstage `RoleRepository.getAll()` reads the whole `roles` collection — no `where`, no `.limit` — `apps/backstage/src/features/permissions/repositories/role-repository.ts:35` | **Recorded exception**, not drift (see below) | | Beacon `getRoleDocsByBuiltInKeys` reads `roles` with `where("builtInKey","in",keys)` and no `.limit` — `apps/beacon/src/claims-sync/firestore-deps.ts` | **Recorded exception**, not drift (see below) — the mirror of the `RoleRepository.getAll()` one, on the trigger side | From 8c4cf1c268a9640bedcbba2dd2f72d0b1ad78089 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 17:22:43 -0400 Subject: [PATCH 23/25] test(rules): make each converse row prove its own lane reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firestore-security-reviewer, Low. `assertFails` is REASON-BLIND: the new converse rows asserted only "denied", so a later change denying those writes for an unrelated reason — a tightened softDeleteSafe(), a lost perm, a renamed fixture — would keep every row green while the CEL/power boundary silently stopped being what denies them. That is the green-for-the-wrong-reason failure this whole file was written to end, reintroduced by the fix for it. Each row now writes a grant-free JDL seat FIRST and asserts it succeeds: same principal, same lane, same fixture shape, one cargo apart, so the conjunct under test is the only difference between the ALLOW and the DENY. Extended to the create lane too. createPositionsSafe() applies the same `(boardSeatDelegate() || cargoAssignableByNonAdmin())` conjunct, and create:Member alone reaches the boundary ONLY there — it has no update lane at all, so the update-only loop never probed it. Re-ran both rules mutations against the hardened rows: dropping the CEL conjunct turns 10 tests red across both lanes, widening boardSeatDelegate() to canDo() turns 3 red including the manage:all create row that did not exist before. Also documents the one place this mirror is deliberately STRICTER than the rules, which the reviewer surfaced and I am not fixing: "unresolvable" here means absent from the PARSED catalog, and parseDocs drops a doc for any schema violation — so a cargo that exists with grants: [] but a malformed `description` locks the editor out of a takedown currentCargoGrantsEmpty() would have allowed. Availability only, needs a malformed doc, Admin can still clear it, and resolving around the schema would mean reading positions past the validation that keeps unparsed data out of the client. The corruption that matters — a bad `grants` — locks on both sides. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/lib/assignable-cargo-core.ts | 13 ++++- .../cargo-assignment-parity.test.ts | 47 ++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index 4f984479..3de24170 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -117,7 +117,18 @@ export function heldCargo

( }; } -/** Whether the member is seated on SOMETHING whose power this editor cannot establish. */ +/** Whether the member is seated on SOMETHING whose power this editor cannot establish. + * + * KNOWN and accepted: "unresolvable" here means ABSENT FROM THE PARSED CATALOG, which is a + * slightly wider net than the rules cast. `parseDocs(positionDocSchema, …)` drops a doc for any + * schema violation, so a cargo that exists with `grants: []` but a missing `description` or an + * off-enum `category` is dropped here while `currentCargoGrantsEmpty()` reads `grants.size() == 0` + * and would ALLOW a non-Admin to clear it. The editor is then locked out of a takedown the rules + * keep open. Availability only, needs a malformed catalog doc, and an Admin can still clear it. + * Resolving the held cargo from the raw snapshot instead would close the gap and cost more than + * it buys: it means reading positions around the schema that exists to keep unvalidated data out + * of the client. The corruption that actually matters — a bad `grants` — locks on BOTH sides, + * since a non-empty stored `grants` fails `size() == 0` too. */ function heldCargoUnresolvable(held: HeldCargo): boolean { return held.cargoId !== null && held.cargoId !== undefined && held.cargo === undefined; } diff --git a/tests/firestore-rules/cargo-assignment-parity.test.ts b/tests/firestore-rules/cargo-assignment-parity.test.ts index e0f19723..598248d5 100644 --- a/tests/firestore-rules/cargo-assignment-parity.test.ts +++ b/tests/firestore-rules/cargo-assignment-parity.test.ts @@ -434,17 +434,50 @@ describe("cargo assignment ⟷ rules: every OFFERED cargo is a write the emulato // It CAN be asserted for the three cargos the delegation is about, and there the gap is not // curation but the boundary itself. Driven through the emulator, so it is the ruleset that // answers and not another read of the same client predicate. + // Each row proves its own lane reachability before asserting the denial. `assertFails` is + // REASON-BLIND: a later change that denies these writes for an unrelated reason — a tightened + // softDeleteSafe(), a lost perm, a renamed fixture — would keep every row green while the + // CEL/power boundary silently stopped being what denies them, which is the exact "green for + // the wrong reason" failure this file was written to end. The paired grant-free ALLOW is what + // makes the denial attributable: same principal, same lane, same fixture shape, one cargo + // apart, so the conjunct under test is the only difference between them. + // + // Both lanes, not just update: createPositionsSafe() applies the same + // `(boardSeatDelegate() || cargoAssignableByNonAdmin())` conjunct, and a principal that can + // create but not update (create-Member) reaches the boundary only there. const WITHHELD = ["cel_free", "cel_power", "jdl_power"] as const; - for (const label of ["custom(update-Position)", "custom(update-Member)", "custom(manage-all)"]) { + const CONVERSE_PRINCIPALS = [ + "custom(update-Position)", + "custom(update-Member)", + "custom(create-Member)", + "custom(manage-all)", + ]; + for (const label of CONVERSE_PRINCIPALS) { const principal = PRINCIPALS.find((p) => p.label === label); if (principal === undefined) continue; const g = gatesFor(principal); - if (g.editMode === "none") continue; - for (const cargoId of WITHHELD) { - it(`BLOCKING: the rules DENY ${label} the ${cargoId} seat the client withholds`, async () => { - expect(offeredCargoIds(g, null)).not.toContain(cargoId); - const id = await seedMember({ key: "unseated", cargoId: null }); - await assertFails(writeUpdate(as(principal), id, cargoId, principal.uid)); + const lanes: Lane[] = [ + ...(g.editMode === "none" ? [] : (["update"] as const)), + ...(g.canCreate ? (["create"] as const) : []), + ]; + for (const lane of lanes) { + const write = (id: string, cargoId: string | null) => + lane === "update" + ? writeUpdate(as(principal), id, cargoId, principal.uid) + : writeCreate(as(principal), id, cargoId, principal.uid); + const target = async () => + lane === "update" + ? await seedMember({ key: "unseated", cargoId: null }) + : `parity_converse_${docCounter++}`; + + it(`BLOCKING: the rules DENY ${label} on ${lane} every seat the client withholds`, async () => { + // Reachability, asserted rather than assumed: this principal really can write a seat on + // this lane, so the refusals below isolate the cargo conjuncts and nothing else. + await assertSucceeds(write(await target(), "jdl_free")); + for (const cargoId of WITHHELD) { + expect(offeredCargoIds(g, null)).not.toContain(cargoId); + await assertFails(write(await target(), cargoId)); + } }); } } From 0239ddf822442ba7a8af6f51d1b794f745e5f886 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 17:32:54 -0400 Subject: [PATCH 24/25] fix(beacon,rules): pin the null-vs-throw contract; dissolve the import cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of the previous commit. Verdict was fix-then-ship; this is the fix half. M1 — `nullIfUserNotFound`'s contract ("null ONLY when the account does not exist — a transient Auth error must throw, or a blip would misread a live linked account as safely deleted") was asserted in two docblocks and pinned by NOTHING. `getUserByUid`, the one the relink guard actually calls, had no coverage in this file at all, and provision-member-login.test.ts drives hand-written fakes that bypass the port entirely. The invisible mutation: widen the null branch to also swallow auth/internal-error and the whole beacon suite stays green while an Identity Toolkit blip lets a caller re-provision over a live account. Both lookups are now pinned both ways, and that exact mutation turns the new BLOCKING row red. L1 — the invalid-email tagging made provision-deps.ts and provision-member-login.ts a real two-node import cycle, safe only because every cross-module reference sat inside a hoisted function body. One top-level `const` reading across it — an ordinary-looking edit — would throw at module evaluation, and index.ts pulls this graph into the shared entry, so it would take out every trigger in the bundle at cold start. Nothing in the repo lints for cycles. The refusal factories move to provision-errors.ts and the cycle is gone, not documented; the remaining edge is type-only and erased. L2 — the tagged throw discards the underlying Auth error, and firebase-functions treats a thrown HttpsError as an expected refusal, so it emits no "Unhandled error" line. That left the failure class with zero trace in Cloud Logging (guardrail #4). Logged now, code only, never the address. Three comments corrected, all of them claims of mine this branch made false — the mistake-class this whole PR is about: - two present-tense comments still said auth/invalid-email "is rethrown as an opaque internal", which is what the previous commit removed. They are the stated justification for the shape screen, so a reader checking them would conclude one of the two guards is redundant and delete the wrong one. - "an Admin can still clear it" was false: an unresolvable cargo is absent from the option list and Combobox clears by re-selecting the selected option, so the Admin's remedy is reassignment. - "a bad grants locks on BOTH sides" overreached: CEL defines .size() on strings and maps, so `grants: ""` or `{}` reads as empty to the rules. And two vacuity guards on the converse block: a renamed principal label would have silently emitted no `it` for the create-Member row — the one that block exists for — and a principal reaching no lane would vanish rather than fail. Not taken, recorded: auth/invalid-uid still surfaces opaque (a different field, needs a console-written uid), and the create-lane fixture is a minimal doc rather than the mapper's full output (full-payload create parity lives in rules.test.ts). Co-Authored-By: Claude Opus 5 (1M context) --- .../members/lib/assignable-cargo-core.ts | 16 +++-- apps/beacon/src/provision-deps.test.ts | 51 +++++++++++++++- apps/beacon/src/provision-deps.ts | 20 ++++++- apps/beacon/src/provision-errors.ts | 43 ++++++++++++++ apps/beacon/src/provision-member-login.ts | 58 ++++++------------- .../cargo-assignment-parity.test.ts | 16 +++++ 6 files changed, 156 insertions(+), 48 deletions(-) create mode 100644 apps/beacon/src/provision-errors.ts diff --git a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts index 3de24170..b5103183 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo-core.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo-core.ts @@ -124,11 +124,17 @@ export function heldCargo

( * schema violation, so a cargo that exists with `grants: []` but a missing `description` or an * off-enum `category` is dropped here while `currentCargoGrantsEmpty()` reads `grants.size() == 0` * and would ALLOW a non-Admin to clear it. The editor is then locked out of a takedown the rules - * keep open. Availability only, needs a malformed catalog doc, and an Admin can still clear it. - * Resolving the held cargo from the raw snapshot instead would close the gap and cost more than - * it buys: it means reading positions around the schema that exists to keep unvalidated data out - * of the client. The corruption that actually matters — a bad `grants` — locks on BOTH sides, - * since a non-empty stored `grants` fails `size() == 0` too. */ + * keep open. Availability only, it needs a malformed catalog doc, and an Admin is not stuck — + * though the remedy is REASSIGNING the seat, not clearing it: an unresolvable cargo is absent + * from the option list, and Combobox clears by re-selecting the selected option, which has to + * exist. Resolving the held cargo from the raw snapshot instead would close the gap and cost + * more than it buys: it means reading positions around the schema that exists to keep + * unvalidated data out of the client. + * + * The corruption that actually matters — a NON-EMPTY `grants` carrying an unknown role — locks + * on both sides, since it fails `size() == 0` too. Not every malformed `grants` does: CEL + * defines `.size()` on strings and maps, so a stored `grants: ""` or `grants: {}` reads as + * empty to the rules and lands back in the availability-only bucket above. */ function heldCargoUnresolvable(held: HeldCargo): boolean { return held.cargoId !== null && held.cargoId !== undefined && held.cargo === undefined; } diff --git a/apps/beacon/src/provision-deps.test.ts b/apps/beacon/src/provision-deps.test.ts index 5252e9ff..0763a52b 100644 --- a/apps/beacon/src/provision-deps.test.ts +++ b/apps/beacon/src/provision-deps.test.ts @@ -13,9 +13,15 @@ const db = {} as Firestore; function fakeAuth(opts: { createError?: unknown; byEmailError?: unknown; + byUidError?: unknown; byEmail?: Record; + byUid?: Record; }) { - const calls = { createUser: [] as string[], getUserByEmail: [] as string[] }; + const calls = { + createUser: [] as string[], + getUserByEmail: [] as string[], + getUser: [] as string[], + }; const auth = { createUser: async ({ email }: { email: string }) => { calls.createUser.push(email); @@ -29,10 +35,53 @@ function fakeAuth(opts: { if (!user) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); return user; }, + getUser: async (uid: string) => { + calls.getUser.push(uid); + if (opts.byUidError !== undefined) throw opts.byUidError; + const user = opts.byUid?.[uid]; + if (!user) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); + return user; + }, } as unknown as Auth; return { auth, calls }; } +// The contract `nullIfUserNotFound` exists to hold, stated in the ProvisionDeps docblock and +// until now pinned by NOTHING: "null ONLY when the account does not exist — transient Auth +// errors must throw, or a blip would misread a live linked account as safely deleted". +// +// getUserByUid is the one the relink guard calls, and it had no coverage at all here; +// provision-member-login.test.ts drives hand-written fakes that bypass this port entirely. The +// mutation that was invisible: widen the null branch to `code === "auth/user-not-found" || +// code === "auth/internal-error"` and the whole beacon suite stays green, while an Identity +// Toolkit blip during the relink guard reads a LIVE linked account as safely deleted and lets +// the caller re-provision over it. +describe("firestoreProvisionDeps — the null-vs-throw contract", () => { + const lookups = ["getUserByEmail", "getUserByUid"] as const; + + it("returns null for user-not-found, on both lookups", async () => { + for (const method of lookups) { + const { auth } = fakeAuth({}); + await expect(firestoreProvisionDeps(db, auth)[method]("a@b.co")).resolves.toBeNull(); + } + }); + + it("BLOCKING: a transient Auth error throws — it must never read as a deleted account", async () => { + for (const method of lookups) { + for (const err of [ + authError("auth/internal-error"), + authError("auth/network-request-failed"), + new Error("socket hang up"), + ]) { + const { auth } = fakeAuth( + method === "getUserByEmail" ? { byEmailError: err } : { byUidError: err }, + ); + await expect(firestoreProvisionDeps(db, auth)[method]("a@b.co")).rejects.toThrow(); + } + } + }); +}); + const authError = (code: string) => Object.assign(new Error(code), { code }); describe("firestoreProvisionDeps.createUser", () => { diff --git a/apps/beacon/src/provision-deps.ts b/apps/beacon/src/provision-deps.ts index 6db6d88c..32cd1437 100644 --- a/apps/beacon/src/provision-deps.ts +++ b/apps/beacon/src/provision-deps.ts @@ -2,7 +2,8 @@ import type { Auth } from "firebase-admin/auth"; import type { Firestore } from "firebase-admin/firestore"; import { readPositionGrants } from "./read-position-grants.js"; import { logError } from "./firestore-util.js"; -import { memberEmailMalformed, type ProvisionDeps } from "./provision-member-login.js"; +import { memberEmailMalformed } from "./provision-errors.js"; +import type { ProvisionDeps } from "./provision-member-login.js"; function authCode(err: unknown): unknown { return (err as { code?: unknown } | null)?.code; @@ -13,9 +14,19 @@ function authCode(err: unknown): unknown { * `ADMIN_SDK_EMAIL_SHAPE` and the Admin SDK's own isEmail alike and only fail server-side. * Rethrown raw, that is an opaque `internal` with no `details.reason` and a member nobody can * provision without knowing why. Tagged HERE rather than by chasing regex precision: this - * closes the class whatever the pattern does next. */ + * closes the class whatever the pattern does next. + * + * Residual, deliberately not chased further: a rejection that maps to `auth/invalid-argument` + * rather than `auth/invalid-email` still reaches the client opaque. The shape screen plus this + * tag cover the reachable cases. */ function tagInvalidEmail(err: unknown): never { - if (authCode(err) === "auth/invalid-email") throw memberEmailMalformed(); + if (authCode(err) === "auth/invalid-email") { + // The HttpsError replaces the original, and firebase-functions treats a thrown HttpsError + // as an EXPECTED refusal — no "Unhandled error" line. Without this the failure class would + // leave zero trace in Cloud Logging (guardrail #4). Code only, never the address: PII. + logError("provision refused: Auth rejected the stored email", { code: authCode(err) }); + throw memberEmailMalformed(); + } throw err; } @@ -47,6 +58,9 @@ export function firestoreProvisionDeps(db: Firestore, auth: Auth): ProvisionDeps linkUid: async (id, uid) => { await db.doc(`members/${id}`).update({ uid }); }, + // Deliberately NOT routed through tagInvalidEmail: this is the only Auth call that runs + // AFTER createUser and linkUid, so tagging it would tell the operator to "corrige el correo" + // about an account that already exists and is already linked. passwordResetLink: (email) => auth.generatePasswordResetLink(email), getPositionGrants: (cargoId) => readPositionGrants(db, cargoId, logError), }; diff --git a/apps/beacon/src/provision-errors.ts b/apps/beacon/src/provision-errors.ts new file mode 100644 index 00000000..9b0ff99e --- /dev/null +++ b/apps/beacon/src/provision-errors.ts @@ -0,0 +1,43 @@ +import { HttpsError } from "firebase-functions/v2/https"; +import type { ProvisionBlockReason } from "@luminova/types"; + +/** + * The tagged refusals `provisionMemberLogin` can be argued with, in a module BOTH the callable + * and its adapter can import. + * + * They live here rather than in `provision-member-login.ts` to dissolve a genuine import cycle: + * the port needs to raise the malformed-email refusal (Identity Toolkit rejects addresses the + * shape screen cannot anticipate), and the callable needs the port. That cycle was safe only + * because every cross-module reference sat inside a hoisted function body — one top-level + * `const` reading across it, which is an ordinary-looking edit, would throw at module + * evaluation, and `index.ts` pulls this graph into the shared entry, so it would take out every + * trigger in the bundle at cold start. Nothing in the repo lints for cycles. Same move + * `firestore-util.ts` already made for the log sinks. + */ + +/** A refusal the CLIENT can name. `reason` is a cross-boundary contract owned by + * `@luminova/types` (PROVISION_BLOCK_REASONS) and consumed by backstage's message table — + * routing every tagged throw through this helper is what makes renaming one a compile + * error on both ends instead of a silent degradation to the generic fallback. */ +export function provisionBlocked( + code: "failed-precondition" | "permission-denied", + message: string, + reason: ProvisionBlockReason, +): HttpsError { + return new HttpsError(code, message, { reason }); +} + +/** The stored email is unusable — absent, empty, wrong shape, or rejected by Identity Toolkit + * itself. One factory because the refusal is raised from TWO layers and must read identically + * from both: `provisionMember` screens the SHAPE up front, and the port tags the SEMANTIC + * rejection the shape screen cannot anticipate. `ADMIN_SDK_EMAIL_SHAPE` is a cheap pre-filter, + * never the sole guarantee — "a@.", ".a@b.co" and "a..b@c.co" each carry one `@`, no + * whitespace and no control characters, so they pass it AND the Admin SDK's own isEmail, reach + * the API, and come back auth/invalid-email. */ +export function memberEmailMalformed(): HttpsError { + return provisionBlocked( + "failed-precondition", + "member's stored email is missing or not a valid address; correct it before provisioning", + "member-email-malformed", + ); +} diff --git a/apps/beacon/src/provision-member-login.ts b/apps/beacon/src/provision-member-login.ts index b4459154..11274ab7 100644 --- a/apps/beacon/src/provision-member-login.ts +++ b/apps/beacon/src/provision-member-login.ts @@ -2,8 +2,8 @@ import { getAuth } from "firebase-admin/auth"; import { getFirestore } from "firebase-admin/firestore"; import { HttpsError, onCall } from "firebase-functions/v2/https"; import { isValidRole, type Role } from "@luminova/auth/roles"; -import type { ProvisionBlockReason } from "@luminova/types"; import { isSafeDocId } from "./firestore-util.js"; +import { memberEmailMalformed, provisionBlocked } from "./provision-errors.js"; import { callerIsAdmin, requireAdminOrPerm } from "./callable-auth.js"; import { firestoreProvisionDeps } from "./provision-deps.js"; import { ensureApp } from "./runtime.js"; @@ -36,34 +36,9 @@ export function nextClaims(existing: RawClaims | undefined, role: Role): { roles return { roles }; } -/** A refusal the CLIENT can name. `reason` is a cross-boundary contract owned by - * `@luminova/types` (PROVISION_BLOCK_REASONS) and consumed by backstage's message table — - * routing every tagged throw through this helper is what makes renaming one a compile - * error on both ends instead of a silent degradation to the generic fallback. */ -function provisionBlocked( - code: "failed-precondition" | "permission-denied", - message: string, - reason: ProvisionBlockReason, -): HttpsError { - return new HttpsError(code, message, { reason }); -} - -/** The stored email is unusable — absent, empty, wrong shape, or rejected by Identity Toolkit - * itself. One factory because the refusal is raised from TWO layers and must read identically - * from both: this module screens the SHAPE up front, and the port (provision-deps) tags the - * SEMANTIC rejection the shape screen cannot anticipate. `ADMIN_SDK_EMAIL_SHAPE` is a cheap - * pre-filter, never the sole guarantee — "a@.", ".a@b.co" and "a..b@c.co" each carry one `@`, - * no whitespace and no control characters, so they pass it AND the Admin SDK's own isEmail, - * reach the API, and come back auth/invalid-email. Without the port tagging that, it surfaced - * as an opaque `internal` and the operator got the generic "No se pudo…" — the dead end - * PROVISION_BLOCK_REASONS exists to remove, reached by a different road. */ -export function memberEmailMalformed(): HttpsError { - return provisionBlocked( - "failed-precondition", - "member's stored email is missing or not a valid address; correct it before provisioning", - "member-email-malformed", - ); -} +// provisionBlocked / memberEmailMalformed live in ./provision-errors.js — the port raises the +// malformed-email refusal too, and keeping the factories here made provision-deps.ts and this +// module a two-node import cycle. /** The Admin SDK's OWN email predicate (`validator.isEmail`: `/^[^@]+@[^@]+$/`), plus the one * tightening that is strictly safe: no whitespace, no control characters. @@ -71,12 +46,16 @@ export function memberEmailMalformed(): HttpsError { * Deliberately not an RFC-ish pattern — a stricter one would start rejecting addresses * Firebase happily accepts, which is a worse failure than the one being fixed. But `[^@]` * matches `\n`, `\r`, `\t`, spaces and NUL, so `"pres@jci.bo\n"` and `"a b@jci.bo"` pass BOTH - * this screen and the SDK's client-side check, reach Identity Toolkit, and come back - * INVALID_EMAIL → `auth/invalid-email` → rethrown by `nullIfUserNotFound` as an opaque - * `internal` with no `details.reason`. That is exactly the unprovisionable-with-no-hint - * failure this constant exists to prevent, and `firestore.rules` never constrains - * `members.email`, so a CSV paste or any `update:Member` holder can store one. Rejecting them - * here costs nothing: the server rejects them anyway, and now with a reason the UI can name. */ + * this screen and the SDK's client-side check and reach Identity Toolkit, which rejects them. + * `firestore.rules` never constrains `members.email`, so a CSV paste or any `update:Member` + * holder can store one. + * + * This is a PRE-FILTER, not the guarantee. The port tags Identity Toolkit's own + * `auth/invalid-email` with the same reason (see ./provision-errors.js), so a shape that slips + * through — `a@.`, `.a@b.co` — no longer surfaces as an opaque `internal`. The screen still + * earns its keep: it refuses one round-trip earlier, never puts a junk address on the Auth + * API, and makes the refusal identical whether or not Identity Toolkit happens to reject that + * particular shape. */ const ADMIN_SDK_EMAIL_SHAPE = /^[^@\s\p{C}]+@[^@\s\p{C}]+$/u; export interface ProvisionUser { @@ -204,10 +183,11 @@ export async function provisionMember( if (member.active !== true) throw new HttpsError("failed-precondition", "member is not active"); // Shape-screened BEFORE it reaches the Auth SDK, for the same reason cargoId and assignedBy // are screened in claims-sync: a stored value the SDK rejects throws a PERMANENT - // auth/invalid-email, which nullIfUserNotFound rethrows and the caller receives as an opaque - // `internal`. That member is then unprovisionable through this callable — with no hint why — - // until someone edits the doc in the console. firestore.rules deliberately does not - // shape-validate `email` on the admin write lane, so the shape reaches here unchecked. + // auth/invalid-email. That used to reach the caller as an opaque `internal`, leaving the + // member unprovisionable with no hint why; the port now tags that case with this same reason, + // so this check is the cheap first line rather than the only one. firestore.rules + // deliberately does not shape-validate `email` on the admin write lane, so the shape reaches + // here unchecked. // // ONE check, not a separate untagged "member has no email" above it. That one threw with no // `details.reason`, so the UI degraded it to the generic "no se pudo" — verbatim the dead end diff --git a/tests/firestore-rules/cargo-assignment-parity.test.ts b/tests/firestore-rules/cargo-assignment-parity.test.ts index 598248d5..669b6d61 100644 --- a/tests/firestore-rules/cargo-assignment-parity.test.ts +++ b/tests/firestore-rules/cargo-assignment-parity.test.ts @@ -452,14 +452,30 @@ describe("cargo assignment ⟷ rules: every OFFERED cargo is a write the emulato "custom(create-Member)", "custom(manage-all)", ]; + // Vacuity guard, same class as the matrix pin above. `CONVERSE_PRINCIPALS` holds strings + // built by `custom()`, so a rename there would silently emit no `it` for that principal — and + // the create-Member row is the one this block exists for. A missing label must be a failure, + // never a skip. + it("every converse principal actually exists", () => { + const known = PRINCIPALS.map((p) => p.label); + for (const label of CONVERSE_PRINCIPALS) expect(known).toContain(label); + }); + for (const label of CONVERSE_PRINCIPALS) { const principal = PRINCIPALS.find((p) => p.label === label); if (principal === undefined) continue; const g = gatesFor(principal); + // Stated rather than derived from MATRIX on purpose: a MATRIX-derived lane list would + // silently DROP a lane that happens to offer nothing, which is exactly the state this block + // needs to probe. The cost is a second copy of the derivation, so the row count is asserted + // below — a principal that reaches no lane at all must fail, not vanish. const lanes: Lane[] = [ ...(g.editMode === "none" ? [] : (["update"] as const)), ...(g.canCreate ? (["create"] as const) : []), ]; + it(`${label} reaches at least one lane`, () => { + expect(lanes.length).toBeGreaterThan(0); + }); for (const lane of lanes) { const write = (id: string, cargoId: string | null) => lane === "update" From 1f20cdeb9778645f8c27b35297d38366f8e3020a Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 28 Aug 2026 21:27:40 -0400 Subject: [PATCH 25/25] chore: reviews Reviews: 0239ddf822442ba7a8af6f51d1b794f745e5f886 security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher