diff --git a/apps/backstage/src/components/initiative-form.test.tsx b/apps/backstage/src/components/initiative-form.test.tsx
index 12a389b3..6a568627 100644
--- a/apps/backstage/src/components/initiative-form.test.tsx
+++ b/apps/backstage/src/components/initiative-form.test.tsx
@@ -83,4 +83,44 @@ describe("InitiativeForm", () => {
expect(screen.queryByLabelText("Estado")).not.toBeInTheDocument();
expect(screen.getByText(/no se puede reabrir/i)).toBeInTheDocument();
});
+
+ // `featured` curation is gated (rules' canCurateFeatured): Admin by role, everyone else by
+ // the update:Showcase perm. A non-curator must not even see the control — the write would
+ // be denied by firestore.rules, taking the whole save down with it.
+ it("renders the destacar checkbox only when the caller may curate", () => {
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByLabelText(/destacar en \/programas/i)).toBeInTheDocument();
+ unmount();
+
+ render(
+ ,
+ );
+ expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument();
+ });
+
+ it("hides the destacar checkbox when canFeature is not passed at all", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument();
+ });
});
diff --git a/apps/backstage/src/components/initiative-form.tsx b/apps/backstage/src/components/initiative-form.tsx
index 4d85cd27..ae180aa2 100644
--- a/apps/backstage/src/components/initiative-form.tsx
+++ b/apps/backstage/src/components/initiative-form.tsx
@@ -39,8 +39,11 @@ interface InitiativeFormProps {
isSaving: boolean;
onSubmit: (data: InitiativeInput) => void;
lockStatus?: boolean;
- /** Whether the caller may set `featured` — Admin/ProjectManager only, mirroring
- * the rules' `featuredUpdateSafe`. A direction/perm editor sees it disabled. */
+ /** Whether the caller may set `featured` — the Admin ROLE, or the `update:Showcase`
+ * PERM, mirroring the rules' `canCurateFeatured()`. Not a ProjectManager role check:
+ * the seed grants that role the perm, so deactivating the role now revokes curation,
+ * and a custom role carrying `update:Showcase` gains it. A direction/perm editor
+ * without either sees it disabled. */
canFeature?: boolean;
}
@@ -207,9 +210,10 @@ export function InitiativeForm({
)}
- {/* `featured` curation is Admin/ProjectManager-only (rules' featuredUpdateSafe);
- a non-curator can never set it here, so hide the control entirely. The form
- still submits the initiative's current value (unchanged), which the rule allows. */}
+ {/* `featured` curation needs the Admin role or the `update:Showcase` perm (rules'
+ canCurateFeatured); a non-curator can never set it here, so hide the control
+ entirely. The form still submits the initiative's current value (unchanged),
+ which the rule allows. */}
{canFeature && (
{
expect(canSee("/positions", claimsFor("Treasury"))).toBe(false);
});
+ it("admits an update:Position custom role to /positions, matching the catalog's own rule", () => {
+ // The catalog arms are canDo('update','Position') / canDo('create','Position'), and
+ // canDo treats manage:Position as satisfying update:Position — so keying `orCan` on
+ // `update` widens nothing the rules did not already allow, and stops the nav from
+ // hiding a page whose writes this principal can actually make (guardrail #6).
+ const orgChartEditor: AuthClaims = { roles: [], perms: ["update:Position"] };
+ expect(canSee("/positions", orgChartEditor)).toBe(true);
+ // read:Position is what a plain Member carries; it must still not open the page.
+ expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false);
+ });
+
+ it("gates /members on read:Member alone — update:Position opens nothing here", () => {
+ // Cargo assignment happens ON /members (the member roster), and the nav probes
+ // read:Member there. So the perm that carries the members-positions LANE is inert for
+ // reaching the page: a custom role holding only update:Position cannot get to the
+ // capability the owner-op hands it, which is why owner-op 1 mandates BOTH perms.
+ // Both halves are load-bearing and falsifiable: read:Member alone is what opens the
+ // page (drop it from the nav gate and the first line goes red), update:Position alone
+ // is what does not (add it as an `orCan` and the second goes red). The pair assertion
+ // this replaced was neither — read:Member already satisfied it, so deleting
+ // update:Position from it could not turn anything red.
+ expect(canSee("/members", { roles: [], perms: ["read:Member"] })).toBe(true);
+ expect(canSee("/members", { roles: [], perms: ["update:Position"] })).toBe(false);
+ });
+
it("shows /notificaciones to a compose-only principal (create:Notification, no read)", () => {
// The page's history list gates on read:Notification, but a compose-only principal
// holds only create:Notification. The item's `subject: Notification` read would hide
diff --git a/apps/backstage/src/components/nav-config.ts b/apps/backstage/src/components/nav-config.ts
index 5ad740df..8f6a2ae9 100644
--- a/apps/backstage/src/components/nav-config.ts
+++ b/apps/backstage/src/components/nav-config.ts
@@ -124,10 +124,13 @@ export const NAV_GROUPS: NavGroup[] = [
// Members can read Position (chip resolution on /me), and Membership shares
// ONLY that same read grant — so no perm cleanly separates catalog viewers
// from Members; hence the built-in allowlist. `orCan` re-admits a dynamic
- // custom role that manages the org chart (manage:Position) but carries no
- // built-in role name, so the route guard doesn't lock the perms system out.
+ // custom role that edits the org chart but carries no built-in role name, so
+ // the route guard doesn't lock the perms system out. Keyed on `update` to match
+ // the catalog's own rules (canDo('update','Position')), which canDo already
+ // treats manage:Position as satisfying — so this admits no principal the rules
+ // did not already let write.
roles: ["Admin", "Membership", "ExecutiveCommittee"],
- orCan: { action: "manage", subject: "Position" },
+ orCan: { action: "update", subject: "Position" },
},
{ to: "/permisos", label: "Permisos", icon: "lock", roles: ["Admin"] },
],
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 24a9f55b..0fa5bdde 100644
--- a/apps/backstage/src/features/members/components/member-form.test.tsx
+++ b/apps/backstage/src/features/members/components/member-form.test.tsx
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import type { Position } from "@luminova/types";
+import type { MemberInput, Position } from "@luminova/types";
import { MemberForm } from "./member-form";
+import { toMemberUpdateDoc } from "../repositories/member-mapper";
import { pickDate } from "../../../test/pick-date";
const positions: Position[] = [
@@ -91,8 +92,13 @@ describe("MemberForm", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
+ // allowPowerGrants: pos-pres is a grant-free CEL cargo, which only an Admin may assign
+ // (rules' cargoAssignableByNonAdmin). This case is about the LABELS, so give it the
+ // authority that renders them all.
it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => {
- render( );
+ render(
+ ,
+ );
await userEvent.click(screen.getByRole("button", { name: "Femenino" }));
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Presidenta")).toBeInTheDocument();
@@ -183,9 +189,12 @@ describe("MemberForm", () => {
);
});
+ // allowPowerGrants for the same reason: picking a CEL cargo at all is an Admin flow.
it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", 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" }));
@@ -229,6 +238,148 @@ describe("MemberForm", () => {
expect(await screen.findByText("Tesorero (inactivo)")).toBeInTheDocument();
});
+ // Mirror of firestore.rules cargoAssignableByNonAdmin() on the CREATE lane
+ // (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( );
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ expect(await screen.findByText("Director de Área")).toBeInTheDocument();
+ expect(screen.queryByText("Presidente")).not.toBeInTheDocument();
+ });
+
+ it("shows a grant-free CEL cargo to an Admin", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ expect(await screen.findByText("Presidente")).toBeInTheDocument();
+ });
+
+ // The lock, not just the option list: every save re-stamps the assigned cargoId, so a
+ // non-Admin editing a member already seated on a grant-free CEL cargo is denied on the
+ // positions slot. Locking it keeps the bio fields savable (the mapper omits the
+ // unchanged slot) instead of failing the whole form with no explanation.
+ // BLOCKING: the rules conjuncts are asymmetric. Keeping a grant-free CEL seat is denied
+ // (`cargoAssignableByNonAdmin`), but CLEARING it is allowed on purpose —
+ // `currentCargoGrantsEmpty()` is not category-gated, because denying it "would strand a
+ // takedown behind an Admin". So the seat renders disabled rather than locked or dropped.
+ const celSeated = {
+ name: "Ana Pérez",
+ email: "ana@jci.bo",
+ gender: "Femenino" as const,
+ joinDate: "2020-03-15",
+ birthdate: "1992-07-15",
+ status: "Activo" as const,
+ cargoId: "pos-pres",
+ comisionIds: [],
+ };
+
+ it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
+ });
+
+ // Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added
+ // an ACTIVE cargo under an inactive label — and re-offered it to the very non-Admin whose
+ // write the rules reject. The two member forms must answer the same rules predicate.
+ it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getByLabelText("Cargo");
+ expect(trigger).toHaveTextContent("Presidente");
+ expect(trigger).not.toHaveTextContent(/inactivo/i);
+ });
+
+ it("BLOCKING: reaches the takedown of a grant-free CEL seat and submits cargoId null", async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
+ expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
+ await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null }));
+ });
+
+ it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ const seat = await screen.findByRole("option", { name: "Presidenta" });
+ expect(seat).toHaveAttribute("aria-disabled", "true");
+ await userEvent.click(seat);
+ await userEvent.keyboard("{Escape}");
+ expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
+ await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null }));
+ });
+
+ // The other half of "never submittable": leaving the seat untouched is the one state that
+ // still carries the CEL cargoId out of the form, and it must never become a positions
+ // WRITE. Asserted through the mapper the edit lane actually uses, not by inspection — the
+ // form's safety here is entirely toMemberUpdateDoc omitting an unchanged slot.
+ it("BLOCKING: an untouched CEL seat never reaches the positions write", async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ const [submitted] = onSubmit.mock.calls[0]! as [MemberInput];
+ expect(submitted.cargoId).toBe("pos-pres");
+ const doc = toMemberUpdateDoc(submitted, "uid-editor", {
+ cargoId: "pos-pres",
+ comisionIds: [],
+ });
+ expect(Object.keys(doc).some((key) => key.startsWith("positions."))).toBe(false);
+ });
+
+ it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
+ });
+
it("renders comisión option as 'sigla — title' when sigla is present", async () => {
render(
Promise;
showPreview?: boolean;
avatarSeed?: string;
- /** Whether the editor may assign power-granting cargos — Admin only (rules'
- * `cargoGrantsEmpty` / `createPositionsSafe`). Non-Admin sees only grant-free
+ /** Whether the editor may assign the cargos the rules reserve to an Admin — power-granting
+ * 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;
children?: ReactNode;
@@ -99,31 +103,29 @@ export function MemberForm({
// only when the user actively switches TO a CEL cargo (see the Cargo onChange) — never
// force-cleared at submit, so a bio edit of a legacy CEL member with stored comisiones
// doesn't trigger a positions write the editor may not be allowed to make.
- const isExecutiveCommitteeCargo =
- positions.find((p) => p.id === currentCargoId)?.category === "CEL";
- const term = currentTermKey();
- // Keep the member's ORIGINALLY-assigned cargo selectable for a non-Admin even if it
- // grants power — but off the static default, not the reactive selection, so switching
- // away and back still works (matches MemberPositionsForm).
+ const selectedCargo = positions.find((p) => p.id === currentCargoId);
+ const isExecutiveCommitteeCargo = selectedCargo?.category === "CEL";
+ // Keep the member's ORIGINALLY-assigned cargo visible for a non-Admin even when the rules
+ // reserve it to an Admin — off the static default, not the reactive selection, so switching
+ // away and back still works (shared with MemberPositionsForm via cargoOptionsForEditor: a
+ // 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;
- // If that assigned cargo grants power and the editor isn't Admin, any positions write
- // is rule-denied (cargoGrantsEmpty) — lock the cargo/comisiones so bio edits still save
- // (the mapper omits the unchanged slot) but a futile positions change can't be attempted.
- const positionsLocked =
- !allowPowerGrants && (positions.find((p) => p.id === assignedCargoId)?.grants.length ?? 0) > 0;
- const activeCargoOptions = positions
- .filter(
- (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term),
- )
- .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === assignedCargoId)
- .map((p) => ({ value: p.id, label: positionTitle(p, gender) }));
- const assignedInactiveCargo =
- currentCargoId && !activeCargoOptions.some((o) => o.value === currentCargoId)
- ? positions
- .filter((p) => p.id === currentCargoId)
- .map((p) => ({ value: p.id, label: `${positionTitle(p, gender)} (inactivo)` }))
- : [];
- const cargoOptions = [...activeCargoOptions, ...assignedInactiveCargo];
+ // 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
+ // 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().
+ const assignedCargo = positions.find((p) => p.id === assignedCargoId);
+ const positionsLocked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo);
+ const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants);
+ const cargoOptions = cargoOptionsForEditor({
+ positions,
+ gender,
+ allowPowerGrants,
+ assignedCargoId,
+ });
const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title);
const activeComisionOptions = positions
@@ -230,21 +232,34 @@ export function MemberForm({
control={control}
name="cargoId"
render={({ field }) => (
- {
- field.onChange(v);
- // Switching to a CEL cargo drops any picked comisiones (CEL members
- // belong to the Comité Ejecutivo Local, not a comisión).
- if (positions.find((p) => p.id === v)?.category === "CEL") {
- setValue("comisionIds", []);
- }
- }}
- placeholder="Sin cargo"
- disabled={positionsLocked}
- />
+
+ {
+ field.onChange(v);
+ // Switching to a CEL cargo drops any picked comisiones (CEL members
+ // belong to the Comité Ejecutivo Local, not a comisión).
+ if (positions.find((p) => p.id === v)?.category === "CEL") {
+ setValue("comisionIds", []);
+ }
+ }}
+ placeholder="Sin cargo"
+ disabled={positionsLocked}
+ />
+ {cargoTakedown && (
+ field.onChange(null)}
+ >
+ Quitar cargo
+
+ )}
+
)}
/>
@@ -284,8 +299,15 @@ export function MemberForm({
)}
{positionsLocked && (
- Solo un Admin puede cambiar el cargo de un miembro con permisos. Puedes editar el resto
- de sus datos.
+ Solo un Admin puede cambiar el cargo de un miembro cuyo cargo otorga permisos. Puedes
+ editar el resto de sus datos.
+
+ )}
+ {cargoTakedown && (
+
+ Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes
+ quitárselo con «Quitar cargo» o dejarlo como está; el resto de sus datos se guarda
+ igual.
)}
{
expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled();
});
+ // The publication half of the mirror. pos_cel_free is grant-free, so the grants filter
+ // alone still offered it: a non-Admin picked 'Presidente' and the save 403'd on the rules'
+ // `category != 'CEL'` conjunct with a generic error.
+ const celFree = pos("presidente_libre", "CEL");
+
+ it("hides a grant-free CEL cargo from a non-Admin", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ // The paired JDL dirección proves the filter is the CEL conjunct, not the list closing
+ // on grant-free board cargos generally — that exposure is accepted and must survive.
+ expect(await screen.findByText("dir")).toBeInTheDocument();
+ expect(screen.queryByText("presidente_libre")).not.toBeInTheDocument();
+ });
+
+ it("shows a grant-free CEL cargo to an Admin", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ expect(await screen.findByText("presidente_libre")).toBeInTheDocument();
+ });
+
+ // Not just the option list: `locked` has to cover it too. Every save re-stamps the
+ // assigned cargoId into the merged doc, so with the form unlocked a comisiones-only edit
+ // on a CEL-seated member is denied — no lock, no note, one generic error.
+ // BLOCKING: the two rules conjuncts are asymmetric, so the client must not mirror the
+ // wrong one. `cargoAssignableByNonAdmin()` denies KEEPING a grant-free CEL seat, but
+ // `currentCargoGrantsEmpty()` is deliberately not category-gated, so CLEARING it is
+ // allowed — firestore.rules says denying that "would strand a takedown behind an Admin".
+ // Locking the form here would strand exactly that takedown in the UI instead.
+ const celSeatedProps = {
+ positions: [celFree, pos("etica", "Comision")],
+ gender: "Masculino" as const,
+ allowPowerGrants: false,
+ defaultValues: { cargoId: "presidente_libre", comisionIds: [] },
+ };
+
+ // Dropping the seat from the options was half a mirror: it left the CEL cargoId as the RHF
+ // value with no option to render it, so the trigger showed the "Sin cargo" PLACEHOLDER for a
+ // seated member, saving as-is re-submitted the cargoId into a 403, and Combobox's clear
+ // gesture (re-select the selected option) was unreachable because that option did not exist.
+ it("BLOCKING: names the grant-free CEL seat a non-Admin holds instead of 'Sin cargo'", () => {
+ render( );
+ expect(screen.getByLabelText("Cargo")).toHaveTextContent("presidente_libre");
+ // Not locked — the takedown stays open — but not savable while the seat is kept either.
+ expect(screen.queryByText(/Solo un Admin puede cambiar los cargos/i)).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
+ });
+
+ it("BLOCKING: reaches the takedown a grant-free CEL seat allows and submits cargoId null", async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
+ expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
+ const save = screen.getByRole("button", { name: /guardar/i });
+ expect(save).toBeEnabled();
+ await userEvent.click(save);
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] }));
+ });
+
+ it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ const seat = await screen.findByRole("option", { name: "presidente_libre" });
+ expect(seat).toHaveAttribute("aria-disabled", "true");
+ await userEvent.click(seat);
+ await userEvent.keyboard("{Escape}");
+ expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
+ await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] }));
+ });
+
+ it("locks the form for a non-Admin when the current cargo GRANTS power (nothing succeeds)", () => {
+ const granting: Position = { ...pos("tesorero", "CEL"), grants: ["Treasury"] };
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
+ expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument();
+ });
+
+ it("does NOT lock a non-Admin editing a member seated on a grant-free JDL dirección", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled();
+ });
+
it("shows error alert when onSubmit throws", async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error("fail"));
render(
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 708784db..bc02f796 100644
--- a/apps/backstage/src/features/members/components/member-positions-form.tsx
+++ b/apps/backstage/src/features/members/components/member-positions-form.tsx
@@ -3,7 +3,12 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, Combobox, Field, MultiSelect } from "@luminova/ui";
-import { positionTitle, currentTermKey, type MemberGender, type Position } from "@luminova/types";
+import { type MemberGender, type Position } from "@luminova/types";
+import {
+ cargoOptionsForEditor,
+ cargoTakedownOnly,
+ positionsLockedForNonAdmin,
+} from "../lib/assignable-cargo";
const positionsSchema = z.object({
cargoId: z.string().min(1).nullable(),
@@ -22,9 +27,10 @@ export function MemberPositionsForm({
positions: Position[];
gender: MemberGender | undefined;
defaultValues: PositionsInput;
- /** Whether the caller may assign power-granting cargos. Only Admin may (rules'
- * `cargoGrantsEmpty`); a non-Admin sees only grant-free cargos (plus the current
- * assignment, so an existing selection still renders). */
+ /** Whether the caller may assign the cargos the rules reserve to an Admin — power-granting
+ * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`). A non-Admin sees only
+ * assignable cargos, plus the seat the member already holds rendered DISABLED, so the
+ * trigger names the real cargo without putting a denied write one click away. */
allowPowerGrants: boolean;
onSubmit: (data: PositionsInput) => Promise;
}) {
@@ -32,22 +38,26 @@ export function MemberPositionsForm({
const {
control,
handleSubmit,
+ watch,
formState: { isSubmitting },
} = useForm({ resolver: zodResolver(positionsSchema), defaultValues });
- const term = currentTermKey();
- // A non-Admin can't write positions at all for a member whose current cargo grants
- // power: the write re-stamps that cargoId and the rules' `cargoGrantsEmpty` denies it
- // (comisiones can't be changed either — the whole slot is rejected). Lock the form.
- const assignedCargoHasGrants =
- (positions.find((p) => p.id === defaultValues.cargoId)?.grants.length ?? 0) > 0;
- const locked = !allowPowerGrants && assignedCargoHasGrants;
- const cargoOptions = positions
- .filter(
- (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term),
- )
- .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === defaultValues.cargoId)
- .map((p) => ({ value: p.id, label: positionTitle(p, gender) }));
+ // A power-granting current cargo locks the whole slot for a non-Admin: every save re-stamps
+ // that cargoId, and `currentCargoGrantsEmpty()` blocks clearing it too, so nothing they can
+ // submit succeeds. A grant-free CEL seat is the asymmetric case — keeping it is 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 positionsLockedForNonAdmin() / cargoTakedownOnly().
+ const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId);
+ const locked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo);
+ const cargoOptions = cargoOptionsForEditor({
+ positions,
+ gender,
+ allowPowerGrants,
+ assignedCargoId: defaultValues.cargoId,
+ });
+ const selectedCargo = positions.find((p) => p.id === watch("cargoId"));
+ const takedownOnly = cargoTakedownOnly(selectedCargo, allowPowerGrants);
const comisionOptions = positions
.filter((p) => p.active && p.category === "Comision")
.map((p) => ({ value: p.id, label: p.sigla ? `${p.sigla} — ${p.title}` : p.title }));
@@ -68,14 +78,27 @@ export function MemberPositionsForm({
control={control}
name="cargoId"
render={({ field }) => (
-
+
+
+ {takedownOnly && (
+ field.onChange(null)}
+ >
+ Quitar cargo
+
+ )}
+
)}
/>
@@ -96,7 +119,13 @@ export function MemberPositionsForm({
{locked && (
- Solo un Admin puede cambiar los cargos de un miembro con permisos.
+ Solo un Admin puede cambiar los cargos de un miembro cuyo cargo otorga permisos.
+
+ )}
+ {takedownOnly && (
+
+ Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes quitárselo
+ con «Quitar cargo» y guardar, o elegir otro cargo.
)}
{formError && (
@@ -104,10 +133,14 @@ export function MemberPositionsForm({
{formError}
)}
+ {/* takedownOnly disables the save, not the form: every positions write this page makes
+ re-stamps the whole slot (MemberRepository.setPositions), so saving while the CEL seat
+ is still selected is the 403 the rules promise. Clearing it (or picking another cargo)
+ re-enables the save — that takedown is exactly what the rules keep open. */}
{isSubmitting ? "Guardando…" : "Guardar cargos"}
diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts
new file mode 100644
index 00000000..97c1b5b0
--- /dev/null
+++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts
@@ -0,0 +1,116 @@
+import { currentTermKey, positionTitle, type MemberGender, type Position } from "@luminova/types";
+
+/**
+ * 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 now goes through cargoOptionsForEditor() /
+// cargoTakedownOnly() / positionsLockedForNonAdmin(), 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";
+}
+
+/**
+ * Whether a non-Admin 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
+ * 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.
+ */
+export function positionsLockedForNonAdmin(
+ cargo: Pick | undefined,
+): boolean {
+ return cargo !== undefined && cargo.grants.length > 0;
+}
+
+/**
+ * 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) &&
+ !positionsLockedForNonAdmin(cargo)
+ );
+}
+
+export type CargoOption = { value: string; label: string; disabled?: boolean };
+
+/**
+ * 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.
+ */
+export function cargoOptionsForEditor({
+ positions,
+ gender,
+ allowPowerGrants,
+ assignedCargoId,
+ term = currentTermKey(),
+}: {
+ positions: Position[];
+ gender: MemberGender | undefined;
+ allowPowerGrants: boolean;
+ 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),
+ },
+ ];
+}
diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts
index ff0e54f7..e34662f8 100644
--- a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts
+++ b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts
@@ -30,16 +30,34 @@ describe("memberEditMode", () => {
expect(modeFor(roleClaims("Membership"))).toBe("full");
});
- it("BLOCKING: a Position capability alone opens no member editor — wrong collection", () => {
- // Both editors submit to members/{id}, gated by canDo('update','Member'). manage:Position
- // governs the separate `positions` cargo catalog, and it is Admin-assignable per member
- // from the profile's permissions panel — so returning "positions" for it rendered the
- // Cargos form to someone whose every submit is PERMISSION_DENIED, permanently. The
- // "positions" arm returns in PR 4 with the members-positions rules lane it maps to.
- expect(modeFor({ roles: [], perms: ["manage:Position"] })).toBe("none");
- expect(modeFor({ roles: [], perms: ["read:Position"] })).toBe("none");
+ it("BLOCKING: update:Member wins over update:Position, so the two editors never both render", () => {
+ // Order is the whole contract of this function: a principal holding both capabilities
+ // must get the full form, never the positions-only one, or the profile page would
+ // mount two competing editors over the same doc.
+ expect(modeFor({ roles: [], perms: ["update:Member", "update:Position"] })).toBe("full");
+ });
+
+ it("opens the positions editor for an update:Position holder — the members-positions lane", () => {
+ // firestore.rules' fourth members update arm is keyed on canDo('update','Position') and
+ // confined to hasOnly(['positions']), so this principal's cargo submit really lands.
+ expect(modeFor({ roles: [], perms: ["update:Position"] })).toBe("positions");
+ // CASL `manage` satisfies can("update", …) exactly as canDo() treats manage:Position as
+ // satisfying update:Position in the rules — so the manage holder gets the same editor.
+ expect(modeFor({ roles: [], perms: ["manage:Position"] })).toBe("positions");
// Even riding on the Member role every provisioned user carries, which is what makes
// the profile page load in the first place.
- expect(modeFor({ roles: ["Member"], perms: ["manage:Position"] })).toBe("none");
+ expect(modeFor({ roles: ["Member"], perms: ["manage:Position"] })).toBe("positions");
+ });
+
+ it("BLOCKING: read:Position alone opens no member editor — reading the catalog is not assigning", () => {
+ // The real guard. `read:Position` confers no write on members/{id}, so rendering the
+ // Cargos form for it would be the render-then-PERMISSION_DENIED shape this gate exists to
+ // remove — and the ability to read the catalog is close to universal. Not because the
+ // `perms` claim carries it (`BUILT_IN_ROLE_PERMS.Member` does not), but because
+ // `applyConditional` in packages/auth/src/ability.ts gives the `Member` ROLE an
+ // unconditioned `read` on `Position` for chip resolution on /me, and every provisioned
+ // user holds that role. The second case below is the production shape.
+ expect(modeFor({ roles: [], perms: ["read:Position"] })).toBe("none");
+ expect(modeFor({ roles: ["Member"], perms: ["read:Position"] })).toBe("none");
});
});
diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.ts b/apps/backstage/src/features/members/lib/member-edit-gate.ts
index 4aa0f27b..4a98511d 100644
--- a/apps/backstage/src/features/members/lib/member-edit-gate.ts
+++ b/apps/backstage/src/features/members/lib/member-edit-gate.ts
@@ -5,13 +5,25 @@ import type { Can } from "../../../lib/authz/use-can";
export type MemberEditMode = "full" | "positions" | "none";
/** Gates on the capability that governs the WRITE, not on a role and not on the subject the
- * form is named after. Both editors submit to `members/{id}`, whose update rule is
- * `canDo('update','Member')` (+ `positionsAssignmentSafe()`); `update:Position` governs the
- * separate `positions` cargo catalog, so reading it here rendered the Cargos form to a
- * principal whose every submit is denied — the render-then-die shape this gate exists to
- * remove, just relocated. Cargo assignment is Admin-only until PR 4 adds the
- * members-positions write lane keyed on `update:Position`; the `"positions"` arm comes back
- * then, together with the `firestore.rules` lane that makes it true. */
+ * form is named after. Both editors submit to `members/{id}`, and each arm maps to a real
+ * `firestore.rules` lane on that doc:
+ * - `"full"` → the institutional arm, `canDo('update','Member')`;
+ * - `"positions"` → the members-positions arm, `canDo('update','Position')` confined to
+ * `hasOnly(['positions'])` + `positionsAssignmentSafe()` — so an org-chart editor who is
+ * not a member editor may assign and clear GRANT-FREE cargos only, on both sides of a
+ * swap. `read:Position` is deliberately not enough: it writes nothing, and rendering the
+ * form for it would be the render-then-PERMISSION_DENIED shape this gate exists to
+ * remove. That case is not hypothetical — every provisioned user holds the `Member`
+ * role, and `applyConditional` (`packages/auth/src/ability.ts`) hands the `Member` role
+ * an unconditioned `read` on `Position` so cargo chips resolve on /me. It is a
+ * CONDITIONAL grant, not a coarse perm: `BUILT_IN_ROLE_PERMS.Member` carries no
+ * `read:Position`, so it never appears in the `perms` claim and cannot be revoked from
+ * `/permisos`. `can("read","Position")` is therefore true for essentially everyone,
+ * which is exactly why this gate keys on `update`.
+ * Order is load-bearing: `update:Member` is checked first so a principal holding both never
+ * mounts two competing editors over the same doc. */
export function memberEditMode(gate: Pick): MemberEditMode {
- return gate.can("update", "Member") ? "full" : "none";
+ if (gate.can("update", "Member")) return "full";
+ if (gate.can("update", "Position")) return "positions";
+ return "none";
}
diff --git a/apps/backstage/src/features/members/lib/member-permissions.ts b/apps/backstage/src/features/members/lib/member-permissions.ts
index 07b6a589..bacecb60 100644
--- a/apps/backstage/src/features/members/lib/member-permissions.ts
+++ b/apps/backstage/src/features/members/lib/member-permissions.ts
@@ -1,7 +1,10 @@
import { ROLES, type Member, type Position, type Role } from "@luminova/types";
-// Cargo grants only — comisiones are chips-only, so the panel mirrors exactly
-// what the claims-sync trigger will mint (rules⇄client parity).
+// 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
+// requires the cargo's `assignedBy` to hold Admin, and drops every grant when it does not.
+// So a cargo assigned by a non-Admin shows here and mints nothing.
export function effectiveRoles(
member: Pick,
positionsById: Map,
diff --git a/apps/backstage/src/features/members/repositories/member-repository.ts b/apps/backstage/src/features/members/repositories/member-repository.ts
index 5da4f881..2bce84ed 100644
--- a/apps/backstage/src/features/members/repositories/member-repository.ts
+++ b/apps/backstage/src/features/members/repositories/member-repository.ts
@@ -78,11 +78,13 @@ export class MemberRepository {
await updateDoc(doc(this.collection, id), toSelfProfileDoc(data));
}
- /** Org-chart edit: writes ONLY the current term's assignment (dot-path). The dedicated
- * ExecutiveCommittee positions-only rule this used to target is gone — the write now
- * goes through the ordinary `update:Member` lane plus `positionsAssignmentSafe()`, so
- * the narrow payload is about not tripping those constraints, not about a separate
- * allow-rule. */
+ /** Org-chart edit: writes ONLY the current term's assignment (dot-path). Two
+ * `firestore.rules` arms accept it: the institutional `update:Member` lane, and the
+ * members-positions lane keyed on `update:Position`, which additionally requires
+ * `affectedKeys().hasOnly(['positions'])`. So the narrow payload IS load-bearing — a
+ * ride-along field on this write would drop an `update:Position`-only caller off its
+ * only arm. Both arms also apply `positionsAssignmentSafe()`, hence the self-stamped
+ * `assignedBy` and the current-term-only dot path. */
async setPositions(
id: string,
assignment: { cargoId: string | null; comisionIds: string[] },
diff --git a/apps/backstage/src/features/permissions/lib/effective-preview.ts b/apps/backstage/src/features/permissions/lib/effective-preview.ts
index 4237d49b..a625806b 100644
--- a/apps/backstage/src/features/permissions/lib/effective-preview.ts
+++ b/apps/backstage/src/features/permissions/lib/effective-preview.ts
@@ -1,26 +1,19 @@
-import { resolveEffectivePerms } from "@luminova/auth/perms";
-import {
- BUILT_IN_ROLE_PERMS,
- type PermissionCode,
- type Role,
- type RoleDefinition,
-} from "@luminova/types";
+import { resolveBuiltInPerms } from "@luminova/auth/built-in-perms";
+import type { PermissionCode, Role, RoleDefinition } from "@luminova/types";
import { assignableRoles, isLiveRole } from "../../../lib/role-lifecycle";
/** Client-side mirror of the beacon resolution for the member-assignment preview:
* effective perms = built-in roles (held via positions) ∪ selected custom roles ∪
* override grants − revokes.
*
- * Three-way per built-in key, mirroring resolveMemberPerms:
- * - NO doc claims the key → the BUILT_IN_ROLE_PERMS snapshot (pre-seed window)
- * - doc(s) claim it, live → the UNION of their permissions
- * - doc(s) claim it, none live → nothing, and the key stays COVERED (so the
- * snapshot must NOT come back)
+ * The three-way itself is NOT reimplemented here — it is `resolveBuiltInPerms`
+ * (`@luminova/auth/built-in-perms`), the same function beacon's `resolveMemberPerms`
+ * delegates to, so preview and mint cannot drift. This file owns only the PORT: which
+ * docs claim a key, and whether each is live. `PERMISSION_CAP` is likewise not applied
+ * here — the role editor blocks Save on it; beacon fail-closes instead.
*
- * Grouped per key, not mapped: two docs may claim one builtInKey, and beacon computes
- * coverage over every doc it read and unions the live ones. A Map would keep only the
- * last, making this preview disagree with the perms that get minted — and disagree
- * differently depending on the sort order it happened to receive.
+ * Every doc claiming the key is passed through, live or not: coverage is what suppresses
+ * the snapshot, liveness is what contributes perms.
*
* NOT parity, and it cannot be: this reads `RoleDefinition[]` already through `parseDocs` +
* `roleDefinitionDocSchema`, so a doc the zod schema rejects is dropped before it arrives
@@ -48,19 +41,23 @@ export function previewEffectivePerms(input: {
overrides: { grant: PermissionCode[]; revoke: PermissionCode[] };
}): PermissionCode[] {
const byId = new Map(assignableRoles(input.allRoles).map((r) => [r.id, r]));
- const builtInDocs = input.builtInRoleNames.flatMap((name) => {
- // Every doc claiming the key, live or not: coverage is what suppresses the snapshot,
- // liveness is what contributes perms. One flatMap so `builtInKey === name` narrows
- // the Role type without a cast.
- const claiming = input.allRoles.filter((r) => r.builtIn && r.builtInKey === name);
- if (claiming.length === 0) return [{ permissions: BUILT_IN_ROLE_PERMS[name] }];
- return claiming.filter(isLiveRole);
- });
+ // ONE pass over allRoles, emitting every built-in doc once — not a rescan per requested
+ // name. Passing docs for keys the member does not hold is safe: `resolveBuiltInPerms`
+ // iterates the requested NAMES, so an unrequested key is never visited (it neither
+ // contributes perms nor covers a key). The `builtInKey !== null` guard is what narrows
+ // `Role | null` to `Role`, keeping the adapter castless.
+ const builtInDocs = input.allRoles.flatMap((r) =>
+ r.builtIn && r.builtInKey !== null
+ ? [{ permissions: r.permissions, builtInKey: r.builtInKey, live: isLiveRole(r) }]
+ : [],
+ );
const customDocs = input.selectedCustomRoleIds
.map((id) => byId.get(id))
.filter((r): r is RoleDefinition => r !== undefined);
- return resolveEffectivePerms({
- roleDocs: [...builtInDocs, ...customDocs],
+ return resolveBuiltInPerms({
+ builtInRoleNames: input.builtInRoleNames,
+ builtInDocs,
+ customDocs,
overrides: input.overrides,
});
}
diff --git a/apps/backstage/src/features/permissions/lib/permission-matrix.ts b/apps/backstage/src/features/permissions/lib/permission-matrix.ts
index c416c87d..1a7356a2 100644
--- a/apps/backstage/src/features/permissions/lib/permission-matrix.ts
+++ b/apps/backstage/src/features/permissions/lib/permission-matrix.ts
@@ -37,6 +37,7 @@ export const SUBJECT_LABELS: Record, string> =
Position: "Cargos",
Lead: "Prospectos",
Notification: "Notificaciones",
+ Showcase: "Destacados públicos",
};
/** Human label for a single code, e.g. "Editar Miembros". */
diff --git a/apps/backstage/src/features/positions/components/position-form.test.tsx b/apps/backstage/src/features/positions/components/position-form.test.tsx
index 94ef0d3f..411c507d 100644
--- a/apps/backstage/src/features/positions/components/position-form.test.tsx
+++ b/apps/backstage/src/features/positions/components/position-form.test.tsx
@@ -3,7 +3,7 @@ import { render as rtlRender, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { RoleDefinition } from "@luminova/types";
+import type { PositionInput, RoleDefinition } from "@luminova/types";
import { roleKeys } from "../../permissions/hooks/role-keys";
import { PositionForm } from "./position-form";
@@ -86,7 +86,13 @@ describe("PositionForm", () => {
it("submits a valid comisión with term null and grants untouched", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
- render( );
+ // canEditGrants, NOT false. With canEditGrants={false} this form starts on
+ // EMPTY_NON_ADMIN (category already "Comision") AND renders the select `disabled`, so
+ // the selectOptions below was a silent no-op and the assertion measured the default
+ // — it passed without the interaction it names. An Admin form starts on CEL with the
+ // select enabled, so selecting "Comision" is a real state change and the payload
+ // assertion is about the onChange handler (term -> null, grants -> [], sigla kept).
+ render( );
await userEvent.selectOptions(screen.getByLabelText("Categoría *"), "Comision");
await userEvent.type(screen.getByLabelText("Nombre *"), "Director de Ética");
await userEvent.type(screen.getByLabelText("Sigla *"), "CCE");
@@ -106,6 +112,110 @@ describe("PositionForm", () => {
});
});
+// The UI mirror of the non-Admin pin on the positions update/create arms in firestore.rules
+// (`unchanged('grants') && unchanged('category') && (!boardSurfacingCategory() || unchanged
+// title/titleFemale)`, and `!boardSurfacingCategory()` on create). It had NO tests: every
+// case below is the render-then-die shape — a control the rules reject, offered as editable —
+// or its inverse, a control the rules allow, locked for no reason.
+describe("PositionForm mirrors the non-Admin catalog pins", () => {
+ const boardCargo: Partial = {
+ category: "JDL",
+ term: 2026,
+ title: "Director de Prensa",
+ titleFemale: "Directora de Prensa",
+ description: "Prensa.",
+ grants: [],
+ };
+
+ it("locks title and titleFemale for a non-Admin editing a board cargo", () => {
+ // boardRank orders the public Directiva by the BASE title, so on a CEL/JDL cargo the
+ // label is an authority field and the rules pin it. Editable here = a generic
+ // "No se pudo guardar" on save.
+ render(
+ ,
+ );
+ expect(screen.getByLabelText("Cargo *")).toBeDisabled();
+ expect(screen.getByLabelText("Variante femenina (opcional)")).toBeDisabled();
+ expect(screen.getByRole("note")).toHaveTextContent(/categoría o el nombre/i);
+ });
+
+ it("BLOCKING: still submits the pinned labels on a non-Admin board-cargo save", async () => {
+ // The regression this exists for: `register("title", { disabled: true })` instead of a
+ // `disabled` prop on the Input. RHF's own `disabled` option submits the field as
+ // undefined, so every non-Admin save would post a doc with no title — zod rejects it
+ // (min 3) and onSubmit is never reached, or, past zod, firestore.rules 403s it because
+ // `unchanged('title')` compares a real title against null. Both halves asserted: the
+ // form validates AND the pinned values ride along unchanged.
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await userEvent.type(screen.getByLabelText("Descripción *"), " y comunicación.");
+ await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Director de Prensa",
+ titleFemale: "Directora de Prensa",
+ category: "JDL",
+ }),
+ );
+ });
+
+ it("leaves a comisión's name editable for a non-Admin (a label there is not authority)", () => {
+ // The inverse case, and the org-chart editor's legitimate use case per owner-op 1:
+ // comisiones never reach boardGroupFromCategory, so the rules leave their labels open.
+ // Locking them would be a UI-only denial with no rule behind it.
+ render(
+ ,
+ );
+ expect(screen.getByLabelText("Nombre *")).toBeEnabled();
+ expect(screen.getByLabelText("Sigla *")).toBeEnabled();
+ expect(screen.getByRole("note")).toHaveTextContent(/solo un admin puede cambiar la categoría/i);
+ });
+
+ it("defaults a non-Admin CREATE to Comisión and locks the category select", () => {
+ // `boardSurfacingCategory()` makes CEL/JDL creation Admin-only, so the blank non-Admin
+ // form must not start on the CEL default and die on save. The select is disabled for
+ // them on every form, create and edit alike — `category` is pinned either way.
+ render( );
+ const category = screen.getByLabelText("Categoría *");
+ expect(category).toBeDisabled();
+ expect(category).toHaveValue("Comision");
+ // A comisión is not a board cargo, so on this same form the labels stay open.
+ expect(screen.getByLabelText("Nombre *")).toBeEnabled();
+ });
+
+ it("leaves the category select open for an Admin", () => {
+ render( );
+ expect(screen.getByLabelText("Categoría *")).toBeEnabled();
+ expect(screen.getByLabelText("Cargo *")).toBeEnabled();
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ });
+});
+
describe("PositionForm grant options are total", () => {
it("still renders a stored grant whose role doc is not in the cache", () => {
// The real-world consequence of deriving the option list from the doc list: MultiSelect
diff --git a/apps/backstage/src/features/positions/components/position-form.tsx b/apps/backstage/src/features/positions/components/position-form.tsx
index 09cccb72..5bfc3ef3 100644
--- a/apps/backstage/src/features/positions/components/position-form.tsx
+++ b/apps/backstage/src/features/positions/components/position-form.tsx
@@ -28,6 +28,10 @@ const EMPTY: PositionInput = {
description: "",
};
+/** A non-Admin may only ever CREATE a comisión (firestore.rules `boardSurfacingCategory()`),
+ * so their blank form must not start on the CEL default and die on save. */
+const EMPTY_NON_ADMIN: PositionInput = { ...EMPTY, category: "Comision" };
+
function SectionLabel({ children }: { children: ReactNode }) {
return (
{children}
@@ -37,6 +41,9 @@ function SectionLabel({ children }: { children: ReactNode }) {
interface PositionFormProps {
defaultValues?: Partial;
submitLabel: string;
+ /** Admin-only authority over the fields the catalog rules pin for everyone else:
+ * `grants`, `category`, and — on a board cargo (CEL/JDL) — `title`/`titleFemale`.
+ * One prop, because firestore.rules keys all four on the same `hasAnyRole(['Admin'])`. */
canEditGrants: boolean;
onSubmit: (data: PositionInput) => Promise;
}
@@ -62,7 +69,10 @@ export function PositionForm({
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(positionSchema),
- defaultValues: { ...EMPTY, ...defaultValues },
+ // On an EDIT the stored category wins — it is pinned, not chosen. Both branches are
+ // module constants, so no fresh object is allocated per render.
+ defaultValues:
+ canEditGrants || defaultValues ? { ...EMPTY, ...defaultValues } : EMPTY_NON_ADMIN,
});
const categoryField = register("category");
@@ -70,6 +80,12 @@ export function PositionForm({
const title = watch("title");
const isTermVisible = category === "JDL";
const isComision = category === "Comision";
+ // Mirror of the non-Admin pin on the positions update arm in firestore.rules. `category`
+ // is an authority field (it decides the public board group), and on a board cargo so is
+ // the TITLE — boardRank() orders the public Directiva by it, so 'Vicepresidente' renamed
+ // to 'Presidente' sorts first. Rendering them editable to a non-Admin buys a generic
+ // "No se pudo guardar" on save: the render-then-die shape this repo guards against.
+ const areLabelsLocked = !canEditGrants && !isComision;
const submit = handleSubmit(async (data) => {
setFormError(null);
@@ -90,7 +106,7 @@ export function PositionForm({
required
error={errors.title?.message}
>
-
+
{!isComision && (
@@ -114,6 +131,7 @@ export function PositionForm({
{
void categoryField.onChange(e);
const newCategory = e.target.value;
@@ -136,6 +154,13 @@ export function PositionForm({
))}
+ {!canEditGrants && (
+
+ {areLabelsLocked
+ ? "Solo un Admin puede cambiar la categoría o el nombre de un cargo del CEL o de una dirección: el nombre define el orden en la Directiva pública."
+ : "Solo un Admin puede cambiar la categoría de un cargo."}
+
+ )}
{isTermVisible && (
diff --git a/apps/backstage/src/lib/authz/use-can.test.ts b/apps/backstage/src/lib/authz/use-can.test.ts
index c3441e54..52557627 100644
--- a/apps/backstage/src/lib/authz/use-can.test.ts
+++ b/apps/backstage/src/lib/authz/use-can.test.ts
@@ -38,10 +38,39 @@ describe("buildCan", () => {
expect(gate.hasRole(["Admin"])).toBe(true);
});
- it("canFeatureInitiatives holds for Admin or ProjectManager only", () => {
+ // Mirrors the rules' canCurateFeatured(): Admin by ROLE, everyone else by the
+ // update:Showcase PERM. ProjectManager curates because its seeded perms carry the code —
+ // deactivate that role and the name survives in the claim while the perm does not.
+ it("canFeatureInitiatives holds for Admin by role or an update:Showcase perm", () => {
expect(can({ roles: ["Admin"] }).canFeatureInitiatives).toBe(true);
- expect(can({ roles: ["ProjectManager"] }).canFeatureInitiatives).toBe(true);
- expect(can({ roles: ["Membership"] }).canFeatureInitiatives).toBe(false);
+ expect(
+ can({ roles: ["ProjectManager"], perms: ["update:Showcase"] }).canFeatureInitiatives,
+ ).toBe(true);
+ expect(can({ roles: ["Membership"], perms: ["manage:Member"] }).canFeatureInitiatives).toBe(
+ false,
+ );
+ });
+
+ it("canFeatureInitiatives holds for Admin with no perms claim at all (role disjunct)", () => {
+ expect(can({ roles: ["Admin"], perms: [] }).canFeatureInitiatives).toBe(true);
+ });
+
+ it("canFeatureInitiatives holds for a perms-only holder with no curation role", () => {
+ expect(can({ roles: ["Member"], perms: ["update:Showcase"] }).canFeatureInitiatives).toBe(true);
+ });
+
+ // The stale-claim case D exists to fix: the deactivated role doc contributes no perms, so
+ // the surviving ProjectManager name must not re-open curation.
+ it("canFeatureInitiatives is false for a ProjectManager whose role doc was deactivated", () => {
+ expect(
+ can({ roles: ["ProjectManager"], perms: ["manage:Project"] }).canFeatureInitiatives,
+ ).toBe(false);
+ });
+
+ // hasPerm, not canDo: manage:all is reachable as a perm without the Admin role, and the
+ // rules' gate is an exact code match. The client gate must not be looser than the rule.
+ it("canFeatureInitiatives is false for a manage:all perm holder without the Admin role", () => {
+ expect(can({ roles: ["Member"], perms: ["manage:all"] }).canFeatureInitiatives).toBe(false);
});
// Same invariant the `` gate carries: a conditional own-doc grant answers only the
diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts
index 1083917b..94dfa074 100644
--- a/apps/backstage/src/lib/authz/use-can.ts
+++ b/apps/backstage/src/lib/authz/use-can.ts
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { hasAnyRole, type AuthClaims, type Role } from "@luminova/auth/roles";
+import { hasAnyRole, hasPerm, type AuthClaims, type Role } from "@luminova/auth/roles";
import type { Action, AppAbility, Subject } from "@luminova/auth/ability";
import type { ParticipationRole } from "@luminova/types/engine";
import { isNavItemVisible, type NavItem } from "../../components/nav-config";
@@ -27,12 +27,15 @@ export interface Can {
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' `featuredUpdateSafe`). Named
- * here so the Admin/ProjectManager policy lives in one place, not scattered
- * role-array literals at each call site. */
+ /** 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 assign power-granting cargos (rules' `cargoGrantsEmpty` / `createPositionsSafe`
- * — Admin only). Named so the policy isn't a bare `isAdmin` at each grant site. */
+ /** The Admin-only half of the positions authority — one flag because firestore.rules keys
+ * every part of it on the same `hasAnyRole(['Admin'])`: assigning a power-granting cargo
+ * (`cargoAssignableByNonAdmin` / `currentCargoGrantsEmpty` / `createPositionsSafe`) or a CEL cargo
+ * at all, creating a board-surfacing cargo (`boardSurfacingCategory()`), and editing a
+ * stored cargo's `grants`, `category` or — on a board cargo — `title`/`titleFemale`.
+ * Named so the policy isn't a bare `isAdmin` at each grant site. */
readonly canAssignPowerGrants: boolean;
}
@@ -51,7 +54,18 @@ export function buildCan(ability: AppAbility, claims: AuthClaims): Can {
isNavItemVisible(item, ability, claims) && !(item.to === "/" && isMemberOnly(claims)),
canRemoveCheckIn: (entry) => canRemoveEntry(ability, claims, entry),
isAdmin: hasAnyRole(claims, ["Admin"]),
- canFeatureInitiatives: hasAnyRole(claims, ["Admin", "ProjectManager"]),
+ // 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: hasAnyRole(claims, ["Admin"]) || hasPerm(claims, "update:Showcase"),
canAssignPowerGrants: hasAnyRole(claims, ["Admin"]),
};
}
diff --git a/apps/backstage/src/lib/role-lifecycle.ts b/apps/backstage/src/lib/role-lifecycle.ts
index 6cdeb601..54f389df 100644
--- a/apps/backstage/src/lib/role-lifecycle.ts
+++ b/apps/backstage/src/lib/role-lifecycle.ts
@@ -22,7 +22,13 @@ export function isUndeactivatableRole(role: RoleDefinition): boolean {
* (apps/beacon/src/claims-sync/role-doc.ts). BOTH fields matter: `active: true` with
* `deletedAt` set is live to `where("active","==",true)` and dead to the perms
* pipeline, so a surface that trusted `active` alone would offer a role that mints
- * nothing. Keep the two in lockstep. */
+ * nothing. Keep the two in lockstep.
+ *
+ * Not the SAME function, though, and it cannot be shared as one: this side is
+ * fail-CLOSED (`active === true`) where beacon's is fail-OPEN (`active !== false`), so a
+ * doc with a missing `active` reads dead here and live there. That gap is documented, not
+ * fixed — see `previewEffectivePerms` and docs/specs/role-lifecycle.md — and closing it
+ * would be a behaviour change, so it is not the kind of duplication to collapse. */
export function isLiveRole(role: RoleDefinition): boolean {
return role.active && role.deletedAt === null;
}
diff --git a/apps/beacon/CLAUDE.md b/apps/beacon/CLAUDE.md
index 35f1e626..61c15672 100644
--- a/apps/beacon/CLAUDE.md
+++ b/apps/beacon/CLAUDE.md
@@ -205,6 +205,28 @@ undone by re-running anything: reverting means editing `roles/Member` back down.
then query programs+projects on `roster.directorId` / `roster.coDirectorIds` /
`roster.teamIds` (nested paths — the bare names match nothing), bounded with `.limit()`
and re-projected through `chunk()`.
+- **Deferred (boardShowcase stale publication under the fail-closed `active` guard):**
+ `projectBoard` now drops a member on `deletedAt != null || active !== true` — fail-closed,
+ matching `projectAlly`. That stops the NEXT publication of a member whose `active` is a
+ non-bool (the string `"false"`) or absent, but it cannot reach a row already published
+ under the old `active === false` test: `onBoardMemberWritten` fires only on a
+ `members/{id}` write, and `firestore.rules` now makes such a doc admin-SDK-only on every
+ lane but one. There is no automatic remedy — `pnpm audit:soft-delete-shapes --repair`
+ deliberately refuses to coerce a non-bool `active`, so it writes nothing and fires no
+ trigger for exactly the exposed shape. The only remedies are (a) that script's report,
+ which lists every exposed doc untruncated, then a Firebase console edit of `active`, or
+ (b) an Admin `publicProfile: false` write on the member — the takedown arm in
+ `firestore.rules` skips `softDeleteSafe()` on purpose so it stays open on these docs (a
+ rules test pins it), though backstage will not list the member because `memberDocSchema`
+ drops it. The script's repair moves the projection the OTHER way for a different shape: a
+ member missing `active` is repaired to `active: true`, which un-blocks this same fail-closed
+ gate and, if the rest of `projectBoard` passes, **adds** a public row. That is announced per
+ doc (`WILL PUBLISH:`) and withheld behind `--allow-publish`, so publication is never a
+ silent side effect of a shape fix. **`pnpm audit:soft-delete-shapes` is a deploy
+ precondition** for the
+ well-formedness rules (owner-op 4 of `docs/specs/position-assignment-lane.md`); it exits 1
+ on findings and 2 when the run itself did not complete. Fix later with a scheduled
+ re-projection, which would also close the term-rollover gap above.
- **boardShowcase ordering (CLOSED):** `onBoardMemberWritten` used to project
`after.data()`, so a late-delivered invocation could re-publish a member from a stale
payload — silently undoing an opt-out or an Admin takedown until the next member write.
diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts
index a790ae3d..2fa90a1f 100644
--- a/apps/beacon/src/claims-sync/firestore-deps.ts
+++ b/apps/beacon/src/claims-sync/firestore-deps.ts
@@ -53,7 +53,7 @@ async function queryBuiltInRoleDocs(
db: Firestore,
keys: Role[],
{ log = true }: { log?: boolean } = {},
-): Promise {
+): Promise {
// `in` supports ≤30 values; ROLES has 9. `builtIn === true` is defense in
// depth against an impostor custom role spoofing a builtInKey (rules also
// forbid clients setting builtInKey, but the trust boundary is the trigger).
@@ -135,16 +135,22 @@ async function queryBuiltInRoleDocs(
// object graph to every member of the fan-out, so an in-place mutation anywhere
// downstream (a future `doc.permissions.sort()`) would corrupt every remaining member's
// claims — one write, N wrong results, no log. Freezing makes that throw in strict mode
- // instead. resolveEffectivePerms only reads, so nothing needs the mutability today.
+ // instead. The type agrees, and that is the point: the PORT itself is
+ // `Promise` (and `BuiltInRoleDoc.permissions` is
+ // `readonly`), so the frozen value is honestly typed and needs no cast. A mutable array
+ // type here would assert a mutability the value does not have — a later `docs.sort()`
+ // would compile clean and throw at runtime inside a `retry: false` fan-out.
+ // (Claims-sync no longer calls `resolveEffectivePerms` directly at all — it goes through
+ // `resolveMemberPerms` → `resolveBuiltInPerms`.)
return Object.freeze(
covering.map((d) =>
Object.freeze({
- permissions: Object.freeze(permsFromRoleDoc(d.data())) as PermissionCode[],
+ permissions: Object.freeze(permsFromRoleDoc(d.data())),
builtInKey: d.get("builtInKey") as Role,
live: isActiveRoleDoc(d.data()),
}),
),
- ) as LiveBuiltInRoleDoc[];
+ );
}
/** Order-insensitive canonical form of a built-in query result, for the staleness compare.
@@ -219,8 +225,8 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD
// and the caller logs the operator's cue to run recomputeAllClaims. Deliberately NOT a TTL:
// a TTL would narrow the window while keeping the failure silent, which is strictly worse
// than a wide window an operator is told about.
- const builtInDocsCache = new Map>();
- function loadBuiltInRoleDocs(keys: Role[]): Promise {
+ const builtInDocsCache = new Map>();
+ function loadBuiltInRoleDocs(keys: Role[]): Promise {
const cacheKey = [...new Set(keys)].sort().join(",");
let pending = builtInDocsCache.get(cacheKey);
if (!pending) {
diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.test.ts b/apps/beacon/src/claims-sync/resolve-member-perms.test.ts
index b88ae950..d15c347c 100644
--- a/apps/beacon/src/claims-sync/resolve-member-perms.test.ts
+++ b/apps/beacon/src/claims-sync/resolve-member-perms.test.ts
@@ -105,6 +105,33 @@ describe("resolveMemberPerms", () => {
expect(out).toEqual(["manage:Position", "read:MemberPoints"]);
});
+ it("skips each query outright when its input is empty", async () => {
+ // The `.length ? … : []` short-circuits are an asserted invariant of the docblock, and
+ // nothing else pins them: dropping one still returns the right perms (both ports answer
+ // [] for an empty input), it just spends a Firestore round trip per member of an
+ // unbounded fan-out. Counting fake, so the regression is observable at all.
+ let builtInCalls = 0;
+ let customCalls = 0;
+ const counting = deps({
+ getRoleDocsByBuiltInKeys: async () => {
+ builtInCalls += 1;
+ return [];
+ },
+ getRolesByIds: async () => {
+ customCalls += 1;
+ return [];
+ },
+ });
+
+ await resolveMemberPerms(counting, [], ["role-x"], NO_OVERRIDES);
+ expect(builtInCalls).toBe(0);
+ expect(customCalls).toBe(1);
+
+ await resolveMemberPerms(counting, ["Treasury"], [], NO_OVERRIDES);
+ expect(builtInCalls).toBe(1);
+ expect(customCalls).toBe(1);
+ });
+
it("resolves the coarse reads for a plain Member/Scanner with no extras", async () => {
// Scanner is no longer conditional-only: event scoping was abandoned, so it carries
// coarse read:Activity + checkIn:Attendance alongside Member's member-facing reads.
diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.ts b/apps/beacon/src/claims-sync/resolve-member-perms.ts
index 1efdd106..64200718 100644
--- a/apps/beacon/src/claims-sync/resolve-member-perms.ts
+++ b/apps/beacon/src/claims-sync/resolve-member-perms.ts
@@ -1,23 +1,11 @@
import type { Role } from "@luminova/auth/roles";
-import { resolveEffectivePerms } from "@luminova/auth/perms";
-import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition";
+import { resolveBuiltInPerms, type BuiltInRoleDoc } from "@luminova/auth/built-in-perms";
import type { PermissionCode, RoleDefinition } from "@luminova/types";
-/** A built-in role doc as this resolver consumes it.
- *
- * `live` is deliberately NOT `RoleDefinition["active"]`, and this type is deliberately
- * not a `Pick` of the doc shape: liveness is the TWO-field predicate `isActiveRoleDoc`
- * computes over `active` AND `deletedAt`. A port field named `active` reads as "the doc's
- * `active` field", so an implementer returning `d.get("active")` would satisfy the type
- * while readmitting the ghost shape (`active: true` with a non-null `deletedAt`) that
- * mints the doc's real perms — the failure the three `resolveMemberPerms` liveness tests
- * exist to catch. Naming the semantic keeps the contract unspoofable by a plain field read. */
-export interface LiveBuiltInRoleDoc {
- permissions: PermissionCode[];
- builtInKey: Role;
- /** `isActiveRoleDoc(doc)` — active AND not soft-deleted. Never the raw `active` field. */
- live: boolean;
-}
+/** The beacon-local name for the shared `BuiltInRoleDoc` port — an ALIAS, not a restatement,
+ * so the compiler couples them and the `live`-not-`active` rationale lives in one docblock
+ * (`@luminova/auth/built-in-perms`). */
+export type LiveBuiltInRoleDoc = BuiltInRoleDoc;
export interface RolePermsDeps {
/** EVERY built-in role doc matching these keys by builtInKey — live AND not-live.
@@ -25,7 +13,7 @@ export interface RolePermsDeps {
* COVER their key, which is the only thing that tells "deactivated" apart from
* "never seeded". Filter them out here and a deactivation silently restores the
* seed snapshot through the fallback below. */
- getRoleDocsByBuiltInKeys(keys: Role[]): Promise;
+ getRoleDocsByBuiltInKeys(keys: Role[]): Promise;
/** Custom role docs by id — ACTIVE only. There is no fallback on this path, so
* dropping an inactive doc already yields zero perms. */
getRolesByIds(ids: string[]): Promise[]>;
@@ -35,11 +23,24 @@ export interface RolePermsDeps {
* the built-in roles they hold (via positions), their directly-assigned custom
* roles, and their per-member overrides.
*
- * Three-way per built-in key:
- * - doc ABSENT → BUILT_IN_ROLE_PERMS[key] (the pre-seed window must
- * still mint perms on a fresh project)
- * - doc present, live → the doc's stored `permissions`
- * - doc present, not live → nothing, and the key stays COVERED
+ * This function owns the FETCH orchestration only; the absent/live/inactive three-way
+ * — including the `BUILT_IN_ROLE_PERMS` fallback — lives in `resolveBuiltInPerms`
+ * (`@luminova/auth/built-in-perms`), shared with the backstage assignment preview so the
+ * admin authorizes from the same resolution beacon then mints. `PERMISSION_CAP` stays out
+ * of the shared half: this side fail-closes to `perms: []` in `sync.ts`, backstage blocks
+ * Save. Liveness DERIVATION also stays here — `isActiveRoleDoc` is fail-open where the
+ * backstage mirror is fail-closed, so the two are not one function — and only its
+ * consumption is shared.
+ *
+ * The two fetches are INDEPENDENT and run concurrently, and the saving is ONE round-trip
+ * overlap per fresh deps instance — not one per member. `firestoreClaimsDeps` memoizes the
+ * built-in query per instance and `onRoleWritten` holds ONE instance for its whole fan-out,
+ * so from the second member onward `getRoleDocsByBuiltInKeys` returns an already-settled
+ * promise: a microtask, not a round trip. What `Promise.all` actually buys is the first
+ * member of a fan-out and every `onMemberWritten` invocation (one instance, one member).
+ * Cheap and correct, but it is not what keeps the 540 s / `retry: false` fan-out inside its
+ * budget — the memo is. The `.length ? … : []` short-circuits are preserved: an empty input
+ * must still skip its query outright (pinned by a test in this file's suite).
*
* Two production callers inherit this: claims-sync/sync.ts (the onMemberWritten /
* onRoleWritten trigger) and set-user-roles.ts (the setUserRoles admin callable). */
@@ -49,16 +50,9 @@ export async function resolveMemberPerms(
roleIds: string[],
overrides: { grant: PermissionCode[]; revoke: PermissionCode[] },
): Promise {
- const builtInDocs = builtInRoleNames.length
- ? await deps.getRoleDocsByBuiltInKeys(builtInRoleNames)
- : [];
- const covered = new Set(builtInDocs.map((doc) => doc.builtInKey));
- const fallback = builtInRoleNames
- .filter((role) => !covered.has(role))
- .map((role) => ({ permissions: BUILT_IN_ROLE_PERMS[role] }));
- const customDocs = roleIds.length ? await deps.getRolesByIds(roleIds) : [];
- return resolveEffectivePerms({
- roleDocs: [...builtInDocs.filter((doc) => doc.live), ...fallback, ...customDocs],
- overrides,
- });
+ const [builtInDocs, customDocs] = await Promise.all([
+ builtInRoleNames.length ? deps.getRoleDocsByBuiltInKeys(builtInRoleNames) : [],
+ roleIds.length ? deps.getRolesByIds(roleIds) : [],
+ ]);
+ return resolveBuiltInPerms({ builtInRoleNames, builtInDocs, customDocs, overrides });
}
diff --git a/apps/beacon/src/claims-sync/role-doc.ts b/apps/beacon/src/claims-sync/role-doc.ts
index ae5476d1..8a1c8153 100644
--- a/apps/beacon/src/claims-sync/role-doc.ts
+++ b/apps/beacon/src/claims-sync/role-doc.ts
@@ -22,6 +22,15 @@ export function roleDocPermsMalformed(data: DocumentData | undefined): boolean {
return rawPermsFromRoleDoc(data).some((p) => !isValidPermissionCode(p));
}
+/** Beacon's liveness predicate, and deliberately NOT unifiable with backstage's `isLiveRole`
+ * (apps/backstage/src/lib/role-lifecycle.ts) today. The blocker is not the `DocumentData`
+ * import — that is type-only and erases. It is that the two do not compute the same
+ * function: this one is fail-OPEN (`active !== false`, so a missing or non-bool `active`
+ * reads LIVE and keeps minting the doc's perms) while `isLiveRole` is fail-CLOSED
+ * (`active === true`). Collapsing them either revokes perms from docs that hold them today
+ * or offers assignment of docs that mint nothing — a behaviour change nobody has scoped.
+ * The divergence and its two directions are written out on `previewEffectivePerms` and in
+ * docs/specs/role-lifecycle.md; firestore.rules now bars authoring the shapes that reach it. */
export function isActiveRoleDoc(data: DocumentData | undefined): boolean {
return data?.active !== false && (data?.deletedAt === null || data?.deletedAt === undefined);
}
diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts
index dd6e74a0..48fd7220 100644
--- a/apps/beacon/src/claims-sync/sync.test.ts
+++ b/apps/beacon/src/claims-sync/sync.test.ts
@@ -126,6 +126,70 @@ describe("syncMemberClaims", () => {
});
});
+ it("BLOCKING: positive-and-inert — a grant-free cargo from a NON-Admin assigner mints nothing", async () => {
+ // The members-positions lane (firestore.rules' fourth members update arm, keyed on
+ // update:Position) lets an org-chart editor who is NOT an Admin assign GRANT-FREE cargos.
+ // docs/specs/position-assignment-lane.md accepts that lane as a PUBLIC publication
+ // authority — a grant-free JDL dirección puts the member on the world-readable Directiva —
+ // precisely BECAUSE it confers no claim. That was inferred from reading sync.ts; this test
+ // asserts it, both halves:
+ //
+ // 1. INERT — the computed claims equal what the member already had, so setClaims is
+ // never even called. Nothing leaks out of the cargo and nothing is carried in.
+ // 2. The assigner is NEVER LOOKED UP. This is the falsifiable half and the load-bearing
+ // one: `resolveTrustedGrants` returns at the `grants.length === 0` early return
+ // BEFORE the assignedBy-holds-Admin gate is consulted. So the inertness does not
+ // depend on the assigner being a non-Admin — it holds for ANY assigner, which is what
+ // makes "this lane cannot mint" a property of the lane rather than of the fixture.
+ // Delete that early return and this assertion goes red while the claims stay equal.
+ const grantFree = { grants: [] as Role[] };
+ const assignerLookups: string[] = [];
+ const { deps, writes } = fakeDeps({
+ positions: { "pos-jdl-dir": grantFree, "pos-pres": { grants: ["Admin"] } },
+ // The org-chart editor holds update:Position via a CUSTOM role — no built-in carries it
+ // — and is emphatically not an Admin.
+ userRoles: { "orgchart-uid": ["Member"] },
+ existing: { "target-uid": { roles: ["Member"], perms: permsFor(["Member"]) } },
+ });
+ const spied: ClaimsSyncDeps = {
+ ...deps,
+ getUserRoles: async (uid) => {
+ assignerLookups.push(uid);
+ return deps.getUserRoles(uid);
+ },
+ };
+ await syncMemberClaims(
+ spied,
+ {
+ uid: "target-uid",
+ positions: {
+ "2026": { cargoId: "pos-jdl-dir", comisionIds: [], assignedBy: "orgchart-uid" },
+ },
+ },
+ "2026",
+ );
+ // 1. Byte-identical to the claims the member already held → the idempotent no-op path.
+ expect(writes).toEqual({});
+ // 2. The trust gate was never reached.
+ expect(assignerLookups).toEqual([]);
+
+ // CONTRAST with the power-cargo half, same non-Admin assigner, same member. Here the
+ // early return does NOT fire, so the assigner IS looked up — and only then dropped for
+ // not holding Admin. Seen side by side: grant-free short-circuits, power-conferring
+ // is adjudicated.
+ await syncMemberClaims(
+ spied,
+ {
+ uid: "target-uid",
+ positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "orgchart-uid" } },
+ },
+ "2026",
+ );
+ expect(assignerLookups).toEqual(["orgchart-uid"]);
+ // Same end state — but reached by the gate, not by the early return.
+ expect(writes).toEqual({});
+ });
+
it("drops power grants when assignedBy is missing (legacy doc)", async () => {
const { deps, writes } = fakeDeps({
positions: { "pos-pres": { grants: ["Admin"] } },
diff --git a/apps/beacon/src/showcase/project-board.test.ts b/apps/beacon/src/showcase/project-board.test.ts
index 8815a69b..bba88ea5 100644
--- a/apps/beacon/src/showcase/project-board.test.ts
+++ b/apps/beacon/src/showcase/project-board.test.ts
@@ -141,6 +141,17 @@ describe("projectBoard", () => {
expect(project("m1", { ...member, active: false, deletedAt: {} }, celCargo)).toBeNull();
});
+ it("drops a member whose active flag is not the boolean true (fail-closed)", () => {
+ // The direction that matters. This gate used to read `active === false`, so the ONE
+ // shape the rules could not reject — a string "false", or a legacy doc with no `active`
+ // at all — was invisible in backstage (the zod doc schema drops it) and PUBLISHED on
+ // the world-readable Directiva. `!== true` matches projectAlly, which had it right.
+ expect(project("m1", { ...member, active: "false" }, celCargo)).toBeNull();
+ expect(project("m1", { ...member, active: "true" }, celCargo)).toBeNull();
+ expect(project("m1", { ...member, active: 1 }, celCargo)).toBeNull();
+ expect(project("m1", { ...member, active: undefined }, celCargo)).toBeNull();
+ });
+
it("drops a cargo with an empty title", () => {
expect(project("m1", member, { category: "CEL", title: "" })).toBeNull();
});
diff --git a/apps/beacon/src/showcase/project-board.ts b/apps/beacon/src/showcase/project-board.ts
index 575cf798..ffb1bb5a 100644
--- a/apps/beacon/src/showcase/project-board.ts
+++ b/apps/beacon/src/showcase/project-board.ts
@@ -72,7 +72,13 @@ export function projectBoard(
// would be publication with an unreachable opt-out — the self lane keys on
// `resource.data.uid == request.auth.uid`. Publish only members who can revoke it.
if (typeof member.uid !== "string" || member.uid.length === 0) return null;
- if (member.deletedAt != null || member.active === false) return null;
+ // `!== true`, not `=== false` — the fail-CLOSED direction, matching projectAlly. A member
+ // doc holding a non-bool `active` (the string "false"), or no `active` at all, is dropped
+ // by memberDocSchema and so is invisible everywhere in backstage; under the old test it
+ // was nonetheless published here, on the world-readable Directiva. firestore.rules now
+ // refuses to create or update such a doc, but only this line un-publishes the ones that
+ // already exist.
+ if (member.deletedAt != null || member.active !== true) return null;
// Shared with the internal birthday lists — one allowlist, so a status added later
// can't surface itself on either.
if (!isSurfaceableStatus(member.status)) return null;
diff --git a/docs/data-models.md b/docs/data-models.md
index ae45d189..0735b890 100644
--- a/docs/data-models.md
+++ b/docs/data-models.md
@@ -185,7 +185,8 @@ hard-coded role checks. `firestore.rules` is the source of truth; summary:
> **Positions-update constraints (all tiers):** any write touching `positions` must satisfy `positionsAssignmentSafe()`:
> - Only the **current term key** (`string(request.time.year())`) may change — past terms are read-only for all client writes (admin-SDK/console for historical corrections).
> - `positions..assignedBy` must equal `request.auth.uid` (writer stamps themselves).
-> - Non-Admin writers may only assign a cargo whose `grants` array is empty (no power conferral) **and** may only displace a cargo whose `grants` array is empty (`currentCargoGrantsEmpty()`) — otherwise a `manage:Member` holder could overwrite a president's cargo with a grant-free one and strip the Admin claim. Admin is unrestricted.
+> - Non-Admin writers may only assign a cargo that is both **grant-free and not `CEL`** (`cargoAssignableByNonAdmin()`) **and** may only displace a cargo whose `grants` array is empty (`currentCargoGrantsEmpty()`) — otherwise a `manage:Member` holder could overwrite a president's cargo with a grant-free one and strip the Admin claim. The `CEL` half is the PUBLICATION boundary: `boardGroupFromCategory` publishes CEL and JDL alike and `boardRank` puts `Presidente` at 0, so a grant-free CEL cargo would seat its holder at the head of the world-readable Directiva. Grant-free JDL direcciones stay assignable — that exposure is the feature. Admin is unrestricted.
+> - The **create** arm (`createPositionsSafe()`) applies the same `cargoAssignableByNonAdmin()` to any `positions` it writes; it cannot apply `currentCargoGrantsEmpty()` (no prior `resource`) and has no old side to protect.
> - These constraints close the "ride-along" attack where a non-Admin sneaks a power cargo under a different term key in the same write.
> - Comisión `grants` are not loop-checkable in rules — instead the invariant is structural: comisiones can never hold grants (`comisionGrantsEmpty()` on positions writes) and claims-sync ignores `comisionIds` for grants entirely.
>
diff --git a/docs/firebase-setup.md b/docs/firebase-setup.md
index 771af9be..eb35bf2d 100644
--- a/docs/firebase-setup.md
+++ b/docs/firebase-setup.md
@@ -348,6 +348,79 @@ is re-written each run.
For wiping production data, see the runbook at `tools/scripts/wipe-prod.md`.
+## Soft-Delete Shape Audit (pre-deploy gate)
+
+`pnpm audit:soft-delete-shapes` scans `members`, `positions` and `allies` for docs
+whose soft-delete pair is malformed — the shapes the well-formedness rules make
+admin-SDK-only to edit (owner-op 4 of `docs/specs/position-assignment-lane.md`,
+BLOCKING before those rules deploy). It exits non-zero when anything is found, so
+it can gate a deploy. Read-only by default; `--repair` fixes only the unambiguous
+shapes (a missing `deletedAt` becomes `null`; a missing `active` on a never-deleted
+doc becomes `true`) and refuses to guess at the rest:
+
+| Shape | `--repair` |
+|---|---|
+| `deletedAt` missing | → `null` |
+| `active` missing, `deletedAt` null/missing | → `true` |
+| `active` present but not a bool (the string `"false"`, or `null`) | refused — a human decides |
+| `active` missing, `deletedAt` set | refused — the two fields disagree |
+| `active: true` **with** a non-null `deletedAt` (the **ghost**) | refused — same disagreement. This is the one malformed shape that is client-reachable and that `memberDocSchema` accepts, so backstage lists the doc as an ordinary live member while every `deletedAt`-aware reader treats it as gone |
+| `deletedAt` present, non-null, not a Timestamp (an ISO string, a number) | refused — the zod schemas reject it and the rules pin it immutable, so the doc is invisible and unwritable at once |
+
+Repair is all-or-nothing per doc: when one field is ambiguous the unambiguous fix
+is withheld too, so whoever resolves it sees the shape the audit reported.
+
+Exit codes are distinct on purpose: **1** = the run completed and found malformed
+docs (the gate); **2** = the run did *not* complete (a per-doc read/write failed,
+or a production `--repair` was not confirmed). A crash must not read as a clean
+gate failure.
+
+For a malformed **member** it also reports whether a `boardShowcase` row is
+currently published — but `--repair` is **not** a takedown, and removes no public
+row:
+
+- A **repaired** doc declares the member live (`active: true` / `deletedAt: null`),
+ so the re-fired `onBoardMemberWritten` re-publishes it. The row correctly stays up.
+- A **refused** doc (the non-bool `active`, the shape that was fail-open published)
+ is not written at all, so no trigger fires and the row **stays published**. Two
+ hand remedies, both printed per doc: a Firebase console edit of `active`, or an
+ Admin `publicProfile: false` write — the members takedown arm in `firestore.rules`
+ deliberately skips `softDeleteSafe()` and stays open on exactly these docs (pinned
+ by a rules test). Backstage will not list such a member (`memberDocSchema` drops
+ it), so make that write from the console or directly.
+
+`--repair` **can add one**, though, and that direction is opt-in. Writing
+`active: true` un-blocks the fail-closed `projectBoard` gate, so a member who also
+carries `publicProfile: true` (the stamped org-wide default), a `uid`, a pinned
+portrait and a current-term CEL/JDL cargo is **newly published** by the re-fired
+trigger — publication as a side effect of a shape fix. Each such member gets a
+`WILL PUBLISH:` line and their repair is **withheld** (counted separately from the
+ambiguous refusals) unless `--allow-publish` is passed. The alternative to passing
+it is to set `publicProfile: false` on the member first — the opt-out they never
+exercised — and re-run. The forecast fails safe: a gate it cannot settle (an
+unreadable `positions` or `boardShowcase` doc) is named in the output and the
+member is announced anyway, never quietly repaired.
+
+Ids that need a hand fix, and every PUBLISHED / WILL PUBLISH / UNKNOWN-publication
+line, are never truncated; only the benign repairable listing is capped.
+
+```bash
+gcloud auth application-default login
+pnpm audit:soft-delete-shapes # count + gate
+pnpm audit:soft-delete-shapes --repair # fix the unambiguous, report the rest
+pnpm audit:soft-delete-shapes --repair --allow-publish # …incl. the repairs that publish
+```
+
+Same credential model as `seed:production`; point it at the emulator by setting
+`FIRESTORE_EMULATOR_HOST` first. A **production** `--repair` writes to members and,
+through the trigger, re-projects the world-readable Directiva, so it demands an
+explicit confirmation — type `repair-production-shapes` at the prompt, or pass
+`--confirm=repair-production-shapes` in a non-interactive shell. Adding
+`--allow-publish` widens what the run may do, so it widens the token: the string
+becomes `repair-production-shapes-and-publish`, and the plain one is then rejected.
+The typed string names the consequence, rather than the one flag that can ADD public
+exposure being the one the prompt is silent about. The emulator needs no confirmation.
+
## Correo de invitación
When an admin provisions login access for a member, the app calls Firebase Auth's
diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md
new file mode 100644
index 00000000..db7ffdb8
--- /dev/null
+++ b/docs/specs/position-assignment-lane.md
@@ -0,0 +1,795 @@
+# Position-assignment lane + role-lifecycle follow-through
+
+**Status:** design (revised after two adversarial passes — see Review corrections)
+**Branch:** `feat/position-assignment-lane`
+**Predecessors:** PR 1 (#216 role display), PR 2 (#219 built-in role set), PR 3 (#221 role lifecycle)
+
+The fourth and last PR of the role-management overhaul, plus three residuals its
+predecessors documented and deferred. Four changes, one branch, because three of the four
+edit `firestore.rules` and share one emulator suite.
+
+| # | Change | Surface |
+|---|---|---|
+| A | Members-positions write lane keyed on `update:Position` | rules, backstage |
+| B | Well-formedness on the four remaining soft-delete lanes | rules, beacon showcase |
+| C | The three-way built-in resolution extracted to `packages/auth` | packages/auth, beacon, backstage |
+| D | `canCurateFeatured` migrated from a role name to a permission | rules, types, beacon seed, backstage |
+
+---
+
+## A. The members-positions write lane
+
+### Problem
+
+PR 2 withdrew `manage:Position` from `ExecutiveCommittee` and deleted the dedicated
+positions-only rule that went with it. Cargo assignment on `members/{id}.positions` became
+Admin-only. `memberEditMode` (`apps/backstage/src/features/members/lib/member-edit-gate.ts:5`)
+still declares a `"positions"` arm in its return union but its body
+(`member-edit-gate.ts:15-17`) never returns it, so `member-profile-page.tsx:168-184` is
+unreachable code, and its doc comment names this PR as the thing that brings it back.
+
+### What blocks a non-Admin today
+
+Not `positionsAssignmentSafe()`. The institutional update arm (`firestore.rules:302-312`)
+leads with `canDo('update', 'Member')`, so a principal holding only `update:Position` fails
+at the **first** conjunct and never reaches the positions gate at all.
+
+### Design
+
+A **new, fourth `allow update` arm** on `match /members/{memberId}`, rather than relaxing
+the leading `canDo('update','Member')` on the existing one. Rules arms OR together, so a new
+arm is purely additive and its scope is auditable in isolation.
+
+```
+// Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on
+// update:Position — the same capability that governs the positions CATALOG — and
+// confined to the positions map. The power-cargo restriction is NOT relaxed:
+// positionsAssignmentSafe()'s non-Admin branch still demands cargoAssignableByNonAdmin()
+// && currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos
+// only, on BOTH sides of a swap.
+//
+// memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated
+// anyway: if hasOnly is ever widened — the obvious future edit is adding a second key —
+// the claims-mint boundary, publication consent and the points ledger must not vanish
+// with it. Ordered cheapest-first: positionsAssignmentSafe() is the only conjunct that
+// can issue cross-document get()s, so every earlier denial costs zero billed reads.
+allow update: if canDo('update', 'Position')
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions'])
+ && memberWriteInvariants()
+ && positionsAssignmentSafe();
+```
+
+`memberWriteInvariants()` is `unchanged('totalPoints') && !touched('uid') &&
+!touched('publicProfile') && updatePermissionAssignmentSafe() && softDeleteSafe()`, shared
+with the institutional arm — the five conjuncts were duplicated across both until
+`/simplify` extracted them. The self-service arm must touch `publicProfile` and the Admin
+takedown arm deliberately skips `softDeleteSafe()`, so neither calls it.
+
+`positionsAssignmentSafe()` is reused as-is by this lane — the arm adds no cargo condition of
+its own. Its `hasAnyRole(['Admin'])` disjunct keeps Admin unrestricted; its
+`cargoAssignableByNonAdmin() && currentCargoGrantsEmpty()` disjunct is exactly the
+assignable-only semantic this lane wants. `currentCargoGrantsEmpty()` must stay inside the
+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.
+
+`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`.
+
+**What `softDeleteSafe()` in this arm does and does not do.** It does not stop a write onto a
+soft-deleted member: it blocks *resurrection*, and `unchanged('active')` is satisfied
+whenever the write does not touch `active`, which `hasOnly` guarantees. So this lane can
+assign a cargo to a soft-deleted member — the same as the existing institutional arm, so not
+a regression. What it does do, once change B lands, is refuse a write onto a **malformed**
+member doc.
+
+### Create keeps the same new-side predicate, and only that
+
+`createPositionsSafe()` cannot call `currentCargoGrantsEmpty()` — a create has no prior
+`resource` — and there is no old side to protect on a create. It **does** call
+`cargoAssignableByNonAdmin()`, the same new-side predicate as the update arm, because that
+one reads `request.resource.data` only (through `assignedTerm()`) and is therefore
+create-safe. Keeping the arms on one predicate is not symmetry for its own sake: a create arm
+that asked only about `grants` let a `manage:Member` holder — `manage:Member` satisfies
+`canDo('create','Member')` — mint a member **born** holding an Admin-minted grant-free CEL
+cargo, self-stamped. `uid` is forbidden at create, so that doc is unpublished at birth; but
+`onMemberCreated` stamps the `publicProfile` default, the creator picks `name` and
+`profilePicture`, and one routine Admin `provisionMemberLogin` later supplies the `uid` —
+after which `projectBoard` publishes an attacker-composed account at board rank 0 as
+Presidente, with a single Admin action in the chain taken for an unrelated reason.
+
+The org-chart lane itself still may not create members: creation stays
+`canDo('create','Member')`, which an `update:Position`-only principal does not hold.
+
+### This lane is a PUBLIC publication authority — stated, and deliberately accepted
+
+`positions..cargoId` is the sole input to `boardShowcase`, the world-readable public
+Directiva (`firestore.rules:706-709`, `allow read: if true`; projection at
+`apps/beacon/src/showcase/project-board.ts:63-97`). `boardGroupFromCategory`
+(`packages/types/src/engine/board-public.ts:39-41`) publishes both `CEL` and `JDL`. **JDL
+direcciones are hand-created in `/positions` and normally carry `grants: []`** — so an
+`update:Position` holder can assign a grant-free board cargo to any member, **including
+themselves**, and put that person on the public site under that title. Combined with the
+self-service arm (which they own for their own `publicProfile` and `profilePicture`), that is
+self-publication with no Admin action.
+
+**The exposure is scoped to JDL, and that scoping is now ENFORCED rather than assumed.** An
+earlier draft argued CEL was blocked because "every seeded CEL cargo carries non-empty
+`grants`" — a claim about DATA, with nothing holding it true. The create arm deliberately
+still lets an **Admin** mint a grant-free CEL cargo (this branch's own rules suite creates
+exactly one, `mint_cel_admin`), and from that moment the claim is false: any `update:Position`
+holder could self-assign it, at CEL rank, `Presidente` included — `boardRank` maps that title
+to 0, so they would head the public Directiva. The non-Admin branch therefore reads
+`category` off the cargo doc it was **already** fetching for the grants check
+(`cargoAssignableByNonAdmin()` — one `get()`, two questions) and refuses `CEL` whatever its
+grants. **Both member lanes ask it**: `positionsAssignmentSafe()` on update and
+`createPositionsSafe()` on create — see "Create keeps the same new-side predicate" above for
+what splitting them cost. Cost: an Admin who mints a ceremonial grant-free CEL cargo must
+also assign it — consistent with the positions create arm, which already made minting one
+Admin-only. Five rules tests pin it (non-Admin CEL denied on the org-chart lane, grant-free
+JDL still allowed, Admin unaffected, the institutional update lane, and the create lane) and
+the conjunct is mutation-proven.
+The REPLACED cargo (`currentCargoGrantsEmpty()`) is deliberately not category-gated: clearing
+a member off a grant-free CEL cargo only reduces exposure.
+
+The JDL half is **accepted as the feature**, not an oversight: assigning a JDL dirección *is* the
+darkened surface PR 2 created, and appearing on the Directiva is the correct product outcome
+of holding that cargo. It is not privilege escalation — no claim is minted, because
+`resolveTrustedGrants` returns at `sync.ts:67` on `grants.length === 0`, before the
+`assignedBy`-holds-Admin gate is even consulted.
+
+Two things follow, and both are in scope:
+
+1. **The catalog's `category` must stop being freely writable by a non-Admin.** Today the
+ positions update arm (`firestore.rules:358-361`) pins only `grants` for a non-Admin, so an
+ `update:Position` holder could retitle a grant-free Comisión to `title: "Presidente",
+ category: "CEL"` (→ `boardRank` 0) and assign it to themselves. `unchanged('category')`
+ joins `unchanged('grants')` in the non-Admin branch. Defensible on its own: `category`
+ decides board group, board rank, and whether `comisionGrantsEmpty()` applies.
+2. **The exposure is asserted, not left incidental** — a rules test pins that an
+ `update:Position` holder can put a member on the board, so a future reader sees it was a
+ decision.
+
+Owner-op 1 states this in the same words, so nobody grants the capability without knowing
+it publishes.
+
+### No built-in role gains the capability, and the hand-off must be a CUSTOM role
+
+`update:Position` is held by **no** entry in `BUILT_IN_ROLE_PERMS` — only Admin satisfies it,
+via `manage:all`. This PR does not change that: every existing principal's authority stays
+byte-identical, so no test asserting a current denial has to be weakened.
+
+**The hand-off must be a custom role doc, not an edit to the `ExecutiveCommittee` built-in.**
+`planRolePermReseed` (`apps/beacon/src/recompute-claims.ts:205-238`) walks **every** built-in
+role doc and rewrites `permissions` to the `BUILT_IN_ROLE_PERMS` snapshot whenever they
+differ — so an `update:Position` added to the CEL built-in from `/permisos` is **silently
+stripped by the next reseed**, which owner-op 2 of this very spec mandates. A custom role
+(`builtIn: false`) is skipped by the reseed as `"not-built-in"`. Owner-op 1 says so
+explicitly.
+
+### Client mirror
+
+```ts
+export function memberEditMode(gate: Pick): MemberEditMode {
+ if (gate.can("update", "Member")) return "full";
+ if (gate.can("update", "Position")) return "positions";
+ return "none";
+}
+```
+
+Order matters and is asserted: a principal holding both gets `"full"`, so the two editors
+never both render.
+
+The cargo mirror in `member-positions-form.tsx` and `member-form.tsx` was keyed on
+`p.grants.length === 0` alone, which was correct only while grants were the whole boundary.
+Both now call one shared `cargoAssignableByNonAdmin()`
+(`features/members/lib/assignable-cargo.ts`), the client twin of the rules predicate, for the
+option list **and** the lock — a grant-free CEL cargo is otherwise offered to a non-Admin who
+then eats a generic 403, and a member already seated on one has no lock at all even though
+every save re-stamps that `cargoId` and is denied. One function, not the same condition typed
+into two forms. `canAssignPowerGrants` (`use-can.ts:55`) stays a role check, mirroring
+`hasAnyRole(['Admin'])` in the rule.
+
+Two stale comments must move with the code, or they read as guarantees:
+`MemberRepository.setPositions` (`member-repository.ts:81-85`) says the write "goes through
+the ordinary `update:Member` lane… not about a separate allow-rule" — false once this arm
+exists; and `effectiveRoles` (`member-permissions.ts:3-4`) claims it "mirrors exactly what
+the claims-sync trigger will mint", which omits the `assignedBy`-holds-Admin gate. The second
+is pre-existing and only needs correcting, not re-implementing.
+
+### Nav parity fix
+
+`nav-config.ts:130` re-admits a perms-only principal to `/positions` via
+`orCan: { action: "manage", subject: "Position" }`, but the catalog's own rules accept
+`canDo('update','Position')`. `orCan` moves to `update` — `canDo` treats `manage:Position` as
+satisfying `update:Position`, so this widens nothing the rules did not already allow
+(guardrail #6).
+
+### Tests that assert today's denial — rewritten, never deleted
+
+| Test | Today | After |
+|---|---|---|
+| `member-edit-gate.test.ts:39` | `manage:Position` → `"none"` | → `"positions"`. CASL `manage` satisfies `can("update", …)` (`ability.ts:34-39`), exactly as `canDo` does in the rules |
+| `member-edit-gate.test.ts:40` | `read:Position` → `"none"` | unchanged |
+| `member-edit-gate.test.ts:43` | `{roles:["Member"], perms:["manage:Position"]}` → `"none"` | → `"positions"` |
+| `member-edit-gate.test.ts:12` | CEL → `"none"` | unchanged — CEL holds no `update:Position` in the seed |
+| `rules.test.ts:2164` | CEL denied any cargo | unchanged, plus a new `update:Position` principal that is allowed |
+| `nav-config.test.ts:170` | admits `manage:Position` | passes, plus an `update:Position` case |
+
+New rules tests. The `describe` at `rules.test.ts:2163` ends with `:2281` "allows Admin to
+assign a power-conferring cargo", which is last **on purpose** — the suite seeds once and
+never resets. Insert before it, and mind the fixtures: at that point `members/m1` holds
+`pos_soft` (grant-free, set at `:2173`, re-set at `:2262`), **not** a power cargo, so the
+old-side test must target `members/m_powercargo` (`:505-512`), which the C1 describe at
+`:2290` also uses as `assertFails` — safe to share.
+
+- allows an `update:Position`-only principal to assign a grant-free cargo, self-stamped
+- **positive-and-inert**: after that assignment, the computed claims are unchanged — asserts
+ "cannot mint" rather than inferring it
+- BLOCKING: denies that principal assigning a power-conferring cargo (new side)
+- BLOCKING: denies that principal replacing `m_powercargo`'s cargo with a grant-free one (old side)
+- BLOCKING: denies that principal touching any non-`positions` field in the same write
+- denies a forged `assignedBy`; denies a non-current-term write; denies creating a member
+- denies a whole-map replacement that drops the current term key, and a `deleteField()` on
+ `positions` — both currently deny via `assignedBySelf()`, an incidental mechanism that a
+ future refactor could remove unnoticed
+- pins the accepted exposure: an `update:Position` holder can assign a grant-free JDL board cargo
+- BLOCKING: denies that same principal a grant-free **CEL** cargo, with the JDL allow adjacent
+ so the denial cannot pass for the wrong reason; plus the Admin allow, and the institutional
+ (`manage:Member`) lane held to the same boundary
+- denies a non-Admin changing a position's `category` (the new catalog conjunct)
+- allows a non-Admin retitling a legacy cargo with **no `category` key** — the create and
+ update arms now ask the same `!boardSurfacingCategory()` question
+
+Every new create-shaped test carries `active`/`deletedAt`, or change B makes it vacuous.
+
+### Residual: the term-rollover window
+
+`currentCargoGrantsEmpty()` reads `resource.data.positions[currentTermKey()].cargoId`. At the
+UTC-year rollover a victim whose Admin comes from `positions["2026"]` has an empty
+`positions["2027"]` slot, so the guard hits its `prior == null` short-circuit and an
+`update:Position` holder can write a grant-free cargo into the new term — and
+`syncMemberClaims`, which resolves from the same current-year key, recomputes
+`roles: ['Member']`. **Pre-existing** — it falls to any `manage:Member` holder through the
+institutional arm today, and A does not create it — but the guard is not unconditional, and
+this spec says so rather than repeating "closes the strip-Admin hole" without qualification.
+A test pins the shape as ALLOWED, named as an accepted hole so a future reader sees it was
+measured rather than missed — an earlier draft claimed that test existed when it did not.
+A does not create the hole, but it does widen the principal set: before A this needed
+`manage:Member`; after A a role whose entire authority is `update:Position` reaches it.
+Owner-op 1 states that, so nobody grants the capability on the strength of an unqualified
+"never a power cargo". Fixing it properly means resolving liveness across terms and is its
+own pass.
+
+**The claim strip is only half of it: the same write re-fires the projection.**
+`onBoardMemberWritten` resolves the cargo through `currentCargoId(member, termKey)` at
+projection time, so the new-term slot is what the public Directiva reads the moment it is
+written. Post-rollover, that same short-circuit therefore lets an `update:Position` holder
+move a sitting president — whose CEL cargo carries `grants`, and who is therefore untouchable
+in-term on BOTH sides (`cargoAssignableByNonAdmin()` refuses CEL; `currentCargoGrantsEmpty()`
+refuses displacing a granted cargo) — onto a grant-free JDL dirección, demoting them from
+public rank 0 to a dirección on the world-readable board. That is a **public-board mutation
+they cannot make in-term at all**, not just a claims change, and it is the half a reader
+tracking only the Admin claim would miss. Same window, same fix, same pass.
+
+---
+
+## B. Well-formedness on the remaining soft-delete lanes
+
+### Problem
+
+`roleLifecycleSafe()` enforces that `active` is present and a bool and `deletedAt` is
+present. The four `softDeleteSafe()` lanes — `members` ×2, `positions`, `allies` — enforce
+neither. The hazard is concrete: a member doc carrying the **string** `"false"` in `active`
+passes `softDeleteSafe()`, is dropped by `memberDocSchema` so it is invisible throughout
+backstage, and is **published to the public Directiva**, because
+`apps/beacon/src/showcase/project-board.ts:75` tests `member.active === false` — the
+fail-open direction. `project-ally.ts:22` uses the fail-closed `active !== true`.
+
+### Design — three parts
+
+**B1. The check goes inside `softDeleteSafe()`, not onto the arms.**
+
+```
+function softDeleteSafe() {
+ let d = request.resource.data;
+ return ('active' in d) && (d.active is bool) && ('deletedAt' in d)
+ && (resource.data.deletedAt == null || unchanged('deletedAt'))
+ && (resource.data.active == true || unchanged('active'));
+}
+```
+
+Inside the helper, because the members Admin-takedown arm (`firestore.rules:320-322`)
+deliberately does **not** call it. That arm is the only rules-level path that can unpublish
+exactly the malformed member this change is about; putting the requirement at arm level would
+remove the remedy along with the disease.
+
+The one-way semantics below the new lines are untouched. Two in-file comments assert
+"softDeleteSafe itself must not change" (`firestore.rules:371-375`, `rules.test.ts:2529-2533`);
+both are updated to say what changed and what did not.
+
+**This is a real new denial, not a reclassification — corrected from the first draft.** The
+first draft argued the missing-field case already denies, because `softDeleteSafe` reads
+`resource.data.active` bare and an absent-key read errors. That is **false**: rules are CEL,
+whose `||` absorbs errors (`error || true == true`), and the bare read sits on the left of a
+`||` whose right side is `unchanged(field)` — which uses `.get(…, null)` and returns
+`null == null → true` on an absent key. Measured against the emulator: a doc missing
+`active`/`deletedAt` is **editable today** and **denied** after B1. The same wrong
+generalization is written into `firestore.rules:396` and is corrected in this PR.
+
+So B1 moves legacy `members`/`positions`/`allies` docs missing either field, or holding a
+non-bool `active`, from *client-editable* to *admin-SDK-only*. Owner-op 4 is therefore a
+**blocking pre-deploy audit**, not a nice-to-know: the count must be known, and ideally zero,
+before the rules ship. There is no rules-layer repair — the only fix is the console or the
+admin SDK.
+
+**What is genuinely safe** is the merge half: on an update `request.resource.data` is the
+**merged** document, so a repository writing neither field still satisfies the check when the
+stored doc is well-formed. `RoleRepository.update` is the live proof under the identical rule
+(`rules.test.ts:2482-2495`).
+
+**B2. The create arms are constrained to match.** `members` (`:287-293`), `positions`
+(`:355-357`) and `allies` (`:547`) require nothing of the two fields, so they mint the exact
+docs B1 then refuses to update. Each gains, mirroring the roles create arm (`:527-529`):
+
+```
+&& request.resource.data.get('active', false) == true
+&& ('deletedAt' in request.resource.data)
+&& request.resource.data.deletedAt == null
+```
+
+Every client creator already writes both. The test cost is larger than the first draft said,
+and it is the interesting part of B2:
+
+- **Three `assertSucceeds` go red** and must carry the fields: `rules.test.ts:608`, `:708`,
+ `:717`.
+- **Twelve `assertFails` become vacuous** — they would pass for the newly-added reason rather
+ than the one they are named for: members `:613, :625, :634, :671, :676, :681, :690, :699,
+ :740, :748, :756` and positions `:1799`. `:681` and `:699` are escalation guards, and
+ `:613-624` states in a comment that it *isolates* the `publicProfile` create guard — a claim
+ B2 falsifies unless the payload is repaired. All twelve carry `active`/`deletedAt` so they
+ keep failing for their own reason. This repo has already cleaned up 14 tautological tests
+ once; this is that class.
+- One new test pins that the bare shape is now denied.
+
+**B3. `project-board.ts` flips to the fail-closed direction.**
+
+```ts
+if (member.deletedAt != null || member.active !== true) return null;
+```
+
+B1 and B2 stop new malformed docs; B3 is what stops an existing one being published. It also
+ends the split with `project-ally.ts`.
+
+**B3 alone does not reach the rows already published, and neither does the audit script —
+corrected twice after review.** An earlier draft said "only B3 removes the existing public
+exposure", which overstates its reach: `onBoardMemberWritten` fires only on a `members/{id}`
+write, so a member already on `boardShowcase` under the fail-open predicate stays there until
+their doc is next written. The correction to *that* correction — "`--repair` re-fires the
+trigger and deletes the row" — is also false, in both directions:
+
+- A doc `--repair` can fix declares the member **live** (`deletedAt: null`, `active: true`).
+ The re-fired trigger re-publishes it. The row correctly stays up; nothing is taken down.
+- The doc that is actually exposed — a non-bool `active`, the string `"false"` — is
+ **refused**, because coercing it is a human call. Nothing is written, so no trigger fires,
+ and the row survives.
+
+So the true division of labour is: **B3 stops the next publication, the script detects and
+gates, and the existing row comes down by hand.** The script prints the two remedies per
+exposed doc: a Firebase console edit of `active`, or an Admin `publicProfile: false` write —
+which the members takedown arm permits precisely because it does not call `softDeleteSafe()`
+(B1's whole reason for living inside the helper; a rules test pins the arm open on a
+malformed member). Neither is reachable from backstage, because `memberDocSchema` drops the
+doc from every list. That is why owner-op 4 is blocking: the script's value is a complete,
+untruncated worklist plus a non-zero exit, not an automatic fix.
+
+### Not in scope
+
+The **coupling** half of `roleLifecycleSafe()` (`active == true ⟹ deletedAt == null`, plus
+the `deletedAt == request.time` stamp) is not generalized. It would deny every subsequent
+edit to an existing ghost doc (`active: true` + non-null `deletedAt`) — a shape that passes
+all four zod schemas and therefore *is* listed and editable today — and it would turn three
+`assertSucceeds` soft-delete tests red for using a client `new Date()`. Owed its own pass.
+
+---
+
+## C. One three-way resolution, in `packages/auth`
+
+### Problem
+
+The absent / live / inactive resolution is implemented twice — `resolveMemberPerms`
+(`apps/beacon/src/claims-sync/resolve-member-perms.ts:46-64`) and `previewEffectivePerms`
+(`apps/backstage/src/features/permissions/lib/effective-preview.ts:44-66`). Both already
+import `resolveEffectivePerms` from `@luminova/auth/perms`, so the shared half has a home and
+creates no new dependency edge (`auth → types` is the only edge; `types` references
+`@luminova/auth` nowhere).
+
+### Design
+
+New file `packages/auth/src/built-in-perms.ts`, new export subpath
+`@luminova/auth/built-in-perms` — the package has no root `"."` export, so an unlisted file
+is unresolvable to esbuild and Vite alike. Relative imports carry explicit `.js`; beacon
+typechecks this package through `NodeNext`.
+
+```ts
+export interface BuiltInRoleDoc {
+ readonly permissions: readonly PermissionCode[];
+ readonly builtInKey: Role;
+ /** Precomputed liveness. NEVER the raw `active` field: a doc with `active: true` and a
+ * non-null `deletedAt` is a ghost — covered, contributing nothing. */
+ readonly live: boolean;
+}
+
+export function resolveBuiltInPerms(input: {
+ builtInRoleNames: readonly Role[];
+ builtInDocs: readonly BuiltInRoleDoc[];
+ customDocs: readonly Pick[];
+ overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] };
+}): PermissionCode[];
+```
+
+Synchronous and pure over already-fetched docs. It must not sort or mutate its inputs —
+beacon's graph is deep-frozen. The seed fallback moves **inside** it (that is what
+`builtInRoleNames` is for), so `packages/auth` gains an import of `BUILT_IN_ROLE_PERMS` from
+`@luminova/types/role-definition`.
+
+**What does NOT move.** `isActiveRoleDoc` stays in beacon: it imports `DocumentData` from
+`firebase-admin/firestore`, and `packages/auth` is consumed by the browser bundle and the
+rules test suite. Liveness *derivation* stays per-side; only *consumption* is shared.
+`PERMISSION_CAP` stays out — beacon fail-closes to `perms: []`, backstage blocks Save.
+
+**One divergence is settled, in the tighter direction.** Beacon unions every live doc it is
+handed without checking its `builtInKey` is one the member holds; backstage only collects
+docs for a requested name. In production the beacon query is `where("builtInKey","in",keys)`
+so they cannot differ — but the *functions* do, and the shared one picks backstage's: docs
+whose key is not in `builtInRoleNames` are ignored. Tested directly, since no production path
+produces it. No existing beacon test changes — all five docs in
+`resolve-member-perms.test.ts:24-72` carry a key that is in `builtInRoleNames`.
+
+**One divergence survives, documented not fixed.** A doc the zod schema rejects reads ABSENT
+to backstage (`parseDocs` dropped it) and COVERED to beacon (which sanitizes per-element).
+That is a difference between the two **ports**, not the two resolutions, so extracting the
+resolution cannot close it.
+
+### Callers and the adapters
+
+`resolveMemberPerms` keeps its `deps` fetch orchestration and delegates; its
+`LiveBuiltInRoleDoc` satisfies `BuiltInRoleDoc` with no cast. `previewEffectivePerms` is the
+harder side — its `builtInDocs` is today a union of `{permissions}` (the snapshot fallback,
+with no `builtInKey` and no `live`) and `RoleDefinition` (whose `builtInKey` is `Role | null`).
+Its adapter maps to `{permissions: r.permissions, builtInKey: name, live: isLiveRole(r)}`,
+castless because `name` is already `Role`, and keeps its `r.builtIn && r.builtInKey === name`
+filter.
+
+Both existing suites stay green unchanged — that is the refactor's acceptance criterion —
+plus a new unit suite in `packages/auth`, which has none for this today.
+
+**Cold-worktree caveat.** `turbo.json`'s `test` task has no `dependsOn: ["^build"]`, and
+`packages/auth`'s `exports` map the `import` condition to `./dist/*.js`, so a bare
+`pnpm test` on a fresh worktree cannot resolve the new subpath. Build the packages first, or
+run `pnpm --filter run ci` / `pnpm pr-tests`, which go through `turbo run ci`.
+
+---
+
+## D. `canCurateFeatured` — role name to permission
+
+### Problem
+
+`canCurateFeatured()` (`firestore.rules:176-178`) is `hasAnyRole(['Admin','ProjectManager'])`.
+`computeMemberRoles` is pure over `{trustedGrants, hadScanner}` and reads no role doc, so a
+**deactivated** `ProjectManager` keeps the name in its claim and keeps the authority. It gates
+the only client-writable input to public-site content that is not a cargo assignment:
+`featured` on `projects` and `programs`, which beacon projects to `showcase/{id}` and
+spotlight renders on `/impacto` and the home band.
+
+### The gate
+
+```
+function canCurateFeatured() {
+ return hasAnyRole(['Admin']) || hasPerm('update:Showcase');
+}
+```
+
+**`hasPerm`, not `canDo` — corrected from the first draft.** `canDo` would let `manage:all`
+satisfy the gate, and `manage:all` is reachable as a *perm* without the Admin role: an
+Admin-written custom role doc or a `permissionOverrides.grant` can carry it, since
+`roleShapeValid()` only requires `permissions is list`. Such a principal already satisfies
+`canDo('update','Project')` at `firestore.rules:208` — the **only** thing stopping them from
+setting `featured` today is this role gate, which the file documents as deliberate at
+`:200-202` ("Intentionally role-based, NOT perm-based"). Migrating to `canDo` would silently
+delete that boundary; `nav-equivalence.test.ts:154-163` exists to assert a perm never unlocks
+a role gate, and does not cover this route (`/initiatives` is `kind: "curationOnly"` and the
+implication loop skips it — so a green suite is not coverage here).
+
+`Admin` stays role-keyed. That is consistent with the rest of the file: `Admin` is `locked`
+and undeactivatable, so name-keyed authority for it carries none of the staleness this change
+exists to fix, and the alternative — adding `update:Showcase` to a role whose entire seeded
+permission set is `["manage:all"]` — would misrepresent how Admin works. Only
+`ProjectManager` moves, which is the whole point: deactivating that role now revokes
+curation.
+
+### Vocabulary — a new subject
+
+Codes are a generated cross-product: 6 actions × 13 subjects = 78. No existing code fits.
+`update:Project` / `manage:Project` already satisfy `canDo('update', subject)` at
+`firestore.rules:208`, precisely the disjunct `featuredUpdateSafe()` layers on top of to
+exclude — reusing one makes the gate a tautology and deletes the boundary `rules.test.ts:1968`
+pins.
+
+Add the subject **`"Showcase"`**: 78 → 84. A new *action* would cost 78 → 91 and put a column
+across all subjects into the `/permisos` matrix. The subject is a slight misnomer — what is
+gated is `featured` on `projects`/`programs`, not the beacon-owned `showcase` collection — and
+the doc comment says so.
+
+The other five `*:Showcase` codes gate nothing. That is the pre-existing condition of this
+vocabulary, not a new defect: the matrix renders the full actions × subjects grid, so
+`checkIn:Member` and dozens like it are already assignable and inert. Notably `manage:Showcase`
+is inert **because** the gate uses exact `hasPerm` — there is no second, undocumented path to
+curation. A test pins that.
+
+Blast radius: `SUBJECT_LABELS` (`permission-matrix.ts:25-40`) is
+`Record, string>` and fails to compile until the label is added.
+`ACTION_LABELS` is untouched. `MATRIX_SUBJECTS` and `ASSIGNABLE_CODES` are derived, so the
+code appears in `/permisos` automatically. Cap headroom is ample: largest built-in holds 9,
+the union of all nine holds 20, cap is 30.
+
+### Seed
+
+`ProjectManager` gains `update:Showcase` in `BUILT_IN_ROLE_PERMS`
+(`packages/types/src/role-definition.ts:45-51`) **and** in the hand-mirror
+`tools/scripts/lib/role-seed.mjs:28-34` — `role-definition.mirror.test.ts:16-18` fails
+otherwise. `Admin` needs no edit. No other role can curate today, so no other role gains it.
+
+### The deploy landmine
+
+`BUILT_IN_ROLE_PERMS` is a seed **snapshot**, not the live source: once a role doc exists its
+stored `permissions` win, and `seedBuiltInRoles` is create-only. Editing the constant mints
+nothing in production. If the rules ship first, every current `ProjectManager` loses curation.
+
+**Order: deploy beacon → run `reseedBuiltInRolePerms` → verify the CLAIM → deploy rules →
+deploy hosting.** The new perm is inert until the rules read it, so granting it early is safe
+and the reverse is not.
+
+`reseedBuiltInRolePerms` is update-only and skips `missing`, `locked`, `not-built-in` and
+**`inactive`** docs (`recompute-claims.ts:212-228`). A *missing* `roles/ProjectManager` is
+harmless — the `BUILT_IN_ROLE_PERMS` fallback already carries the code. A *locked* or
+*inactive* one silently drops PM curation, which is why the verification step is the claim and
+not the doc.
+
+### Residual introduced by D
+
+Curation now sits behind `PERMISSION_CAP`'s fail-closed path: a member over 30 effective perms
+gets `perms: []` written while keeping `roles: ['ProjectManager']` (`sync.ts:111-122`). Today
+they still curate via the role gate; after D they cannot. One more surface behind the cap.
+
+### Client mirror
+
+`canFeatureInitiatives` (`use-can.ts:54`) moves from `hasAnyRole(claims, ["Admin","ProjectManager"])`
+to `isAdmin || `, mirroring the rule's two disjuncts
+exactly. Its two consumers (`initiatives-page.tsx:32`, `initiative-detail-page.tsx:60`) are
+unchanged. `isAdmin` and `canAssignPowerGrants` in the same file stay role checks — they guard
+the claims-mint trust anchor.
+
+### Tests
+
+**Sixteen** `rules.test.ts` featured tests, not eight: `:1320, 1323, 1328, 1333, 1338, 1343,
+1348, 1353` and `:1959, 1968, 1977, 1986, 1995, 2003, 2015, 2024`. They keep their names and
+intent; the `as(...)` principals gain the code where they are meant to pass. New:
+
+- BLOCKING: a `ProjectManager` whose role doc is DEACTIVATED cannot set `featured` — the claim
+ keeps the name, the perms do not carry the code. The whole point of D.
+- BLOCKING: `manage:all` as a **perm**, with no Admin role, cannot set `featured` — pins the
+ `hasPerm`-not-`canDo` decision and preserves the `:200-202` boundary
+- BLOCKING: `manage:Showcase` alone cannot set `featured` — pins that the inert codes are inert
+- a custom role holding `update:Showcase` and nothing else can curate
+
+`use-can.test.ts:41-45` needs a **fixture rewrite**, not a relabel: its cases pass
+`{ roles: ["ProjectManager"] }` with no `perms`, and `buildAbility` reads `claims.perms ?? []`,
+so all three assertions go false until the fixtures carry `perms: ["update:Showcase"]`.
+`initiative-form.tsx`'s `canFeature &&` render branch has no test today — one is added.
+
+---
+
+## Owner-ops
+
+1. **Hand cargo assignment to an org-chart editor (optional, data-only).** In `/permisos`,
+ create a **custom** role carrying **both `update:Position` and `read:Member`** and assign
+ it. Both: the nav gates `/members` on `read:Member` and the profile page must read the
+ member doc, so `update:Position` alone delivers a capability the holder cannot reach —
+ pinned by a `nav-config` test. **Do not add the perm to the `ExecutiveCommittee`
+ built-in** — `reseedBuiltInRolePerms` rewrites built-in docs back to the seed snapshot and
+ would strip it without warning.
+
+ Understand what it confers. The holder may assign and clear **grant-free** cargos and
+ comisiones, and may edit the positions catalog except `grants`, `category`, and — on a
+ CEL/JDL cargo — `title`/`titleFemale`. Two consequences to accept before granting it:
+
+ - **They may publish a member, including themselves, to the public Directiva**, because
+ grant-free JDL direcciones are board cargos. The ceiling is JDL and it is a RULE, not a
+ data assumption: `cargoAssignableByNonAdmin()` refuses a `CEL` cargo to a non-Admin
+ whatever its `grants`, so `Presidente` at public rank 0 stays an Admin decision on both
+ ends (minting the cargo and assigning it).
+ - **"Never a power cargo" holds for the CURRENT term only.** `currentCargoGrantsEmpty()`
+ reads `positions[currentTermKey()]`, so between the UTC-year rollover and the victim's
+ next write, a member whose Admin comes from last year's cargo has an empty current-term
+ slot, the guard short-circuits, and this holder can write a grant-free cargo into the new
+ term — which claims-sync resolves to `roles: ['Member']`, stripping the Admin claim.
+ The same write also re-fires the board projection, which reads the current-term cargo:
+ so in that window this holder can also move a sitting **president** off CEL rank 0 onto a
+ JDL dirección on the public Directiva — a public-board change the in-term rules deny them
+ outright on both sides. Pre-existing (it falls to any `manage:Member` holder through the
+ institutional arm) but newly reachable by a role whose entire authority is
+ `update:Position`. See Residuals; the shape is now pinned by a rules test named as an
+ accepted hole.
+2. **Before deploying D's rules:** run `reseedBuiltInRolePerms`, then run `recomputeAllClaims`,
+ then confirm a live `ProjectManager`'s **ID-token claim** carries `update:Showcase`. The
+ reseed's `onRoleWritten` fan-out is unbounded and `retry: false`, so it can strand members;
+ checking the role doc is not sufficient.
+3. **Outstanding from PR 2, still open:** in `/positions`, ADD `Secretary` to the Secretario
+ cargo's grants, THEN remove `Admin`. In that order, or ally management goes dark.
+4. **BLOCKING pre-deploy audit for B.** Count `members`, `positions` and `allies` docs whose
+ `active` is missing or not a bool, or which lack `deletedAt`. Each becomes admin-SDK-only
+ to edit the moment B1 ships, with no UI affordance to repair it — and unlike the first
+ draft's claim, they are editable **today**. Run `pnpm audit:soft-delete-shapes`
+ (`tools/scripts/audit-soft-delete-shapes.mjs`, documented in `docs/firebase-setup.md`):
+ it exits non-zero on any finding so it gates the deploy (exit 1 = findings, exit 2 = the
+ run did not complete), `--repair` fixes the unambiguous shapes through the admin SDK, and
+ it refuses to guess at a non-bool `active`, a **ghost** (`active: true` with a non-null
+ `deletedAt` — client-reachable, and the one malformed shape `memberDocSchema` accepts, so
+ it renders as an ordinary live member) or a **non-Timestamp `deletedAt`**. **`--repair`
+ takes no `boardShowcase` row down** — see B3: a repaired doc re-publishes, and the exposed
+ shape is the refused one. For each malformed member the script reports whether a public row
+ is live and prints the two hand remedies (console `active` edit, or an Admin
+ `publicProfile: false` write through the takedown arm).
+
+ **`--repair` can ADD a public row, and that is opt-in.** Writing `active: true` un-blocks
+ the fail-closed `projectBoard` gate, so a member who also carries `publicProfile: true`
+ (the stamped org-wide default), a `uid`, a pinned portrait and a current-term CEL/JDL cargo
+ is **newly published** by the re-fired trigger — publication as a side effect of a shape
+ fix. Every such member gets a `WILL PUBLISH:` line and their repair is **withheld** (a
+ count of its own, apart from the ambiguous refusals) unless `--allow-publish` is passed.
+ The forecast mirrors `projectBoard` by hand and fails safe: any gate it cannot settle (an
+ unreadable `positions` doc, an unreadable `boardShowcase` doc) is named in the output and
+ the member is still announced, never quietly repaired.
+
+ Clear the reported remainder that way before the rules deploy, or accept a known, counted
+ set. A production `--repair` requires an explicit confirmation.
+
+## Deploy order
+
+**beacon → reseed + recomputeAllClaims → verify claim → rules → hosting.** B3 and D's seed
+live in beacon; D's rules depend on the reseed; A and B are rules-only; the mirrors are
+hosting.
+
+## Review corrections
+
+Recorded because each falsified something this document previously asserted:
+
+1. **CEL `||` absorbs errors**, so B1's "denies nothing legitimate" was wrong — the `in` checks
+ are a genuine new denial on legacy docs. Owner-op 4 upgraded to blocking.
+ `firestore.rules:396` carries the same wrong generalization and is corrected here.
+2. **`planRolePermReseed` strips a `/permisos` grant on a built-in role**, so owner-op 1 must
+ use a custom role — the two owner-ops previously contradicted each other.
+3. **`canDo('update','Showcase')` would hand curation to a `manage:all`-perm principal**, the
+ exact boundary `firestore.rules:200-202` declares deliberate. Changed to `hasPerm`.
+4. **The new lane is a public publication authority** via grant-free JDL board cargos.
+ Accepted deliberately, stated in owner-op 1, pinned by a test, and narrowed by
+ `unchanged('category')` on the catalog arm.
+5. **B2's test cost is 3 reds + 12 vacuous**, not one red.
+6. **D's test cost is 16 rules tests**, not eight, plus a `use-can` fixture rewrite.
+7. **`manage:Position` opens the positions editor too** (CASL `manage` satisfies `update`), so
+ two `member-edit-gate` assertions invert rather than one.
+
+Found by the mandated review round, after the code was written:
+
+8. **`unchanged('category')` was defeated one door over.** Pinning the update arm left the same
+ outcome reachable through CREATE: a non-Admin `create:Position` holder could mint a fresh
+ `{category: 'CEL', title: 'Presidente', grants: []}` cargo — grant-free, so the power-grant
+ check passes — and self-assign it at board rank 0. "Seeded CEL cargos all carry grants" is
+ what made it look blocked; nothing stopped an UNSEEDED one. Closed by
+ `boardSurfacingCategory()` on the create arm.
+9. **`boardRank` reads the TITLE, not the category**, so pinning `category` alone still let a
+ non-Admin retitle Vicepresidente to "Presidente". `title`/`titleFemale` are now pinned on
+ board cargos and left open on comisiones.
+10. **The rules pin had no client mirror**, so `PositionForm` offered a Categoría dropdown whose
+ submission the rules reject with a generic save error — render-then-die. Mirrored in the UI.
+11. **Two tests were order-coupled through one fixture.** An Admin test wrote `category: 'CEL'`
+ onto the doc a BLOCKING denial read, so after it ran the identical non-Admin write became an
+ unchanged echo and was ALLOWED. The denial passed only because vitest runs `it`s in
+ declaration order. Measured by reordering; fixed with a dedicated fixture.
+12. **Claims the branch made about its own tests were false**: the rollover test and the
+ "positive-and-inert" claims-mint test were both described as existing and did not. Both
+ written.
+13. **B3's reach was overstated** — see B3 above.
+14. **And so was the audit script's.** The correction in 13 replaced one false claim with
+ another: `--repair` does not take a `boardShowcase` row down in any branch — a repaired
+ doc re-publishes, and the exposed non-bool `active` is refused, so nothing is written at
+ all. The script is a gate plus complete detection; the public row comes down by hand
+ (console `active` edit, or the Admin `publicProfile: false` takedown arm). B3 and
+ owner-op 4 rewritten; `docs/firebase-setup.md` too.
+
+Found by the final review round:
+
+15. **The CEL half of the exposure was ASSUMED closed, not enforced** — "seeded CEL cargos all
+ carry grants" is a data claim, and this branch's own suite mints the counterexample
+ (`mint_cel_admin`). `cargoAssignableByNonAdmin()` now refuses a `CEL` cargo to a non-Admin
+ off the get() the grants check already paid for. Section A and owner-op 1 rewritten
+ from "assumed" to "enforced".
+16. **Nothing tied `boardSurfacingCategory()` to `BOARD_GROUPS`.** A third publishable group
+ would have reopened correction 8 silently. `packages/types/src/board-surfacing-category.
+ rules.test.ts` parses the rules literal and asserts the set — the fourth instance of this
+ repo's parse-the-rules-file pattern.
+17. **The two catalog arms disagreed on an absent/unrecognized `category`.** Create asked
+ `!boardSurfacingCategory()`, the update escape hand-rolled `== 'Comision'`, so a legacy
+ doc with no `category` was non-board on create and board on update — fail-closed, so
+ never a hole. The payoff is **not** that legacy docs become editable: `positionDocSchema`
+ requires `category`, so `parseDocs` drops a category-less cargo and `/positions` never
+ lists it — unreachable from the UI either way. It is that the second, longhand definition
+ of "is this published" cannot drift from the create arm's (guardrail #1), and that one is
+ load-bearing on live docs. One predicate now.
+18. **A sentinel bug made the audit script's UNKNOWN-publication branch dead code.**
+ `published.get(id) ?? false` falls back on exactly the `null` that meant "unreadable", so
+ one rejected `getAll` chunk silently reclassified those members as unpublished — and
+ therefore truncatable. Proven against the emulator by injecting a `getAll` rejection: with
+ the bug, zero UNKNOWN lines; fixed, one per member.
+19. **`--repair` could PUBLISH a member and announced only takedowns.** See owner-op 4:
+ `WILL PUBLISH:` per doc, withheld behind `--allow-publish`.
+20. **`classify()` under-reported two shapes** it is the blocking gate for: the ghost
+ (`active: true` + non-null `deletedAt`) and a non-Timestamp `deletedAt`.
+21. **The UI mirror had no tests and one existing test had gone vacuous.** `position-form.
+ test.tsx`'s comisión submit selected a category on a select that was both `disabled` and
+ already defaulted to that value — the interaction was a no-op and the assertion measured
+ the default. Fixed, plus coverage for the label/category locks, including a BLOCKING test
+ that would go red on `register("title", { disabled: true })` (RHF's own `disabled` submits
+ the field as `undefined`, which 403s every non-Admin save).
+22. **Correction 15 fixed the update arm only, and this spec claimed otherwise.**
+ `createPositionsSafe()` still asked `cargoGrantsEmpty()`, so a `manage:Member` holder
+ could mint a member BORN on an Admin-minted grant-free CEL cargo — unpublished only until
+ a routine Admin `provisionMemberLogin` wrote the `uid`. Both arms now share
+ `cargoAssignableByNonAdmin()`; `cargoGrantsEmpty()` had no remaining caller and is
+ deleted. The three claims this falsified (the rules comment's "an Admin decision on both
+ ends", "an Admin who mints must also assign", owner-op 1's "rank 0 stays an Admin
+ decision on both ends") are now true rather than aspirational, and a mirror rules test
+ pins the create lane.
+23. **The CEL literal had no drift guard and defaults to ALLOW.** Correction 16 bound
+ `boardSurfacingCategory()` to `BOARD_GROUPS` but parsed around `nonAdminAssignable()`, a
+ denylist of one. A third publishable group would have gone Admin-only to MINT and stayed
+ open to ASSIGN for every non-Admin — auto-enrolled into the accepted exposure with nobody
+ deciding. The same test now parses both and asserts the board groups a non-Admin may
+ assign are exactly `['JDL']`. Mutation-proven in both directions.
+24. **No client mirror for the new denial.** Both member forms filtered cargo options on
+ `grants.length === 0`, so a grant-free CEL cargo was still offered to a non-Admin
+ (render-then-die), and a member already seated on one showed no lock while every save
+ re-stamped the `cargoId` — a comisiones-only edit was denied with no explanation. One
+ shared `cargoAssignableByNonAdmin()` now backs the option list and the lock in both forms.
+25. **Three audit-script defects.** The `WILL PUBLISH` line printed "`--allow-publish` was
+ passed — repairing it" in READ-ONLY mode, where nothing is written; `cargoPublishes()`
+ would have TypeError'd on an `undefined` cargo (reachable when `currentTermKey()`
+ straddles a UTC-year boundary between its two evaluations) and now reports it as the
+ unknown it is; and `--allow-publish` — the one flag that can ADD public exposure — now
+ changes the typed confirmation token to `repair-production-shapes-and-publish`, so the
+ string the operator types names the consequence.
+
+## Residuals
+
+- **A stale `boardShowcase` row for a member with a non-bool `active`** (B3): fail-closed
+ `projectBoard` stops the next publication, but an existing row is removed only by a
+ `members/{id}` write — which `--repair` refuses to fabricate for that shape. Detected and
+ gated by `pnpm audit:soft-delete-shapes`, remediated by hand. Recorded in
+ `apps/beacon/CLAUDE.md` next to the trigger that owns it.
+- **Publication of a grant-free JDL dirección by an `update:Position` holder** — the accepted
+ exposure, narrowed to JDL by rule (correction 15) rather than by an assumption about data.
+- The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope).
+- The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual) — **both** halves: the
+ Admin-claim strip, and the public-board move of a sitting president off CEL rank 0 that the
+ same write re-projects.
+- The port-level divergence in C: a zod-rejected doc reads ABSENT to backstage, COVERED to beacon.
+- (Not a residual, recorded so it is not re-opened as one: `siteConfig` write staying
+ `hasAnyRole(['Admin'])` is **correct**, and is not "the D case with a smaller blast
+ radius". D's argument is that Admin-by-role is the right key precisely because `Admin` is
+ `locked` and undeactivatable, so its name in a claim can never go stale — which is why D
+ kept the Admin disjunct and only moved the *other* half onto a perm. A single-authority
+ Admin-only gate has no second half to move. Migrating it to a perm would be a change with
+ no defect behind it.)
+- Refresh-token revocation: a revoked perm survives in a long-lived tab until reload.
+- `onRoleWritten`'s unbounded, no-retry fan-out.
diff --git a/firestore.rules b/firestore.rules
index 14b45094..af78c354 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -35,10 +35,69 @@ service cloud.firestore {
}
// Block client-side resurrection of soft-deleted docs: once deletedAt is set
// it is immutable, and active can only transition true -> false, never back.
+ //
+ // The three leading conjuncts are a WELL-FORMEDNESS prefix (B), the same one
+ // roleLifecycleSafe() already carried, and they are a genuine new denial rather than a
+ // tidier spelling of an existing one. The tempting argument — "a doc missing `active`
+ // already dies, because the bare resource.data.active read errors and an erroring rule
+ // denies" — is FALSE for this function: rules are CEL, whose || ABSORBS errors
+ // (error || true == true), and that bare read sits on the left of a || whose right side
+ // is unchanged(field), which compares .get(…, null) on both sides and yields
+ // null == null -> true on an absent key. Measured against the emulator: before this
+ // prefix, a members/positions/allies doc missing either field, or holding a non-bool
+ // `active`, was freely client-editable. Note the absorption is specific to the ||: the
+ // ('active' in d) conjunct below CANNOT be mutation-proven red, because removing it
+ // leaves `d.active is bool` reading an absent key inside a pure && chain, where the
+ // error does deny. It buys a clean denial, not a guard — recorded so nobody counts it
+ // as coverage.
+ //
+ // What the prefix costs: a legacy doc in any of those shapes becomes admin-SDK-only to
+ // edit, with no rules-layer repair for `active` (see the tests). The three create arms
+ // are constrained to match, so no NEW doc can enter those shapes. The Admin takedown arm
+ // on members deliberately does NOT call this function — it is the only rules-level path
+ // that can unpublish such a member, so the requirement lives here in the helper and not
+ // on the arms, which would have removed the remedy along with the disease.
+ //
+ // The one-way semantics below the prefix are UNCHANGED: deletedAt stays immutable once
+ // set and active still only goes true -> false. Four collections depend on that
+ // (members ×3 lanes, positions, allies); roles deliberately does not, which is why it
+ // has roleLifecycleSafe() instead.
+ //
+ // Which of the three prefix conjuncts a mutation sweep can turn red, measured (each
+ // neutralized to `true` in turn, full rules suite re-run):
+ // (d.active is bool) — RED alone: the three "stored active is a non-bool" tests.
+ // ('deletedAt' in d) — RED alone: "denies updating a member whose stored doc has no
+ // deletedAt key". This is the conjunct the || could not reach.
+ // ('active' in d) — NEVER red (448/448 still green). With it gone, `d.active is
+ // bool` reads an absent key, that read ERRORS, and unlike the
+ // deletedAt half there is no || to absorb it — so the rule still
+ // denies. It buys a clean denial, not a guard. Recorded so
+ // nobody counts it as coverage (guardrail #6), and kept for the
+ // same reason roleShapeValid() keeps its four.
function softDeleteSafe() {
- return (resource.data.deletedAt == null || unchanged('deletedAt'))
+ let d = request.resource.data;
+ return ('active' in d) && (d.active is bool) && ('deletedAt' in d)
+ && (resource.data.deletedAt == null || unchanged('deletedAt'))
&& (resource.data.active == true || unchanged('active'));
}
+ // The birth state every soft-deletable collection's create arm requires: born live,
+ // with an explicit null deletedAt. FOUR arms share it — members, positions, allies
+ // (softDeleteSafe lanes) and roles (roleLifecycleSafe) — so it lives here rather than
+ // being retyped per arm.
+ //
+ // Without it a create mints exactly the docs softDeleteSafe()'s well-formedness prefix
+ // then refuses to update, i.e. a doc that is admin-SDK-only from birth. It also closes
+ // the INACTIVE-vs-MISSING ambiguity on the way in: with the three update-side lanes
+ // constrained to match, no NEW doc can enter the legacy shapes those lanes now reject.
+ //
+ // `.get('active', false) == true` and an explicit `'deletedAt' in` check, not bare
+ // reads: a create OMITTING either field must deny CLEANLY, and
+ // `.get('deletedAt', null) == null` would let an omission through.
+ function bornLive() {
+ return request.resource.data.get('active', false) == true
+ && ('deletedAt' in request.resource.data)
+ && request.resource.data.deletedAt == null;
+ }
// Missing-key-safe comparison (docs created before a field existed).
function unchangedOrAbsent(field) {
@@ -81,14 +140,40 @@ service cloud.firestore {
function assignedCargoId() {
return assignedTerm().get('cargoId', null);
}
- // A null cargoId short-circuits (no get). For a non-null cargoId, get() on a
- // missing position errors → rule denies (fail-closed). Comisión grants are not
- // checked here (rules can't iterate the array) — the beacon trust gate is their backstop.
- function cargoGrantsEmpty() {
+ // The two questions a non-Admin assignment must answer, read off ONE cargo doc — one
+ // get(), two questions, rather than a read per boundary.
+ // grants.size() == 0 the claims-mint boundary.
+ // category != 'CEL' the PUBLICATION boundary. boardGroupFromCategory publishes
+ // CEL and JDL alike, and boardRank orders CEL by statutory
+ // title with 'Presidente' at 0 — so a grant-free CEL cargo,
+ // self-assigned through the members-positions lane, puts its
+ // holder at the head of the world-readable Directiva as the
+ // chapter president, with no Admin action in the chain.
+ // That was previously argued blocked because "seeded CEL cargos all carry grants" — a
+ // claim about DATA, enforced by nothing. The positions create arm below deliberately
+ // still lets an ADMIN mint a grant-free CEL cargo (rules.test.ts creates exactly one,
+ // `mint_cel_admin`), and from that moment the data claim is false. This makes the
+ // boundary a RULE.
+ // JDL stays open: grant-free direcciones are the accepted publication exposure this lane
+ // exists to deliver (spec A, owner-op 1). CEL is the executive committee, whose seats are
+ // an Admin decision on both ends — the positions create arm already says so.
+ // Reads request.resource.data only (via assignedTerm()), so BOTH member arms use it:
+ // positionsAssignmentSafe() on update and createPositionsSafe() on create. Splitting them
+ // is what let a manage:Member holder mint a member BORN on a grant-free CEL cargo.
+ // A null cargoId short-circuits (no get). For a non-null cargoId, get() on a missing
+ // position errors → rule denies (fail-closed). Comisión grants are not checked here
+ // (rules can't iterate the array) — the beacon trust gate is their backstop.
+ // Assignment only. currentCargoGrantsEmpty() (the cargo being REPLACED) is deliberately
+ // NOT category-gated: clearing a member off a grant-free CEL cargo is exposure-reducing,
+ // and denying it would strand a takedown behind an Admin.
+ function cargoAssignableByNonAdmin() {
return assignedCargoId() == null
- || get(/databases/$(database)/documents/positions/$(assignedCargoId())).data.grants.size() == 0;
+ || nonAdminAssignable(get(/databases/$(database)/documents/positions/$(assignedCargoId())).data);
}
- // The OTHER side of cargoGrantsEmpty(): the cargo being REPLACED. assignedCargoId()
+ function nonAdminAssignable(cargo) {
+ return cargo.grants.size() == 0 && cargo.get('category', '') != 'CEL';
+ }
+ // The OTHER side of cargoAssignableByNonAdmin(): the cargo being REPLACED. assignedCargoId()
// reads request.resource.data — the post-write cargo — so without this a manage:Member
// holder could overwrite a president's positions. with a grant-free cargo,
// claims-sync would resolve grants.length == 0, and the Admin claim would be gone with
@@ -121,16 +206,24 @@ service cloud.firestore {
function positionsAssignmentSafe() {
return positionsDelta().hasOnly([currentTermKey()])
&& assignedBySelf()
- && (hasAnyRole(['Admin']) || (cargoGrantsEmpty() && currentCargoGrantsEmpty()));
- }
- // Create has no prior resource to diff, so it can't use positionsDelta(); it
- // applies the same self-stamp + power-cargo-Admin-only gate to any positions it
- // writes. Without this a Membership user could create a member with a forged
- // assignedBy = a known Admin's uid + a power cargo; the claims-sync trigger would
- // then honor the grant and mint Admin onto that member's (or the attacker's) uid.
+ && (hasAnyRole(['Admin'])
+ || (cargoAssignableByNonAdmin() && currentCargoGrantsEmpty()));
+ }
+ // Create has no prior resource to diff, so it can't use positionsDelta(); it applies the
+ // same self-stamp + Admin-only-cargo gate to any positions it writes. Without this a
+ // Membership user could create a member with a forged assignedBy = a known Admin's uid +
+ // a power cargo; the claims-sync trigger would then honor the grant and mint Admin onto
+ // that member's (or the attacker's) uid.
+ // Same predicate as the update arm, deliberately: a create arm that only asked about
+ // `grants` let a manage:Member holder mint a member BORN holding a grant-free CEL cargo.
+ // The create arm forbids `uid`, so that doc is unpublished at birth — but onMemberCreated
+ // stamps the publicProfile default, the creator picks profilePicture, and one routine
+ // Admin provisionMemberLogin then writes the uid, after which projectBoard publishes an
+ // attacker-composed account at board rank 0 as Presidente. currentCargoGrantsEmpty() is
+ // the only half a create cannot ask (no prior resource), and it has no old side to guard.
function createPositionsSafe() {
return !('positions' in request.resource.data)
- || (assignedBySelf() && (hasAnyRole(['Admin']) || cargoGrantsEmpty()));
+ || (assignedBySelf() && (hasAnyRole(['Admin']) || cargoAssignableByNonAdmin()));
}
// roleIds + permissionOverrides feed Auth custom claims via the beacon trigger.
@@ -148,6 +241,35 @@ service cloud.firestore {
|| (unchanged('roleIds') && unchanged('permissionOverrides'));
}
+ // The invariants EVERY institutional write to a member doc must hold, as one named
+ // thing: the claims-mint boundary (roleIds + permissionOverrides via
+ // updatePermissionAssignmentSafe, plus uid), publication consent (publicProfile), the
+ // points ledger (totalPoints) and the soft-delete/well-formedness contract. Shared by
+ // the members update arm and the positions-only lane so the authority can never drift
+ // apart again — the same reason canCurateFeatured() exists.
+ //
+ // publicProfile is member-consent-only: it publishes their face/name to the public
+ // Directiva, so an institutional writer may never set or change it — only the
+ // owner-only self-lane, or the Admin takedown arm, can. Those two arms are therefore
+ // the ones that do NOT call this, each deliberately: the self lane MUST touch
+ // publicProfile (it IS the consent path), and the takedown arm must skip
+ // softDeleteSafe() (it is the only rules-level way to unpublish a malformed member).
+ //
+ // touched(), not unchanged(), on both pinned keys: writing an explicit null onto a doc
+ // that lacks the key passes a null == null comparison. For uid that null then fails
+ // memberDocSchema, dropping the member out of every backstage list with only a
+ // console.error.
+ //
+ // Cheap by construction — token reads and request/resource data only, no cross-document
+ // get() — so an arm may put it ahead of anything that does.
+ function memberWriteInvariants() {
+ return unchanged('totalPoints')
+ && !touched('uid')
+ && !touched('publicProfile')
+ && updatePermissionAssignmentSafe()
+ && softDeleteSafe();
+ }
+
// The completion trio (status + finalReport + impact) is written atomically by the
// wizard. A status flip to Finalizado missing either the report or the impact would
// orphan child-activity point confirmation and permanently strand the doc (the trio
@@ -173,8 +295,33 @@ service cloud.firestore {
// Single source of truth for who may curate `featured` — shared by the
// create and update arms so the authority can never drift apart again.
+ //
+ // The authority is SPLIT, and each half is the way it is on purpose:
+ // - Admin by ROLE. It is `locked` and undeactivatable, so its name carries none of
+ // the staleness the perm half exists to fix, and adding update:Showcase to a role
+ // whose entire seeded permission set is ["manage:all"] would misrepresent Admin.
+ // - everyone else by exact PERM (update:Showcase, seeded onto ProjectManager). Role
+ // NAMES are the wrong key here: computeMemberRoles is pure over the trusted grants
+ // and reads no role doc, so a DEACTIVATED ProjectManager keeps the name in its
+ // claim — and, under the old `hasAnyRole(['Admin','ProjectManager'])`, kept the
+ // authority. Keying on the perm means deactivating the role revokes curation.
+ // `hasPerm`, deliberately NOT `canDo`: canDo would let `manage:all` satisfy this, and
+ // manage:all is reachable as a PERM without the Admin role (an Admin-written custom
+ // role doc or a permissionOverrides.grant can carry it — roleShapeValid() only
+ // requires `permissions is list`). Such a principal already satisfies
+ // canDo('update','Project') on the update arm below, so this gate is the ONLY thing
+ // stopping them from setting `featured`; canDo would silently delete that boundary. It
+ // also keeps the other five *:Showcase codes — manage:Showcase included — inert, so
+ // there is no second, undocumented path to curation. A custom role with manage:Project
+ // may edit an initiative but must leave `featured` unchanged.
+ //
+ // Both arms need it: initiativeCreateAllowed is PERM-based (canDo), so a custom role
+ // holding create:Project/Program could otherwise create a doc born `featured: true`
+ // and reach the public page once finalized — hence its curator-or-featured-false arm.
+ // A curator creating it featured is their prerogative and only reaches the public page
+ // once finalized (isProjectable).
function canCurateFeatured() {
- return hasAnyRole(['Admin', 'ProjectManager']);
+ return hasAnyRole(['Admin']) || hasPerm('update:Showcase');
}
function initiativeCreateAllowed(subject) {
return canDo('create', subject)
@@ -186,20 +333,14 @@ service cloud.firestore {
|| request.resource.data.get('featured', false) == false);
}
// `featured` promotes an already-public finalized initiative onto the curated
- // public /programas page — a curation decision, not an editing one. Only Admin/
- // ProjectManager may flip it; a direction-only editor (who may otherwise update
- // their own initiative) must leave it unchanged. A pre-feature doc has no
- // `featured` field, which is semantically `false`, so default-to-false on BOTH
- // sides — else a direction editor echoing `featured: false` (the form always
- // sends it) on a legacy doc reads as null!=false and the whole update is denied.
- // Create mirrors the same authority: initiativeCreateAllowed is PERM-based
- // (canDo), so a custom role holding create:Project/Program could otherwise
- // create a doc born `featured: true` and reach the public page once finalized —
- // hence its Admin/PM-or-featured-false arm. An Admin/PM creating it featured is
- // their prerogative and only reaches the public page once finalized (isProjectable).
- // Intentionally role-based, NOT perm-based: `featured` is a curation decision
- // bound to Admin/ProjectManager. A custom role with manage:Project may edit an
- // initiative but must leave `featured` unchanged (falls through to the second arm).
+ // public /programas page — a curation decision, not an editing one. Only a curator
+ // may flip it; a direction-only editor (who may otherwise update their own
+ // initiative) must leave it unchanged. A pre-feature doc has no `featured` field,
+ // which is semantically `false`, so default-to-false on BOTH sides — else a
+ // direction editor echoing `featured: false` (the form always sends it) on a legacy
+ // doc reads as null!=false and the whole update is denied.
+ // Who counts as a curator, and why that authority is split the way it is, lives on
+ // canCurateFeatured() above.
function featuredUpdateSafe() {
return canCurateFeatured()
|| request.resource.data.get('featured', false) == resource.data.get('featured', false);
@@ -289,26 +430,17 @@ service cloud.firestore {
&& !('uid' in request.resource.data)
&& !('publicProfile' in request.resource.data)
&& memberNameValid(request.resource.data.get('name', ''))
+ && bornLive()
&& createPermissionAssignmentSafe()
&& createPositionsSafe();
- // publicProfile is member-consent-only: it publishes their face/name to the
- // public Directiva, so the institutional (membership-tier) arm may never set or
- // change it — only the owner-only self-lane below, or the Admin takedown arm after
- // it, can. touched() rather than unchanged(): writing an explicit null onto a doc
- // that lacks the key would pass a null == null comparison.
+ // The institutional (membership-tier) lane. memberWriteInvariants() carries the
+ // claims-mint boundary, publication consent, the points ledger and softDeleteSafe().
// touched('name'): an admin editing only a phone must not be denied by a legacy name
// stored before this gate existed — an unchanged value is absent from affectedKeys().
// Setting a name, on any lane, must satisfy it.
allow update: if canDo('update', 'Member')
- && unchanged('totalPoints')
- // touched(), not unchanged(): writing uid:null onto a member that has none passes
- // a null == null comparison, and a null then fails memberDocSchema — dropping
- // that member out of every backstage list with only a console.error.
- && !touched('uid')
- && !touched('publicProfile')
+ && memberWriteInvariants()
&& (!touched('name') || memberNameValid(request.resource.data.get('name', '')))
- && updatePermissionAssignmentSafe()
- && softDeleteSafe()
&& (!touched('positions') || positionsAssignmentSafe());
// Takedown-only arm: an Admin may turn publication OFF and nothing else. Needed
// because publication now defaults on, so a member who has lost account access —
@@ -334,6 +466,33 @@ service cloud.firestore {
&& resource.data.get('active', true) == true
&& selfProfileValid(request.resource.data.diff(resource.data).affectedKeys())
&& softDeleteSafe();
+ // Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on
+ // update:Position — the same capability that governs the positions CATALOG — and
+ // confined to the positions map. The power-cargo restriction is NOT relaxed:
+ // positionsAssignmentSafe()'s non-Admin branch still demands
+ // cargoAssignableByNonAdmin() && currentCargoGrantsEmpty(), so this principal assigns
+ // and clears grant-free cargos only, on BOTH sides of a swap.
+ //
+ // This lane IS a public publication authority, deliberately, and its ceiling is JDL:
+ // grant-free JDL direcciones are board cargos, so its holder can put a member —
+ // including themselves — on the world-readable Directiva under a dirección. No claim
+ // is minted (claims-sync returns early on grants.length == 0). Accepted, and pinned by
+ // a rules test. CEL is NOT in that grant — cargoAssignableByNonAdmin() refuses it
+ // whatever its grants, so 'Presidente' at public rank 0 stays an Admin decision.
+ //
+ // memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated
+ // anyway: if hasOnly is ever widened — the obvious future edit is adding a second
+ // key — the claims-mint boundary, publication consent and the points ledger must not
+ // vanish with it.
+ //
+ // Ordered cheapest-first on purpose: positionsAssignmentSafe() is the only conjunct
+ // here that can issue cross-document get()s (cargoAssignableByNonAdmin /
+ // currentCargoGrantsEmpty), so every denial this arm reaches through the token,
+ // affectedKeys or the invariants costs zero billed reads.
+ allow update: if canDo('update', 'Position')
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions'])
+ && memberWriteInvariants()
+ && positionsAssignmentSafe();
allow delete: if false;
}
@@ -348,15 +507,65 @@ service cloud.firestore {
return request.resource.data.get('category', '') != 'Comision'
|| request.resource.data.get('grants', []) == [];
}
+ // A category that boardGroupFromCategory() publishes — packages/types/src/engine/
+ // board-public.ts maps exactly CEL and JDL onto the world-readable Directiva, and
+ // boardRank() then orders them (an unknown CEL title sorts last; 'Presidente' is 0).
+ // Creating one is a PUBLICATION decision, so it is Admin-only, matching the pin on
+ // the update arm below. Non-Admins create comisiones.
+ function boardSurfacingCategory() {
+ return request.resource.data.get('category', '') in ['CEL', 'JDL'];
+ }
match /positions/{positionId} {
allow read: if signedIn();
// Authorization is perm-based; the power-grant authority (who may confer
// non-empty grants → claims) stays Admin-role-only, as before.
+ //
+ // boardSurfacingCategory() closes the create-side twin of the update-side
+ // `category` pin. Pinning only the update arm left the same outcome reachable one
+ // door over: a non-Admin create:Position holder could MINT a fresh
+ // { category: 'CEL', title: 'Presidente', grants: [] } cargo — grants-free, so it
+ // passes the power-grant check — and self-assign it through the members-positions
+ // lane, landing on the public Directiva at rank 0. Seeded CEL cargos all carry
+ // grants, which is what made that look blocked; nothing stopped an UNSEEDED one.
allow create: if canDo('create', 'Position')
- && (hasAnyRole(['Admin']) || request.resource.data.grants == [])
+ && (hasAnyRole(['Admin']) || (request.resource.data.grants == []
+ && !boardSurfacingCategory()))
+ && bornLive()
&& comisionGrantsEmpty();
+ // `category` joins `grants` in the non-Admin pin because it is an authority field,
+ // not a label: it decides the public board GROUP and whether comisionGrantsEmpty()
+ // applies at all. Without it an update:Position holder could retitle a grant-free
+ // Comisión to { category: 'CEL', title: 'Presidente' } and then assign it to
+ // themselves through the members-positions lane, landing on the world-readable
+ // Directiva with no Admin action anywhere in the chain.
+ //
+ // The TITLE is pinned with it, on board cargos only — and that is not symmetry for its
+ // own sake. boardRank() (packages/types/src/engine/board-public.ts:49-52) is computed
+ // from the base TITLE, not the category: CEL_ORDER maps 'Presidente' onto rank 0, and
+ // project-board.ts copies `title` verbatim into the world-readable Directiva. So
+ // pinning `category` alone left the same outcome one door over: a non-Admin cannot
+ // ASSIGN a seeded CEL cargo (they all carry grants) but could RENAME one — retitle the
+ // grant-free 'Vicepresidente' to 'Presidente' and the public board shows them first.
+ // On a board cargo a title is an authority field; on a comisión it is a label —
+ // comisiones never reach boardGroupFromCategory() — so comisión renames stay open,
+ // which is the legitimate org-chart-editor use case owner-op 1 describes.
+ // `unchanged('category')` is evaluated first, so reading the POST-write category to
+ // decide which branch applies cannot be steered by the writer.
+ //
+ // The escape is `!boardSurfacingCategory()`, NOT a hand-rolled `== 'Comision'`: the two
+ // disagree on a doc whose `category` is absent or unrecognized (a legacy doc predating
+ // the field). The hand-rolled form treated that as a board cargo and pinned its labels —
+ // fail-closed, so never a hole. The reason to unify is NOT that it frees such docs to be
+ // edited: positionDocSchema requires `category`, so parseDocs drops a category-less cargo
+ // and /positions never lists it — no UI reaches it either way. It is that a second
+ // definition of "is this published", written out longhand, drifts from the create arm's
+ // (guardrail #1) — and that one IS load-bearing on live docs. One predicate, both arms.
allow update: if canDo('update', 'Position')
- && (hasAnyRole(['Admin']) || unchanged('grants'))
+ && (hasAnyRole(['Admin'])
+ || (unchanged('grants')
+ && unchanged('category')
+ && (!boardSurfacingCategory()
+ || (unchanged('title') && unchanged('titleFemale')))))
&& softDeleteSafe()
&& comisionGrantsEmpty();
allow delete: if false;
@@ -370,9 +579,18 @@ service cloud.firestore {
}
// Roles are the ONE collection whose soft-delete is REVERSIBLE: a built-in role
// must be able to go out of service and come back. softDeleteSafe() is one-way and
- // shared by four other collections (members, positions, allies) — it must NOT
- // change; member resurrection is pinned denied in tests/firestore-rules/rules.test.ts
- // ("denies resurrecting a soft-deleted member").
+ // shared by four other lanes (members ×3, positions, allies), so the roles lane
+ // cannot borrow it.
+ //
+ // softDeleteSafe() has since gained this function's well-formedness prefix
+ // (`active` present and a bool, `deletedAt` present), for the reason spelled out
+ // below. What must NOT change there is its ONE-WAY semantics:
+ // member resurrection is pinned denied in tests/firestore-rules/rules.test.ts
+ // ("denies resurrecting a soft-deleted member"). What did NOT come with the prefix is
+ // this function's COUPLING half below (active == true => deletedAt == null, plus the
+ // request.time stamp): generalizing that would deny every later edit to an existing
+ // ghost doc, a shape all four zod schemas accept and which is therefore listed and
+ // editable today. Owed its own pass — see docs/specs/position-assignment-lane.md, B.
//
// What this closes: this repo's two definitions of "inactive" disagree.
// roleDefinitionDocSchema requires `active: z.boolean()`, so a malformed doc is
@@ -390,12 +608,16 @@ service cloud.firestore {
// (see below), and on a doc whose deletedAt is already null that alone would let
// active:false through — inactive with no stamp, which the UI reads as still
// restorable. This conjunct is what rejects it.
- // The two `in` checks add no authorization outcome on their own: reading an absent
- // key ERRORS, and an erroring rule already denies. They are here so a missing field
- // denies CLEANLY rather than by evaluation error — the same reason this file prefers
- // .get() on the resource side (softDeleteSafe's bare resource.data.active errors on a
- // doc missing the field). Don't remove them on a "no test covers it" argument; no
- // test CAN, because nothing becomes allowed.
+ // The two `in` checks add no authorization outcome IN THIS FUNCTION: reading an absent
+ // key errors, and here the erroring read sits in a pure CONJUNCTION, which cannot
+ // recover from it — so the rule denies either way and they only buy a clean denial.
+ // Don't remove them on a "no test covers it" argument; no test CAN, because nothing
+ // becomes allowed.
+ //
+ // That is a claim about this function's SHAPE, not a general property of rules — under
+ // a DISJUNCTION the same bare read is absorbed and denies nothing. Written out in full
+ // on softDeleteSafe() above, which is why the `in` prefix (B) and its legacy-doc cost
+ // landed there and not here.
// deletedAt value forgery has no authorization effect (every consumer tests
// null-ness only, never ordering); `== request.time` is audit hygiene on the
// DEACTIVATING write. It cannot be required of every write that merely leaves the
@@ -521,12 +743,7 @@ service cloud.firestore {
&& request.resource.data.builtIn == false
&& request.resource.data.builtInKey == null
&& request.resource.data.get('locked', false) == false
- // Close the same INACTIVE-vs-MISSING ambiguity on create. `.get(…, false)` and an
- // explicit key check, not a bare read: a create OMITTING the field must deny
- // cleanly, and `.get('deletedAt', null) == null` would let an omission through.
- && request.resource.data.get('active', false) == true
- && ('deletedAt' in request.resource.data)
- && request.resource.data.deletedAt == null;
+ && bornLive();
// A `locked` role is immutable. Identity fields can't change. Every write must leave
// a doc that BOTH readers agree on (roleShapeValid + roleLifecycleSafe). A built-in
// role MAY otherwise be deactivated and reactivated — the perms consequence is
@@ -544,7 +761,7 @@ service cloud.firestore {
match /allies/{allyId} {
allow read: if canDo('read', 'Ally');
- allow create: if canDo('create', 'Ally');
+ allow create: if canDo('create', 'Ally') && bornLive();
allow update: if canDo('update', 'Ally') && softDeleteSafe();
allow delete: if false;
}
diff --git a/package.json b/package.json
index 21d0a308..6ffa2411 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"pr-tests": "pnpm format && turbo run ci && pnpm knip && pnpm audit --audit-level=high && pnpm test:seed && pnpm test:harness",
"seed:emulator": "FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:4030 node tools/scripts/seed-emulator.mjs",
"seed:production": "node tools/scripts/seed-production.mjs",
+ "audit:soft-delete-shapes": "node tools/scripts/audit-soft-delete-shapes.mjs",
"deploy:rules": "firebase deploy --only firestore,storage",
"deploy:indexes": "firebase deploy --only firestore:indexes",
"deploy:functions": "firebase deploy --only functions",
diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md
index fc51b4dc..8717dd55 100644
--- a/packages/auth/CLAUDE.md
+++ b/packages/auth/CLAUDE.md
@@ -19,6 +19,8 @@ sentence: `.claude/hooks/route.sh` prints the mandated set for your diff.
| `@luminova/auth/roles` | `AuthClaims`, `Role`, `ROLES`, `isValidRole`, `hasRole`, `hasAnyRole` |
| `@luminova/auth/ability` | `buildAbility`, `subject`, `AppAbility`, plus `Action`/`Subject` **re-exported** from `@luminova/types` |
| `@luminova/auth/perms` | `resolveEffectivePerms` |
+| `@luminova/auth/built-in-perms` | `resolveBuiltInPerms`, `BuiltInRoleDoc` — the ONE absent/live/inactive three-way over already-fetched role docs, shared by beacon's claims-sync and the backstage assignment preview |
+| `@luminova/auth/test-helpers` | `roleClaims` — mints `{roles, perms}` the production way; tests must not use a bare `{ roles: [...] }` fixture (see the `claims.perms` invariant below) |
`exports` maps types to `src/*.ts` but runtime to `dist/*.js`, so a **fresh
worktree must build this package before an app's vitest run** — an unbuilt `dist`
@@ -56,13 +58,26 @@ a data change.
## Invariants
-- **`resolveEffectivePerms` returns the set UNCAPPED.** Enforcing `PERMISSION_CAP`
- (`@luminova/types`) is the caller's job, and the three callers do not agree:
- beacon's claims-sync is **fail-closed** (`sync.ts`), the backstage role editor
- blocks Save, and `apps/beacon/scripts/seed-roles.ts` enforces **nothing** — it
- writes `perms` straight to `setCustomUserClaims`. That last one is emulator-only
- (`assertEmulator()`), which is the only reason it is not a hole. Any new caller
- must enforce the cap.
+- **`resolveEffectivePerms` returns the set UNCAPPED, and so does `resolveBuiltInPerms`
+ on top of it.** Enforcing `PERMISSION_CAP` (`@luminova/types`) is the caller's job.
+ Neither production consumer calls `resolveEffectivePerms` directly any more — both
+ reach it through `resolveBuiltInPerms`, so that is where the cap discipline now
+ attaches. The current call graph:
+ - `resolveBuiltInPerms` (`built-in-perms.ts`) — in-package, uncapped by design
+ because its two callers disagree on the *response*, not on the limit:
+ - beacon claims-sync → `resolveMemberPerms` → `sync.ts`, **fail-closed** to
+ `perms: []`; and `set-user-roles.ts`, which throws `internal` over the cap;
+ - backstage `previewEffectivePerms`
+ (`features/permissions/lib/effective-preview.ts`) → `member-roles-panel.tsx`,
+ which disables Save while `effective.length > PERMISSION_CAP`.
+ - `roleClaims` (`test-helpers.ts`) — test-only fixtures, no cap.
+ - `apps/beacon/scripts/seed-roles.ts` — enforces **nothing**, writing `perms`
+ straight to `setCustomUserClaims`. Emulator-only (`assertEmulator()`), which is
+ the only reason it is not a hole.
+
+ (The backstage *role editor* caps a different thing: `roleDefinitionSchema` bounds
+ one role doc's own `permissions` array. That is not this resolution.) Any new
+ caller of either function must enforce the cap.
- Output is **deduped**, which is what makes the write-skip check work: beacon's
`sameList` compares length then Set membership, so a duplicate would flip
lengths and force a redundant claim write. It is **not** order-sensitive — that
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 47471041..f58ae89e 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -19,6 +19,11 @@
"import": "./dist/perms.js",
"default": "./dist/perms.js"
},
+ "./built-in-perms": {
+ "types": "./src/built-in-perms.ts",
+ "import": "./dist/built-in-perms.js",
+ "default": "./dist/built-in-perms.js"
+ },
"./test-helpers": {
"types": "./src/test-helpers.ts",
"import": "./dist/test-helpers.js",
diff --git a/packages/auth/src/built-in-perms.test.ts b/packages/auth/src/built-in-perms.test.ts
new file mode 100644
index 00000000..b6e995b5
--- /dev/null
+++ b/packages/auth/src/built-in-perms.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from "vitest";
+import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition";
+import type { PermissionCode } from "@luminova/types";
+import { resolveBuiltInPerms, type BuiltInRoleDoc } from "./built-in-perms.js";
+
+const NO_OVERRIDES = { grant: [], revoke: [] } as const;
+
+/** Same shape beacon's `firestore-deps` hands in: array, docs and each `permissions`
+ * array all frozen, because one memoized graph is reused for the whole fan-out. */
+function frozenDocs(docs: BuiltInRoleDoc[]): readonly BuiltInRoleDoc[] {
+ return Object.freeze(
+ docs.map((doc) => Object.freeze({ ...doc, permissions: Object.freeze(doc.permissions) })),
+ );
+}
+
+describe("resolveBuiltInPerms", () => {
+ it("falls back to the seed snapshot when NO doc claims the key", () => {
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Treasury].sort());
+ });
+
+ it("prefers a live doc's stored permissions over the seed snapshot", () => {
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [{ permissions: ["read:Member"], builtInKey: "Treasury", live: true }],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual(["read:Member"]);
+ });
+
+ it("BLOCKING: a not-live doc contributes nothing AND suppresses the seed fallback", () => {
+ // The distinction the whole three-way exists for: "deactivated" must not be
+ // indistinguishable from "never seeded". Perms deliberately non-empty, so an
+ // implementation that ignores `live` fails loudly instead of returning [] by accident.
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [{ permissions: ["manage:all"], builtInKey: "Treasury", live: false }],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual([]);
+ });
+
+ it("BLOCKING: a not-live doc covers its key while an absent key still falls back", () => {
+ // This does NOT construct a ghost — resolveBuiltInPerms never sees active/deletedAt;
+ // it takes the caller's precomputed `live`. The ghost shape (`active: true` with a
+ // non-null `deletedAt`) being derived INTO live: false is beacon's job, and the test
+ // that feeds real doc fields through that derivation lives in
+ // apps/beacon/src/claims-sync/sync.test.ts. What this case adds over the
+ // suppresses-the-fallback test above is the split verdict: the covered key mints
+ // nothing while the absent key still falls back to its seed — so the assertion cannot
+ // pass by returning [] wholesale.
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury", "Secretary"],
+ builtInDocs: [{ permissions: ["manage:all"], builtInKey: "Treasury", live: false }],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Secretary].sort());
+ expect(out).not.toContain("manage:all");
+ });
+
+ it.each([
+ ["live doc first", false],
+ ["not-live doc first", true],
+ ])("unions the LIVE docs when two docs claim one key (%s)", (_name, deadFirst) => {
+ const live: BuiltInRoleDoc = {
+ permissions: ["read:Member"],
+ builtInKey: "Treasury",
+ live: true,
+ };
+ const alsoLive: BuiltInRoleDoc = {
+ permissions: ["read:Position"],
+ builtInKey: "Treasury",
+ live: true,
+ };
+ const dead: BuiltInRoleDoc = {
+ permissions: ["manage:all"],
+ builtInKey: "Treasury",
+ live: false,
+ };
+ const docs = deadFirst ? [dead, live, alsoLive] : [live, alsoLive, dead];
+ expect(
+ resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: docs,
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ }),
+ ).toEqual(["read:Member", "read:Position"]);
+ });
+
+ it("BLOCKING: two docs claiming one key, both not live, still suppress the fallback", () => {
+ // A per-key `find`/Map that kept only one doc would get this right by luck; a
+ // `some(live)` coverage test would get it wrong and re-mint the snapshot.
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [
+ { permissions: ["manage:all"], builtInKey: "Treasury", live: false },
+ { permissions: ["read:Member"], builtInKey: "Treasury", live: false },
+ ],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual([]);
+ });
+
+ it("BLOCKING: a doc for an unrequested key is IGNORED — no perms, no coverage", () => {
+ // Settles the one divergence between the two former implementations, in the tighter
+ // direction: beacon unioned every doc it was handed without checking the key. No
+ // production path produces such a doc (the query is where('builtInKey','in',keys)),
+ // so this direct test is the only thing pinning the choice.
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [
+ { permissions: ["manage:all"], builtInKey: "Membership", live: true },
+ { permissions: ["manage:Ally"], builtInKey: "Secretary", live: false },
+ ],
+ customDocs: [],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Treasury].sort());
+ });
+
+ it("unions custom role docs with the built-in resolution", () => {
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [{ permissions: ["read:Member"], builtInKey: "Treasury", live: true }],
+ customDocs: [{ permissions: ["manage:Ally"] }],
+ overrides: NO_OVERRIDES,
+ });
+ expect(out).toEqual(["manage:Ally", "read:Member"]);
+ });
+
+ it("applies overrides on top: grant adds, revoke wins", () => {
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: ["Treasury"],
+ builtInDocs: [],
+ customDocs: [],
+ overrides: { grant: ["manage:Position", "read:Member"], revoke: ["read:Member"] },
+ });
+ expect(out).toEqual(["manage:Position", "read:MemberPoints"]);
+ });
+
+ it("resolves with no overrides argument at all", () => {
+ expect(
+ resolveBuiltInPerms({
+ builtInRoleNames: [],
+ builtInDocs: [],
+ customDocs: [{ permissions: ["manage:Ally"] }],
+ }),
+ ).toEqual(["manage:Ally"]);
+ });
+
+ it("BLOCKING: does not mutate a deep-frozen input graph", () => {
+ // Beacon memoizes ONE frozen graph and hands it to every member of an unbounded
+ // fan-out. An in-place `.sort()` here would corrupt every remaining member's claims —
+ // frozen, it throws in strict mode instead, which is what this test observes.
+ const names = Object.freeze<["Membership", "Treasury", "Secretary"]>([
+ "Membership",
+ "Treasury",
+ "Secretary",
+ ]);
+ const docs = frozenDocs([
+ {
+ permissions: ["read:Position", "checkIn:Attendance"],
+ builtInKey: "Membership",
+ live: true,
+ },
+ { permissions: ["manage:all"], builtInKey: "Treasury", live: false },
+ ]);
+ const customPerms: PermissionCode[] = ["manage:Ally"];
+ Object.freeze(customPerms);
+ const customDocs = Object.freeze([Object.freeze({ permissions: customPerms })]);
+
+ const out = resolveBuiltInPerms({
+ builtInRoleNames: names,
+ builtInDocs: docs,
+ customDocs,
+ overrides: NO_OVERRIDES,
+ });
+
+ expect(out).toEqual(
+ [
+ ...new Set([
+ "read:Position",
+ "checkIn:Attendance",
+ "manage:Ally",
+ ...BUILT_IN_ROLE_PERMS.Secretary,
+ ]),
+ ].sort(),
+ );
+ expect(names).toEqual(["Membership", "Treasury", "Secretary"]);
+ expect(docs[0]?.permissions).toEqual(["read:Position", "checkIn:Attendance"]);
+ expect(docs[1]?.permissions).toEqual(["manage:all"]);
+ expect(customDocs[0]?.permissions).toEqual(["manage:Ally"]);
+ });
+});
diff --git a/packages/auth/src/built-in-perms.ts b/packages/auth/src/built-in-perms.ts
new file mode 100644
index 00000000..a1df01cf
--- /dev/null
+++ b/packages/auth/src/built-in-perms.ts
@@ -0,0 +1,71 @@
+import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition";
+import type { PermissionCode, Role, RoleDefinition } from "@luminova/types";
+import { resolveEffectivePerms } from "./perms.js";
+
+/** Anything `resolveEffectivePerms` can union perms out of — a live built-in doc, a custom
+ * doc, or the seed snapshot. Structural, so no branch has to be widened by hand. */
+type PermsSource = { readonly permissions: readonly PermissionCode[] };
+
+/** A built-in role doc as the shared resolution consumes it.
+ *
+ * Deliberately NOT a `Pick` of the stored doc shape. Liveness is the TWO-field predicate
+ * over `active` AND `deletedAt`, so a port field named `active` would read as "the doc's
+ * `active` field" and an implementer returning `d.get("active")` would satisfy the type
+ * while readmitting the ghost shape (`active: true` with a non-null `deletedAt`) that
+ * mints the doc's real perms. Naming the semantic keeps the contract unspoofable by a
+ * plain field read.
+ *
+ * Liveness *derivation* stays per-side, and NOT because of an import boundary: beacon's
+ * `isActiveRoleDoc` is fail-OPEN (`active !== false`, so a missing or non-bool `active`
+ * reads live) while backstage's `isLiveRole` is fail-CLOSED (`active === true`). They do
+ * not compute the same function, so unifying them would be a behaviour change, not a
+ * refactor. Only *consumption* is shared. */
+export interface BuiltInRoleDoc {
+ readonly permissions: readonly PermissionCode[];
+ readonly builtInKey: Role;
+ /** Precomputed liveness. NEVER the raw `active` field: a doc with `active: true` and a
+ * non-null `deletedAt` is a ghost — covered, contributing nothing. */
+ readonly live: boolean;
+}
+
+/** The one three-way built-in resolution, shared by beacon's claims-sync
+ * (`resolveMemberPerms`) and the backstage member-assignment preview
+ * (`previewEffectivePerms`). Synchronous and pure over already-fetched docs — it does not
+ * sort or mutate its inputs, because beacon hands it a deep-frozen graph.
+ *
+ * Three-way per built-in key:
+ * - NO doc claims the key → BUILT_IN_ROLE_PERMS[key] (the pre-seed window must still
+ * mint perms on a fresh project)
+ * - doc(s) claim it, live → the UNION of their stored `permissions`
+ * - doc(s) claim it, none live → nothing, and the key stays COVERED (so the snapshot
+ * must NOT come back). That distinction is the entire
+ * reason not-live docs have to be passed in: drop them at
+ * the port and a deactivation silently restores the seed.
+ *
+ * Iterating the deduped NAMES (not the docs) is what makes all three cases one expression:
+ * coverage is "this name has at least one claiming doc", so a doc whose `builtInKey` is
+ * not requested is ignored STRUCTURALLY — it is never visited. Grouping per name also
+ * handles two docs claiming one `builtInKey`: both live ones are unioned, where a
+ * `Map` keyed on the doc would have kept whichever arrived last.
+ *
+ * `PERMISSION_CAP` is deliberately NOT applied: the callers disagree on the response
+ * (beacon fail-closes to `perms: []`, backstage blocks Save), so the cap stays theirs. */
+export function resolveBuiltInPerms(input: {
+ builtInRoleNames: readonly Role[];
+ builtInDocs: readonly BuiltInRoleDoc[];
+ customDocs: readonly Pick[];
+ overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] };
+}): PermissionCode[] {
+ // The return annotation is load-bearing: without it the two branches infer as a union of
+ // two ARRAY types, which flatMap cannot flatten.
+ const roleDocs = [...new Set(input.builtInRoleNames)].flatMap((name): PermsSource[] => {
+ const claiming = input.builtInDocs.filter((doc) => doc.builtInKey === name);
+ return claiming.length
+ ? claiming.filter((doc) => doc.live)
+ : [{ permissions: BUILT_IN_ROLE_PERMS[name] }];
+ });
+ return resolveEffectivePerms({
+ roleDocs: [...roleDocs, ...input.customDocs],
+ overrides: input.overrides,
+ });
+}
diff --git a/packages/auth/src/perms.ts b/packages/auth/src/perms.ts
index 70bfdaa3..3d73ac58 100644
--- a/packages/auth/src/perms.ts
+++ b/packages/auth/src/perms.ts
@@ -1,13 +1,17 @@
-import type { PermissionCode, RoleDefinition } from "@luminova/types";
+import type { PermissionCode } from "@luminova/types";
/** Resolve a member's effective coarse permission set: union of all role perms,
* plus override grants, minus override revokes. Deduped and sorted so equality
* checks (idempotent claim writes) are stable. Revoke wins over grant.
*
+ * `roleDocs` is readonly all the way down because this function only ITERATES it —
+ * beacon hands it a deep-frozen graph, so requiring mutability would force callers to
+ * copy arrays purely to launder the type.
+ *
* Returns the full set uncapped — the caller enforces `PERMISSION_CAP`
* (fail-closed in the beacon trigger; a save-blocking preview in the admin UI). */
export function resolveEffectivePerms(input: {
- roleDocs: Pick[];
+ roleDocs: readonly { readonly permissions: readonly PermissionCode[] }[];
overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] };
}): PermissionCode[] {
const set = new Set();
diff --git a/packages/auth/src/roles.test.ts b/packages/auth/src/roles.test.ts
index c9b17c18..62e8a65a 100644
--- a/packages/auth/src/roles.test.ts
+++ b/packages/auth/src/roles.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { isValidRole, hasRole, hasAnyRole, type AuthClaims } from "./roles";
+import { isValidRole, hasRole, hasAnyRole, hasPerm, type AuthClaims } from "./roles";
// The canonical ROLES catalog is derivation-guarded in packages/types
// (role-definition.test.ts: every role has a BUILT_IN_ROLE_PERMS entry). A retyped
@@ -25,4 +25,17 @@ describe("roles", () => {
expect(hasAnyRole(claims, ["Admin", "ExecutiveCommittee"])).toBe(true);
expect(hasAnyRole(claims, ["Admin", "Treasury"])).toBe(false);
});
+
+ it("hasPerm tests the EXACT code, with no manage:* expansion", () => {
+ const claims: AuthClaims = { roles: [], perms: ["update:Showcase", "manage:all"] };
+ expect(hasPerm(claims, "update:Showcase")).toBe(true);
+ // manage:all is present, yet an unheld exact code is still false — mirrors the rules'
+ // hasPerm(), not canDo(). Expanding here would re-open the boundary canCurateFeatured()
+ // relies on.
+ expect(hasPerm(claims, "update:Member")).toBe(false);
+ });
+
+ it("hasPerm reads an absent perms claim as zero coarse abilities", () => {
+ expect(hasPerm({ roles: ["Admin"] }, "update:Showcase")).toBe(false);
+ });
});
diff --git a/packages/auth/src/roles.ts b/packages/auth/src/roles.ts
index 0aea2224..8952d141 100644
--- a/packages/auth/src/roles.ts
+++ b/packages/auth/src/roles.ts
@@ -18,3 +18,13 @@ export function hasRole(claims: AuthClaims, role: Role): boolean {
export function hasAnyRole(claims: AuthClaims, roles: readonly Role[]): boolean {
return claims.roles.some((role) => roles.includes(role));
}
+
+/** Client mirror of `hasPerm()` in firestore.rules: does the claim carry this EXACT code?
+ * No `manage:all` / `manage:` expansion — that is the rules' `canDo()`, and on
+ * this side the CASL ability (`buildAbility` / `abilityAllows`) is what answers it.
+ *
+ * Use this only where the rule being mirrored is itself `hasPerm` — a gate that reads the
+ * ability instead would show an affordance whose write the rules then reject. */
+export function hasPerm(claims: AuthClaims, code: PermissionCode): boolean {
+ return (claims.perms ?? []).includes(code);
+}
diff --git a/packages/types/src/board-surfacing-category.rules.test.ts b/packages/types/src/board-surfacing-category.rules.test.ts
new file mode 100644
index 00000000..f93c7b82
--- /dev/null
+++ b/packages/types/src/board-surfacing-category.rules.test.ts
@@ -0,0 +1,116 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { BOARD_GROUPS, boardGroupFromCategory } from "./engine/board-public.js";
+
+// firestore.rules cannot import this package, so boardSurfacingCategory() hand-writes the
+// set of Position categories that reach the world-readable Directiva. This package owns the
+// authority — BOARD_GROUPS / boardGroupFromCategory, which beacon's projectBoard actually
+// consults — so it owns the proof the two agree ("rules mirror code", guardrail #2), the
+// same job as role-name-bound.rules.test and member-self-lane.rules.test.
+//
+// What goes wrong without it: the two literals are the ONLY thing standing between a
+// non-Admin create:Position holder and a self-minted board cargo. Add a third publishable
+// group to BOARD_GROUPS — beacon starts publishing it — and the rules create arm still reads
+// `in ['CEL','JDL']`, so minting one is a non-Admin write again. That is precisely the hole
+// `boardSurfacingCategory()` was added to close, silently reopened, with nothing red.
+// Runs in the fast `checks` job (no emulator).
+//
+// The same job is done a second time for nonAdminAssignable(), the OTHER hand-written
+// literal over these categories — and it is the one that defaults to ALLOW. It encodes the
+// assignment boundary as a denylist of one (`category != 'CEL'`), so a third publishable
+// group added to BOARD_GROUPS would be forced into boardSurfacingCategory() by the
+// assertions above — minting it goes Admin-only, good — while nonAdminAssignable() silently
+// kept ASSIGNING it open to every non-Admin. A new board group would auto-enroll into the
+// accepted-exposure class (spec A: "the ceiling is JDL") with nobody deciding. Same
+// silent-reopen shape, one function over.
+//
+// Parsed out of each function specifically, not matched loose: 'CEL' and 'JDL' appear
+// throughout the rules (comment prose, the positions arms), so a repo-wide search for the
+// strings would pass on the wrong occurrence.
+
+const RULES = readFileSync(
+ fileURLToPath(new URL("../../../firestore.rules", import.meta.url)),
+ "utf8",
+);
+
+function parseBoardSurfacingCategories(rules: string): string[] {
+ const fn = rules.match(/function boardSurfacingCategory\(\)\s*\{[\s\S]*?\n {4}\}/)?.[0];
+ if (fn === undefined) throw new Error("boardSurfacingCategory() not found in firestore.rules");
+ const list = fn.match(/\bin\s*\[([^\]]*)\]/)?.[1];
+ if (list === undefined) {
+ throw new Error("boardSurfacingCategory() no longer tests membership of a [...] literal");
+ }
+ return list
+ .split(",")
+ .map((entry) => entry.trim().replace(/^'(.*)'$/, "$1"))
+ .filter((entry) => entry.length > 0);
+}
+
+/** The categories nonAdminAssignable() names in a `category ... != ''` conjunct — the
+ * denylist that decides which board cargos a non-Admin may assign through the members
+ * lanes. */
+function parseNonAdminAssignableDenials(rules: string): string[] {
+ const fn = rules.match(/function nonAdminAssignable\(cargo\)\s*\{[\s\S]*?\n {4}\}/)?.[0];
+ if (fn === undefined) throw new Error("nonAdminAssignable() not found in firestore.rules");
+ const denials = [...fn.matchAll(/!=\s*'([^']*)'/g)].map((match) => match[1]);
+ if (denials.length === 0) {
+ throw new Error("nonAdminAssignable() no longer excludes any category by name");
+ }
+ return denials;
+}
+
+describe("firestore.rules boardSurfacingCategory is in sync with BOARD_GROUPS", () => {
+ const categories = parseBoardSurfacingCategories(RULES);
+
+ it("gates exactly the categories BOARD_GROUPS publishes", () => {
+ expect(new Set(categories)).toEqual(new Set(BOARD_GROUPS));
+ });
+
+ // BOARD_GROUPS is the constant; boardGroupFromCategory is what projectBoard calls. Bind
+ // the rules literal to the FUNCTION too, so a hand-written mapping that drifts from the
+ // constant it is derived from cannot slip between them.
+ it("names only categories boardGroupFromCategory actually publishes", () => {
+ for (const category of categories) {
+ expect(boardGroupFromCategory(category), category).not.toBeNull();
+ }
+ });
+
+ it("omits no category boardGroupFromCategory publishes", () => {
+ for (const group of BOARD_GROUPS) {
+ expect(boardGroupFromCategory(group)).not.toBeNull();
+ expect(categories, group).toContain(group);
+ }
+ });
+
+ // The guard is only worth having if the non-board side stays non-board: `Comision` must
+ // be absent from both, or the create arm goes Admin-only for the one category a non-Admin
+ // is supposed to be able to mint.
+ it("leaves Comision out of both", () => {
+ expect(boardGroupFromCategory("Comision")).toBeNull();
+ expect(categories).not.toContain("Comision");
+ });
+
+ // The ALLOW-by-default twin. Everything above binds the Admin-only MINT boundary; this
+ // binds the non-Admin ASSIGN boundary to the same constant, so a new board group cannot
+ // enroll itself into the accepted public exposure.
+ describe("nonAdminAssignable's exclusions cover every board group but JDL", () => {
+ const denied = new Set(parseNonAdminAssignableDenials(RULES));
+
+ it("leaves exactly JDL assignable by a non-Admin", () => {
+ // Not `expect(denied).toEqual(new Set(['CEL']))`: the assertion that must go red on a
+ // new BOARD_GROUPS entry is about the categories left OPEN, and it is derived from the
+ // constant. Adding 'XYZ' to BOARD_GROUPS makes this ['JDL','XYZ'] until somebody
+ // either excludes it here or decides, in writing, that it is publishable by delegates.
+ expect(BOARD_GROUPS.filter((group) => !denied.has(group))).toEqual(["JDL"]);
+ });
+
+ it("excludes only categories that actually reach the public Directiva", () => {
+ // The other direction: an exclusion naming a non-board category would be dead weight
+ // gating nothing (and would wrongly narrow the comisión lane if it named 'Comision').
+ for (const category of denied) {
+ expect(boardGroupFromCategory(category), category).not.toBeNull();
+ }
+ });
+ });
+});
diff --git a/packages/types/src/permission.test.ts b/packages/types/src/permission.test.ts
index ecf7ff3f..e6ac2346 100644
--- a/packages/types/src/permission.test.ts
+++ b/packages/types/src/permission.test.ts
@@ -47,6 +47,21 @@ describe("permission vocabulary", () => {
});
});
+describe("Showcase subject", () => {
+ it("is a known subject", () => {
+ expect(SUBJECTS).toContain("Showcase");
+ });
+ // Only update:Showcase is read by firestore.rules' canCurateFeatured(); the other five
+ // are generated by the cross-product and gate nothing. They must still be VALID codes —
+ // the /permisos matrix renders the full grid, and an assignable-but-unvalidatable code
+ // would fail the role editor's write validation.
+ it("accepts update:Showcase and the inert siblings the matrix will render", () => {
+ expect(isValidPermissionCode("update:Showcase")).toBe(true);
+ expect(isValidPermissionCode("manage:Showcase")).toBe(true);
+ expect(isValidPermissionCode("read:Showcase")).toBe(true);
+ });
+});
+
describe("Notification subject", () => {
it("is a known subject", () => {
expect(SUBJECTS).toContain("Notification");
diff --git a/packages/types/src/permission.ts b/packages/types/src/permission.ts
index 3edb685e..b691741d 100644
--- a/packages/types/src/permission.ts
+++ b/packages/types/src/permission.ts
@@ -14,6 +14,19 @@ export const SUBJECTS = [
"Role",
"Lead",
"Notification",
+ // Public-site curation. What this gates is the `featured` flag on `projects`/`programs`
+ // — NOT the beacon-owned `showcase` collection, which is `allow read: if true` with no
+ // client write at all. The name is a slight misnomer kept because `featured` is the input
+ // to that public projection.
+ //
+ // Only `update:Showcase` is live: firestore.rules' canCurateFeatured() is
+ // `hasAnyRole(['Admin']) || hasPerm('update:Showcase')` — an EXACT code match, not
+ // canDo(). So the other five codes gate nothing, and `manage:Showcase` in particular is
+ // inert BECAUSE the gate is exact: there is no second, undocumented path to curation.
+ // That inertness is the pre-existing condition of this vocabulary, not a new defect —
+ // the /permisos matrix renders the full actions × subjects grid, so `checkIn:Member` and
+ // dozens like it are already assignable and equally inert.
+ "Showcase",
"all",
] as const;
export type Subject = (typeof SUBJECTS)[number];
diff --git a/packages/types/src/role-definition.ts b/packages/types/src/role-definition.ts
index 621675fa..dd0fa089 100644
--- a/packages/types/src/role-definition.ts
+++ b/packages/types/src/role-definition.ts
@@ -1,3 +1,6 @@
+// MUST stay `import type`. `firebase` is only a devDependency here, and `@luminova/auth/built-in-perms`
+// pulls this file into the esbuild Cloud Functions bundle — a value import would resolve and bundle
+// the client SDK into a beacon artifact that is admin-SDK-only, silently. No lint rule enforces this.
import type { Timestamp } from "firebase/firestore";
import type { Role } from "./permission-role.js";
import type { PermissionCode } from "./permission.js";
@@ -48,6 +51,11 @@ export const BUILT_IN_ROLE_PERMS: Record = {
"manage:Activity",
"checkIn:Attendance",
"read:Ally",
+ // Public-site curation of `featured` (rules' canCurateFeatured). Held as a perm, not
+ // as the ProjectManager role NAME, so deactivating the role doc revokes it — the name
+ // survives in the claim, the perm does not. manage:Project does NOT imply it: the gate
+ // is an exact hasPerm.
+ "update:Showcase",
],
// Meant for a JDL dirección — prod data created in /positions, never seeded onto a cargo.
ActivityManager: ["manage:Activity", "checkIn:Attendance"],
diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts
index 0e81a851..035f981d 100644
--- a/tests/firestore-rules/rules.test.ts
+++ b/tests/firestore-rules/rules.test.ts
@@ -39,11 +39,29 @@ function anon() {
return env.unauthenticatedContext().firestore();
}
+/** The members-positions lane's principal: a perms-only org-chart editor with NO built-in
+ * role name — the custom role owner-op 1 describes. `as(uid, [], [...])` is what makes it
+ * perms-only: the third argument replaces the seeded role perms entirely. Module-scoped
+ * because the positions catalog arm, the members-positions lane and the well-formedness
+ * sweep all need the same principal. */
+const ORG_CHART = "orgchart-uid";
+const orgChart = () => as(ORG_CHART, [], ["update:Position"]);
+
const MEMBER_DOC = { name: "Ana", totalPoints: 0, uid: "owner-uid", active: true, deletedAt: null };
+/** The birth state every members/positions/allies create arm now requires (B2): born
+ * live, with an explicit null deletedAt. Spread into a create payload whose test is
+ * about something ELSE, so it keeps failing (or succeeding) for its own reason rather
+ * than for the missing lifecycle pair — the tautological-test class this repo has
+ * already had to clean up once. */
+const BORN_LIVE = { active: true, deletedAt: null };
+
// Rules derive the term from request.time.year() (UTC); compute it from the client
// clock so this suite can't rot when the calendar year rolls over.
const TERM = String(new Date().getUTCFullYear());
+/** The term BEFORE the current one — the term-rollover residual's whole point: a power cargo
+ * parked here is invisible to currentCargoGrantsEmpty(), which only reads currentTermKey(). */
+const PRIOR_TERM = String(Number(TERM) - 1);
const DELETED_AT = new Date("2026-01-01T00:00:00Z");
// Fixed instant for the activity-lock fixtures so echo-update tests can resend
// the exact same startAt value.
@@ -413,6 +431,43 @@ beforeAll(async () => {
impact: null,
status: "EnEjecucion",
});
+ // Targets for the perm-keyed curation describe. Each absorbs a featured:true
+ // success, so they are separate docs (the suite seeds once, no per-test reset)
+ // and separate from each other so neither success can mask the other.
+ await setDoc(doc(db, "projects/p_feat_showcase"), {
+ termId: "2026",
+ title: "Destacable (update:Showcase)",
+ roster: { directorId: "m1", coDirectorIds: [], teamIds: [] },
+ directionUids: ["owner-uid"],
+ finalReport: null,
+ impact: null,
+ status: "EnEjecucion",
+ });
+ await setDoc(doc(db, "projects/p_feat_admin_role"), {
+ termId: "2026",
+ title: "Destacable (Admin sin perms)",
+ roster: { directorId: "m1", coDirectorIds: [], teamIds: [] },
+ directionUids: ["owner-uid"],
+ finalReport: null,
+ impact: null,
+ status: "EnEjecucion",
+ });
+ // ONE pristine (featured absent) target per curation DENY test. They must not share a
+ // doc: if any deny regresses, its write persists featured:true and every later deny on
+ // the same doc turns vacuous — it would then pass as an unchanged echo rather than as a
+ // real denial. Measured, not theorised: with a shared doc the manage:all and
+ // manage:Showcase denials both passed against the OLD role-keyed gate.
+ for (const id of ["p_feat_deny_stale", "p_feat_deny_mgrall", "p_feat_deny_inert"]) {
+ await setDoc(doc(db, `projects/${id}`), {
+ termId: "2026",
+ title: "No destacable",
+ roster: { directorId: "m1", coDirectorIds: [], teamIds: [] },
+ directionUids: [],
+ finalReport: null,
+ impact: null,
+ status: "EnEjecucion",
+ });
+ }
// Finalized targets for the featured quick-toggle (curation happens after
// completion). finalReport + impact are non-null so a featured-only write
// survives finalizedRequiresReport() / initiativeWriteSafe(). Two docs, like
@@ -522,6 +577,206 @@ beforeAll(async () => {
active: true,
deletedAt: null,
});
+ // The members-positions lane's own principal (perms-only `update:Position`, no built-in
+ // role) has a member record of its own — the accepted-exposure test assigns a grant-free
+ // JDL board cargo to THIS doc, i.e. to itself.
+ await setDoc(doc(db, "members/m_orgchart"), {
+ name: "Gabriela",
+ totalPoints: 0,
+ uid: ORG_CHART,
+ active: true,
+ deletedAt: null,
+ });
+ // A member whose POWER cargo sits under a PRIOR term key, leaving the CURRENT term slot
+ // empty. Pins the term-rollover residual (docs/specs/position-assignment-lane.md,
+ // "Residual: the term-rollover window"): currentCargoGrantsEmpty() reads only
+ // positions[currentTermKey()], so it short-circuits on `prior == null` and never sees
+ // this cargo. Its own doc — m_powercargo holds its power cargo in the CURRENT term and
+ // is what proves the guard DOES fire, so the two must not share a fixture.
+ await setDoc(doc(db, "members/m_priorterm_power"), {
+ name: "Lucía",
+ totalPoints: 0,
+ uid: "priorterm-uid",
+ active: true,
+ deletedAt: null,
+ positions: { [PRIOR_TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" } },
+ });
+ // Full-payload targets for the positions catalog arm. toPositionUpdateDoc
+ // (apps/backstage/src/features/positions/repositories/position-mapper.ts:14) spreads the
+ // WHOLE PositionInput on every save, so the non-Admin arm has to pass that seven-field
+ // shape — not just the `{ description }` partial the category-pin test writes. One board
+ // cargo (labels pinned) and one comisión (labels open), each its own doc so the success
+ // cases cannot disturb pos1/pos_cat/pos_soft.
+ await setDoc(doc(db, "positions/pos_payload"), {
+ title: "Director de Prensa",
+ titleFemale: "Directora de Prensa",
+ sigla: null,
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Prensa.",
+ active: true,
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "positions/com_payload"), {
+ title: "Comisión de Prensa",
+ titleFemale: null,
+ sigla: "CP",
+ category: "Comision",
+ grants: [],
+ term: null,
+ description: "Prensa.",
+ active: true,
+ deletedAt: null,
+ });
+ // Category-mutation target for the positions catalog arm. Its own doc so the Admin
+ // success case cannot disturb pos1/pos_soft, which half this suite reads.
+ await setDoc(doc(db, "positions/pos_cat"), {
+ title: "Director de Membresía",
+ titleFemale: "Directora de Membresía",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Dirección de membresía.",
+ active: true,
+ deletedAt: null,
+ });
+ // The ADMIN category-change success case gets its own doc, away from pos_cat: the suite
+ // seeds once with no reset, so an Admin writing category: 'CEL' onto pos_cat would turn
+ // the BLOCKING non-Admin denial (which writes that same value) into an unchanged echo —
+ // unchanged('category') true, arm ALLOWS — leaving that denial green only by
+ // declaration order. Same shape as pos_cat so the two tests differ in principal only.
+ await setDoc(doc(db, "positions/pos_cat_admin"), {
+ title: "Director de Capacitación",
+ titleFemale: "Directora de Capacitación",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Dirección de capacitación.",
+ active: true,
+ deletedAt: null,
+ });
+ // A LEGACY cargo with no `category` key at all — the doc shape the create and update
+ // arms used to disagree about. `!boardSurfacingCategory()` reads it as non-board (labels
+ // open), matching what the create arm has always said; the hand-rolled `== 'Comision'`
+ // it replaced read it as a board cargo and pinned its labels shut.
+ await setDoc(doc(db, "positions/pos_nocategory"), {
+ title: "Comisión Heredada",
+ titleFemale: null,
+ sigla: "CH",
+ grants: [],
+ term: null,
+ description: "Sin categoría.",
+ active: true,
+ deletedAt: null,
+ });
+ // The ADMIN title-change success case likewise: retitling pos_payload would silently
+ // turn the non-Admin full-payload echo into a title CHANGE and flip that success red.
+ await setDoc(doc(db, "positions/pos_payload_admin"), {
+ title: "Director de Finanzas",
+ titleFemale: "Directora de Finanzas",
+ sigla: null,
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Finanzas.",
+ active: true,
+ deletedAt: null,
+ });
+ // Malformed soft-delete state, seeded through the admin SDK because no client arm can
+ // produce it any more (B2). These are the legacy shapes B1's well-formedness prefix
+ // moves from client-editable to admin-SDK-only — the whole reason owner-op 4 is a
+ // blocking pre-deploy audit. m_badactive additionally carries publicProfile: true so
+ // the Admin TAKEDOWN arm — the one arm that does not call softDeleteSafe() — can be
+ // proven still open on it. That arm is the only rules-level remedy for these docs.
+ await setDoc(doc(db, "members/m_badactive"), {
+ name: "Hilda",
+ totalPoints: 0,
+ uid: "badactive-uid",
+ publicProfile: true,
+ active: "false",
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "members/m_noactive"), {
+ name: "Irene",
+ totalPoints: 0,
+ deletedAt: null,
+ });
+ // active is a well-formed bool, so the self lane's own `active == true` check passes
+ // and ('deletedAt' in d) is the only thing left to deny — isolation, not a pile-up.
+ await setDoc(doc(db, "members/m_nodeletedat"), {
+ name: "Julio",
+ totalPoints: 0,
+ uid: "nodeletedat-uid",
+ active: true,
+ });
+ // Same shape as m_nodeletedat, its own doc: the repair-asymmetry test WRITES the missing
+ // key, and sharing a fixture with the denial tests above would couple them to order.
+ await setDoc(doc(db, "members/m_nodeletedat_fix"), {
+ name: "Karina",
+ totalPoints: 0,
+ active: true,
+ });
+ await setDoc(doc(db, "positions/pos_badactive"), {
+ title: "Vocal Suplente",
+ titleFemale: "Vocal Suplente",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Malformado.",
+ active: "false",
+ deletedAt: null,
+ });
+ // The missing-field twins for positions and allies. softDeleteSafe() is ONE helper
+ // shared by four lanes, but "shared helper" is a claim until each lane's denial is
+ // measured — members alone had the missing-active / missing-deletedAt tests. The
+ // *_nodeletedat_fix docs are separate because the repair-asymmetry test WRITES the
+ // missing key (a mutating success), and the denial tests must keep reading a doc
+ // that still lacks it.
+ await setDoc(doc(db, "positions/pos_noactive"), {
+ title: "Vocal Segundo",
+ titleFemale: "Vocal Segunda",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Sin active.",
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "positions/pos_nodeletedat"), {
+ title: "Vocal Tercero",
+ titleFemale: "Vocal Tercera",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Sin deletedAt.",
+ active: true,
+ });
+ await setDoc(doc(db, "positions/pos_nodeletedat_fix"), {
+ title: "Vocal Cuarto",
+ titleFemale: "Vocal Cuarta",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Sin deletedAt, reparable.",
+ active: true,
+ });
+ await setDoc(doc(db, "allies/a_badactive"), {
+ companyName: "Malformada",
+ active: "false",
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "allies/a_noactive"), {
+ companyName: "Sin active",
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "allies/a_nodeletedat"), {
+ companyName: "Sin deletedAt",
+ active: true,
+ });
+ await setDoc(doc(db, "allies/a_nodeletedat_fix"), {
+ companyName: "Sin deletedAt, reparable",
+ active: true,
+ });
await setDoc(doc(db, "positions/pos_soft"), {
title: "Vocal",
titleFemale: "Vocal",
@@ -532,6 +787,32 @@ beforeAll(async () => {
active: true,
deletedAt: null,
});
+ // The artifact that made "seeded CEL cargos all carry grants" the wrong kind of defense:
+ // a grant-free CEL cargo at statutory rank 0. An Admin can mint one (the create arm
+ // permits it, and "allows an Admin to create a CEL cargo" does exactly that as
+ // mint_cel_admin). Seeded here rather than reusing that test's output so the
+ // cargoAssignableByNonAdmin() probes below cannot pass or fail on declaration order.
+ await setDoc(doc(db, "positions/pos_cel_free"), {
+ title: "Presidente",
+ titleFemale: "Presidenta",
+ sigla: null,
+ category: "CEL",
+ grants: [],
+ term: null,
+ description: "Preside el capítulo.",
+ active: true,
+ deletedAt: null,
+ });
+ // Assignment target for the Admin half of the CEL probe. Its own doc: the Admin case
+ // SUCCEEDS, so sharing m_positions would leave a CEL cargo on a doc four other lane
+ // tests write, and the next currentCargoGrantsEmpty() read would answer about it.
+ await setDoc(doc(db, "members/m_celadmin"), {
+ name: "Renata",
+ totalPoints: 0,
+ uid: "celadmin-uid",
+ active: true,
+ deletedAt: null,
+ });
await setDoc(doc(db, "showcase/s1"), { id: "s1", kind: "Project", title: "Eco" });
await setDoc(doc(db, "boardShowcase/b1"), {
id: "b1",
@@ -607,7 +888,11 @@ describe("firestore.rules — members", () => {
});
it("allows Membership to create with totalPoints 0", async () => {
await assertSucceeds(
- setDoc(doc(as("u", ["Membership"]), "members/new1"), { name: "Bruno Paz", totalPoints: 0 }),
+ setDoc(doc(as("u", ["Membership"]), "members/new1"), {
+ name: "Bruno Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ }),
);
});
it("denies create with publicProfile pre-set (consent is not institutionally stamped)", async () => {
@@ -618,6 +903,7 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("u", ["Membership"]), "members/new_consent"), {
name: "Bruno Paz",
totalPoints: 0,
+ ...BORN_LIVE,
publicProfile: true,
}),
);
@@ -625,8 +911,12 @@ describe("firestore.rules — members", () => {
it("denies create with publicProfile explicitly false (no client owns this key)", async () => {
await assertFails(
setDoc(doc(as("u", ["Membership"]), "members/new_consent_false"), {
- name: "B",
+ // "Bruno Paz", not "B": a one-character name is below memberNameValid()'s floor,
+ // so the old payload was already denied for the NAME — the publicProfile guard
+ // this test is named for never got to speak.
+ name: "Bruno Paz",
totalPoints: 0,
+ ...BORN_LIVE,
publicProfile: false,
}),
);
@@ -634,8 +924,9 @@ describe("firestore.rules — members", () => {
it("denies create with publicProfile null (an explicit null still counts as present)", async () => {
await assertFails(
setDoc(doc(as("u", ["Membership"]), "members/new_consent_null"), {
- name: "B",
+ name: "Bruno Paz",
totalPoints: 0,
+ ...BORN_LIVE,
publicProfile: null,
}),
);
@@ -670,12 +961,20 @@ describe("firestore.rules — members", () => {
});
it("denies create when totalPoints != 0", async () => {
await assertFails(
- setDoc(doc(as("u", ["Membership"]), "members/new2"), { name: "Bruno Paz", totalPoints: 5 }),
+ setDoc(doc(as("u", ["Membership"]), "members/new2"), {
+ name: "Bruno Paz",
+ totalPoints: 5,
+ ...BORN_LIVE,
+ }),
);
});
it("denies a non-admin/non-membership role from creating", async () => {
await assertFails(
- setDoc(doc(as("u", ["Treasury"]), "members/new3"), { name: "Bruno Paz", totalPoints: 0 }),
+ setDoc(doc(as("u", ["Treasury"]), "members/new3"), {
+ name: "Bruno Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ }),
);
});
it("BLOCKING: denies Membership creating with a forged assignedBy + power cargo (escalation on create)", async () => {
@@ -683,6 +982,7 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("mem-uid", ["Membership"]), "members/new_evil"), {
name: "Evil",
totalPoints: 0,
+ ...BORN_LIVE,
positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-victim-uid" } },
}),
);
@@ -692,6 +992,7 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("mem-uid", ["Membership"]), "members/new_uid"), {
name: "Ximena Paz",
totalPoints: 0,
+ ...BORN_LIVE,
uid: "mem-uid",
}),
);
@@ -701,6 +1002,7 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("mem-uid", ["Membership"]), "members/new_pow"), {
name: "Ximena Paz",
totalPoints: 0,
+ ...BORN_LIVE,
positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "mem-uid" } },
}),
);
@@ -710,15 +1012,54 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("mem-uid", ["Membership"]), "members/new_ok"), {
name: "Ximena Paz",
totalPoints: 0,
+ ...BORN_LIVE,
positions: { [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "mem-uid" } },
}),
);
});
+ it("BLOCKING: denies Membership creating a member holding a grant-free CEL cargo", async () => {
+ // The create-arm mirror of "denies the update:Position lane assigning a GRANT-FREE CEL
+ // cargo". pos_cel_free has grants: [], so the grants half says yes; only the `category
+ // != 'CEL'` conjunct of cargoAssignableByNonAdmin() denies it — which the create arm
+ // only asks because it shares that predicate with positionsAssignmentSafe().
+ // What it stops: manage:Member satisfies canDo('create','Member'), so a Membership
+ // holder could mint a member BORN at board rank 0 as Presidente, self-stamped. `uid` is
+ // forbidden at create, so the doc is unpublished at birth — but onMemberCreated stamps
+ // publicProfile, the creator owns name/profilePicture, and one routine Admin
+ // provisionMemberLogin later supplies the uid, at which point projectBoard publishes it.
+ // If this goes green, the create arm has drifted back off the shared predicate.
+ await assertFails(
+ setDoc(doc(as("mem-uid", ["Membership"]), "members/new_cel_free"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "mem-uid" },
+ },
+ }),
+ );
+ });
+ it("allows Admin creating a member on a grant-free CEL cargo (the authority, not the delegate)", async () => {
+ // The paired ALLOW: the CEL conjunct lives inside the non-Admin branch of the create arm
+ // too, so seating the CEL at create stays possible — for an Admin only. Without this,
+ // a create arm that denied everyone would pass the test above for the wrong reason.
+ await assertSucceeds(
+ setDoc(doc(as("admin-uid", ["Admin"]), "members/new_cel_admin"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "admin-uid" },
+ },
+ }),
+ );
+ });
it("allows Admin creating with a power cargo + self assignedBy", async () => {
await assertSucceeds(
setDoc(doc(as("admin-uid", ["Admin"]), "members/new_admin"), {
name: "Ximena Paz",
totalPoints: 0,
+ ...BORN_LIVE,
positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" } },
}),
);
@@ -742,6 +1083,7 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("u", ["Membership"]), "members/new_formula"), {
name: '=HYPERLINK("http://evil")',
totalPoints: 0,
+ ...BORN_LIVE,
}),
);
});
@@ -750,12 +1092,17 @@ describe("firestore.rules — members", () => {
setDoc(doc(as("u", ["Membership"]), "members/new_digits"), {
name: "Ana Rivas 2",
totalPoints: 0,
+ ...BORN_LIVE,
}),
);
});
it("denies Membership creating a member with a name below the length floor", async () => {
await assertFails(
- setDoc(doc(as("u", ["Membership"]), "members/new_short"), { name: "Al", totalPoints: 0 }),
+ setDoc(doc(as("u", ["Membership"]), "members/new_short"), {
+ name: "Al",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ }),
);
});
it("denies Membership renaming a member to a formula-shaped name", async () => {
@@ -1798,7 +2145,7 @@ describe("firestore.rules — positions", () => {
});
it("denies Membership creating positions", async () => {
await assertFails(
- setDoc(doc(as("u", ["Membership"]), "positions/new2"), { title: "X", active: true }),
+ setDoc(doc(as("u", ["Membership"]), "positions/new2"), { title: "X", ...BORN_LIVE }),
);
});
it("denies resurrecting a soft-deleted position", async () => {
@@ -1867,6 +2214,164 @@ describe("firestore.rules — positions", () => {
updateDoc(doc(as("u", ["Admin"]), "positions/pos1"), { category: "Comision" }),
);
});
+ it("BLOCKING: denies a non-Admin update:Position holder changing a position's category", async () => {
+ // The escalation this closes, now that the members-positions lane exists: `grants` was
+ // the only field pinned for a non-Admin, so an update:Position holder could retitle a
+ // grant-free Comisión to { category: 'CEL', title: 'Presidente' } — boardRank 0 — and
+ // then assign it to themselves on the public Directiva, with no Admin action anywhere.
+ // category also decides board GROUP and whether comisionGrantsEmpty() applies at all.
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_cat"), {
+ category: "CEL",
+ }),
+ );
+ // The rest of the catalog stays writable for them — this pins one field, not the arm.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "positions/pos_cat"), {
+ description: "Actualizada.",
+ }),
+ );
+ });
+ it("allows an Admin to change a position's category (the surviving authority)", async () => {
+ // pos_cat_admin, NOT pos_cat: writing CEL onto pos_cat would make the BLOCKING denial
+ // above an unchanged echo — allowed — for every run after this one (seed-once suite).
+ // Measured: with the shared fixture, running this test FIRST fails that denial.
+ await assertSucceeds(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_cat_admin"), { category: "CEL" }),
+ );
+ });
+ // ── The production payload against the title/category pins ────────────────────────────
+ // toPositionUpdateDoc (apps/backstage/src/features/positions/repositories/
+ // position-mapper.ts:14) spreads the WHOLE PositionInput on every save: seven fields,
+ // no active/deletedAt. The `{ description }` partial the category-pin test writes never
+ // proves that shape passes the arm — the exact way a rules pin ships and 403s the first
+ // real save in production. These payload literals mirror the pos_payload/com_payload
+ // seeds field-for-field so an unchanged spread is a true echo.
+ const boardPayload = () => ({
+ title: "Director de Prensa",
+ titleFemale: "Directora de Prensa",
+ sigla: null,
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Prensa.",
+ });
+ const comisionPayload = () => ({
+ title: "Comisión de Prensa",
+ titleFemale: null,
+ sigla: "CP",
+ category: "Comision",
+ grants: [],
+ term: null,
+ description: "Prensa.",
+ });
+ it("allows a non-Admin update:Position holder the full production payload (pins unchanged)", async () => {
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "positions/pos_payload"), {
+ ...boardPayload(),
+ description: "Prensa y comunicación.",
+ }),
+ );
+ });
+ it("BLOCKING: denies the full payload when it changes the category", async () => {
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_payload"), {
+ ...boardPayload(),
+ category: "CEL",
+ term: null,
+ }),
+ );
+ });
+ it("BLOCKING: denies the full payload retitling a board cargo (either label)", async () => {
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_payload"), {
+ ...boardPayload(),
+ title: "Presidente",
+ }),
+ );
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_payload"), {
+ ...boardPayload(),
+ titleFemale: "Presidenta",
+ }),
+ );
+ });
+ it("lets a non-Admin retitle a legacy cargo that has no category key (both arms agree)", async () => {
+ // The create arm asks `!boardSurfacingCategory()` and the update arm now asks the same
+ // question, so a doc whose `category` is absent (or an unrecognized value) is non-board
+ // on BOTH — creatable by a non-Admin, therefore editable by one. Under the
+ // `== 'Comision'` escape this write was DENIED: fail-closed, so never a hole, but it
+ // locked the labels of exactly the legacy docs an org-chart editor is meant to clean up.
+ // Falsifiable both ways: restore `== 'Comision'` and this goes red; drop the escape's
+ // guard entirely and "denies the full payload retitling a board cargo" goes red.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "positions/pos_nocategory"), {
+ title: "Comisión de Actas",
+ titleFemale: "Comisión de Actas",
+ }),
+ );
+ });
+ it("allows the full payload retitling a comisión (the branch the pin leaves open)", async () => {
+ // On a comisión a title is a label, not an authority field — comisiones never reach
+ // boardGroupFromCategory() — so the org-chart editor's rename use case stays open.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "positions/com_payload"), {
+ ...comisionPayload(),
+ title: "Comisión de Comunicación",
+ }),
+ );
+ });
+ it("allows an Admin to retitle a board cargo via the full payload (the surviving authority)", async () => {
+ // pos_payload_admin, not pos_payload: retitling pos_payload would flip the non-Admin
+ // echo success above into a title change on later runs (seed-once suite).
+ await assertSucceeds(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_payload_admin"), {
+ title: "Director de Tesorería",
+ titleFemale: "Directora de Tesorería",
+ sigla: null,
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Finanzas.",
+ }),
+ );
+ });
+ // The CREATE-side twin of the category pin above. Pinning only the update arm left the
+ // same outcome one door over: MINT a grant-free CEL cargo instead of retitling one.
+ // grants:[] passes the power-grant check, so `grants` alone never blocked this — and
+ // "seeded CEL cargos all carry grants" is true only of SEEDED ones.
+ const posCreator = () => as("poscreate-uid", [], ["create:Position"]);
+ const boardCargo = (category: string) => ({
+ title: "Presidente",
+ titleFemale: null,
+ sigla: null,
+ category,
+ term: category === "JDL" ? 2026 : null,
+ grants: [],
+ description: "",
+ ...BORN_LIVE,
+ });
+ it("BLOCKING: denies a non-Admin create:Position holder minting a CEL cargo", async () => {
+ await assertFails(setDoc(doc(posCreator(), "positions/mint_cel"), boardCargo("CEL")));
+ });
+ it("BLOCKING: denies a non-Admin create:Position holder minting a JDL dirección", async () => {
+ await assertFails(setDoc(doc(posCreator(), "positions/mint_jdl"), boardCargo("JDL")));
+ });
+ it("still allows a non-Admin create:Position holder to create a comisión", async () => {
+ // Pins that the fix narrows the board categories only — it does not close the arm.
+ await assertSucceeds(
+ setDoc(doc(posCreator(), "positions/mint_com"), {
+ ...boardCargo("Comision"),
+ title: "Comisión de Prensa",
+ sigla: "CP",
+ }),
+ );
+ });
+ it("allows an Admin to create a CEL cargo (the surviving authority)", async () => {
+ await assertSucceeds(
+ setDoc(doc(as("admin-uid", ["Admin"]), "positions/mint_cel_admin"), boardCargo("CEL")),
+ );
+ });
// Deliberate fail-closed: a legacy power comisión (possible before the
// invariant) is client-unwritable — even soft-delete — until an admin-SDK/
// console repair empties its grants. Documented in the design spec.
@@ -1950,8 +2455,9 @@ describe("firestore.rules — activity parent-initiative direction", () => {
});
describe("firestore.rules — initiative featured create-gate", () => {
- // Curation authority is the Admin/ProjectManager ROLE; a custom role holding only
- // the create perm may create initiatives but never born-featured ones.
+ // Curation authority is canCurateFeatured(): the Admin ROLE or the update:Showcase PERM
+ // (seeded onto ProjectManager). A custom role holding only the create perm may create
+ // initiatives but never born-featured ones.
function asCustom(uid: string, perms: string[]) {
return env.authenticatedContext(uid, { roles: ["Member"], perms }).firestore();
}
@@ -2032,6 +2538,78 @@ describe("firestore.rules — initiative featured create-gate", () => {
});
});
+// canCurateFeatured() is `hasAnyRole(['Admin']) || hasPerm('update:Showcase')`. Admin stays
+// role-keyed (locked + undeactivatable, so its name carries no staleness); everyone else is
+// perm-keyed, so DEACTIVATING a role now revokes curation — computeMemberRoles is pure over
+// the trusted grants and reads no role doc, so the NAME survives a deactivation in the claim
+// while the perms do not.
+//
+// Every test here targets a doc the principal can otherwise update (a direction uid, or an
+// initiative update perm) and is paired with a non-featured write that SUCCEEDS, so a denial
+// is the curation gate and never a missing update perm.
+describe("firestore.rules — featured curation is perm-keyed (canCurateFeatured)", () => {
+ it("denies a DEACTIVATED ProjectManager setting featured (stale role name in the claim)", async () => {
+ // The whole point of D: roles claim still says ProjectManager, perms carry no
+ // update:Showcase because the role doc is inactive. manage:Project comes from some
+ // other live role, so the write clears every conjunct except the curation gate.
+ const stale = as("pm-stale-uid", ["ProjectManager"], ["manage:Project"]);
+ await assertFails(updateDoc(doc(stale, "projects/p_feat_deny_stale"), { featured: true }));
+ await assertSucceeds(
+ updateDoc(doc(stale, "projects/p_feat_deny_stale"), { title: "No destacable (PM inactivo)" }),
+ );
+ });
+
+ it("denies a manage:all PERM holder with no Admin role setting featured", async () => {
+ // manage:all is reachable as a perm without the Admin role (a custom role doc or a
+ // permissionOverrides.grant can carry it — roleShapeValid() only requires a list). The
+ // gate uses exact hasPerm, NOT canDo, so manage:all does not satisfy update:Showcase.
+ const superPerm = as("mgr-all-uid", ["Member"], ["manage:all"]);
+ await assertFails(updateDoc(doc(superPerm, "projects/p_feat_deny_mgrall"), { featured: true }));
+ await assertSucceeds(
+ updateDoc(doc(superPerm, "projects/p_feat_deny_mgrall"), {
+ title: "No destacable (manage:all)",
+ }),
+ );
+ });
+
+ it("denies a manage:Showcase holder setting featured (the inert codes stay inert)", async () => {
+ // The other five *:Showcase codes gate nothing. manage:Showcase is inert BECAUSE the
+ // gate is exact hasPerm — there is no second, undocumented path to curation.
+ const inert = as("mgr-showcase-uid", ["Member"], ["manage:Project", "manage:Showcase"]);
+ await assertFails(updateDoc(doc(inert, "projects/p_feat_deny_inert"), { featured: true }));
+ await assertSucceeds(
+ updateDoc(doc(inert, "projects/p_feat_deny_inert"), {
+ title: "No destacable (manage:Showcase)",
+ }),
+ );
+ });
+
+ it("allows a custom role holding update:Showcase to curate, with no Admin/PM role", async () => {
+ await assertSucceeds(
+ updateDoc(
+ doc(
+ as("cust-showcase-uid", ["Member"], ["update:Project", "update:Showcase"]),
+ "projects/p_feat_showcase",
+ ),
+ {
+ featured: true,
+ },
+ ),
+ );
+ });
+
+ it("allows Admin with an EMPTY perms claim to curate (the role disjunct)", async () => {
+ // Real shape: a member over PERMISSION_CAP gets perms:[] written while keeping
+ // roles:['Admin']. The uid is a direction uid, so the update itself is allowed and the
+ // only question left is curation authority.
+ await assertSucceeds(
+ updateDoc(doc(as("owner-uid", ["Admin"], []), "projects/p_feat_admin_role"), {
+ featured: true,
+ }),
+ );
+ });
+});
+
describe("firestore.rules — activity lock (hasCheckIns)", () => {
it("has a drift value for every field activityLockSafe() locks (no probe lags the rules)", () => {
expect(RULES_LOCKED_FIELDS.length).toBeGreaterThan(0);
@@ -2275,6 +2853,159 @@ describe("firestore.rules — member positions assignment", () => {
}),
);
});
+ // ── The members-positions lane: canDo('update','Position') + hasOnly(['positions']) ──
+ // Principal: the module-scoped orgChart() / ORG_CHART above.
+ it("allows an update:Position-only principal to assign a grant-free cargo, self-stamped", async () => {
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("BLOCKING: denies the update:Position lane assigning a power-conferring cargo (new side)", async () => {
+ // positionsAssignmentSafe() is reused verbatim: its non-Admin branch demands
+ // cargoAssignableByNonAdmin(), and pos1 grants Treasury. Without it this lane would
+ // mint claims.
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos1", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("BLOCKING: denies the update:Position lane replacing a power cargo with a grant-free one (old side)", async () => {
+ // The de-elevation attack from the OTHER direction: m_powercargo holds pos1, so only
+ // currentCargoGrantsEmpty() — the old-side half — stops this lane from stripping a
+ // claim. assertFails, so m_powercargo stays intact for the C1 describe below.
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_powercargo"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("BLOCKING: denies the update:Position lane touching any non-positions field in the same write", async () => {
+ // hasOnly(['positions']) is what confines this lane to the org chart. A ride-along
+ // `name` (or roleIds, or totalPoints) must drop the whole write off the arm.
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ name: "Renombrado",
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("denies the update:Position lane a forged assignedBy", async () => {
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "admin-uid" },
+ }),
+ );
+ });
+
+ it("denies the update:Position lane a non-current-term write", async () => {
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ "positions.2099": { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("denies the update:Position lane creating a member (create stays canDo('create','Member'))", async () => {
+ // createPositionsSafe() cannot call currentCargoGrantsEmpty() — a create has no prior
+ // resource — and there is no old side to protect, so creation was deliberately not
+ // widened. An org-chart editor may not mint member records.
+ await assertFails(
+ setDoc(doc(orgChart(), "members/m_orgchart_new"), {
+ name: "Nuevo",
+ totalPoints: 0,
+ active: true,
+ deletedAt: null,
+ positions: { [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART } },
+ }),
+ );
+ });
+
+ it("denies the update:Position lane wiping the current term — whole-map replacement and deleteField", async () => {
+ // Both currently deny via assignedBySelf(): a positions map with no current-term key
+ // reads assignedBy '' != uid. That is INCIDENTAL — nothing else in the arm asks whether
+ // an assignment was removed — so pin it, or a future refactor of assignedBySelf() drops
+ // the clear-a-cargo guard without a red test.
+ await assertFails(updateDoc(doc(orgChart(), "members/m_positions"), { positions: {} }));
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), { positions: deleteField() }),
+ );
+ });
+
+ it("BLOCKING: denies the update:Position lane assigning a GRANT-FREE CEL cargo", async () => {
+ // The publication half of cargoAssignableByNonAdmin(), and the conjunct this test
+ // exists for. pos_cel_free carries grants: [], so the grants half of that predicate —
+ // the whole non-Admin check before this branch — says yes; only `category != 'CEL'`
+ // denies it.
+ // What it stops: boardRank maps 'Presidente' to 0, so this write would put the
+ // org-chart editor at the head of the world-readable Directiva as chapter president.
+ // If this goes green, neutralize nothing and re-read the rule: the CEL conjunct is gone.
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("still allows the update:Position lane a grant-free JDL dirección (the accepted exposure survives)", async () => {
+ // The paired ALLOW, adjacent on purpose: it is what proves the denial above is the CEL
+ // conjunct and not the lane closing on grant-free board cargos generally. pos_soft is
+ // JDL with grants: [] — byte-identical to pos_cel_free but for `category`.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
+ it("leaves Admin unaffected — an Admin may still assign a grant-free CEL cargo", async () => {
+ // The conjunct lives inside the non-Admin branch only. Seating the CEL is an Admin
+ // decision on BOTH ends (the create arm already made minting one Admin-only); this pins
+ // that the fix narrowed the delegate, not the authority.
+ await assertSucceeds(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_celadmin"), {
+ [`positions.${TERM}`]: {
+ cargoId: "pos_cel_free",
+ comisionIds: [],
+ assignedBy: "admin-uid",
+ },
+ }),
+ );
+ });
+
+ it("BLOCKING: denies Membership assigning a grant-free CEL cargo too (the conjunct is not lane-local)", async () => {
+ // positionsAssignmentSafe() is shared by the institutional members arm, so a manage:Member
+ // holder is held to the same publication boundary — otherwise the fix would just move the
+ // door. m_positions, not m1: m1 ends this block holding a power cargo.
+ await assertFails(
+ updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "mem-uid" },
+ }),
+ );
+ });
+
+ it("ACCEPTED EXPOSURE: an update:Position holder may put a member — including itself — on the public Directiva", async () => {
+ // Deliberate, not an oversight (see docs/specs/position-assignment-lane.md, section A).
+ // pos_soft is a JDL dirección with grants: [], and boardGroupFromCategory publishes both
+ // CEL and JDL, so this write lands on the world-readable boardShowcase projection. It is
+ // not privilege escalation — resolveTrustedGrants returns early on grants.length === 0,
+ // so no claim is minted — but it IS a publication authority, and owner-op 1 says so in
+ // the same words. Its ceiling is JDL: CEL is Admin-only BY RULE whatever its grants
+ // (cargoAssignableByNonAdmin's `category != 'CEL'`), not because of what the seeded CEL
+ // cargos happen to hold — the test two above pins exactly that.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "members/m_orgchart"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+
// LAST in this block on purpose: the suite seeds once and never resets, and this write
// leaves members/m1 holding a power cargo. A Membership success case running after it
// would be denied by currentCargoGrantsEmpty() — the C1 guard — not by its own subject.
@@ -2289,8 +3020,9 @@ describe("firestore.rules — member positions assignment", () => {
describe("firestore.rules — replacing an already-assigned power cargo (C1)", () => {
it("BLOCKING: denies Membership replacing an Admin-granting cargo with a grant-free one", async () => {
- // The de-elevation attack: the NEW cargo is grant-free, so the old cargoGrantsEmpty()
- // check passed. currentCargoGrantsEmpty() is what looks at resource.data — the cargo
+ // The de-elevation attack: the NEW cargo is grant-free and not CEL, so the new-side
+ // cargoAssignableByNonAdmin() passed. currentCargoGrantsEmpty() looks at resource.data —
+ // the cargo
// being displaced — and denies the write.
await assertFails(
updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_powercargo"), {
@@ -2300,7 +3032,7 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", (
});
it("BLOCKING: denies Membership clearing an Admin-granting cargo to null", async () => {
- // cargoId: null makes cargoGrantsEmpty() short-circuit true; only the old-side guard
+ // cargoId: null makes cargoAssignableByNonAdmin() short-circuit true; only the old-side guard
// catches it.
await assertFails(
updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_powercargo"), {
@@ -2316,6 +3048,279 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", (
}),
);
});
+
+ it("ACCEPTED HOLE (term rollover): a prior-term power cargo does NOT guard the empty current term", async () => {
+ // Pins the shape docs/specs/position-assignment-lane.md documents under "Residual: the
+ // term-rollover window" — this assertSucceeds is the test that section promises, NOT a
+ // regression. currentCargoGrantsEmpty() reads only positions[currentTermKey()]:
+ // m_priorterm_power's Treasury-granting pos1 sits under the PRIOR term key, the current
+ // slot is empty, the guard short-circuits on `prior == null`, and an update:Position
+ // holder writes a grant-free cargo into the current term. claims-sync resolves from the
+ // same current-year key, so it recomputes roles: ['Member'] — the Treasury claim is gone.
+ // The grant is Treasury rather than Admin only because that is what pos1 seeds; the
+ // shape is "any cargo-conferred claim", and Admin sits in it the same way.
+ // Pre-existing (any manage:Member holder had it through the institutional arm) and
+ // deliberately NOT fixed here: closing it means resolving liveness across terms, its
+ // own pass. If this test goes RED, the guard grew cross-term eyes — delete this test
+ // and the Residual section together.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "members/m_priorterm_power"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART },
+ }),
+ );
+ });
+});
+
+// B: the four softDeleteSafe() lanes (members ×2 + the positions lane, positions, allies)
+// gained the same well-formedness prefix roleLifecycleSafe() already had, and their three
+// create arms gained the born-live requirement the roles create arm already had.
+//
+// The two halves are inseparable on purpose: the create arms stop new malformed docs, the
+// prefix stops client writes onto the ones already stored. Neither repairs anything — the
+// remedy for an existing malformed doc is the console, the admin SDK, or (for publication
+// specifically) the Admin takedown arm, which deliberately does NOT call softDeleteSafe()
+// and is pinned open at the bottom of this block.
+describe("firestore.rules — soft-delete well-formedness (B)", () => {
+ const secretary = () => as("sec-uid", ["Secretary"]);
+
+ it("denies creating a member with no active/deletedAt (the bare legacy shape)", async () => {
+ // Same payload as "allows Membership to create with totalPoints 0" minus BORN_LIVE —
+ // so this isolates the new create conjuncts and nothing else.
+ await assertFails(
+ setDoc(doc(as("u", ["Membership"]), "members/new_bare"), {
+ name: "Bruno Paz",
+ totalPoints: 0,
+ }),
+ );
+ });
+ it("denies creating a position with no active/deletedAt", async () => {
+ await assertFails(
+ setDoc(doc(as("admin-uid", ["Admin"]), "positions/new_bare"), {
+ title: "Director de Finanzas",
+ titleFemale: "Directora de Finanzas",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Finanzas.",
+ }),
+ );
+ });
+ it("denies creating an ally with no active/deletedAt", async () => {
+ await assertFails(
+ setDoc(doc(secretary(), "allies/a_bare"), { companyName: "Sin ciclo de vida" }),
+ );
+ });
+
+ it("denies a member born soft-deleted (active false / deletedAt already stamped)", async () => {
+ // A doc born dead is invisible to every list yet occupies its id; a doc born with a
+ // deletedAt is the ghost shape (live to a `where`, dead to the pipeline). Neither is a
+ // state any client flow produces, so neither is client-creatable.
+ await assertFails(
+ setDoc(doc(as("u", ["Membership"]), "members/new_born_dead"), {
+ name: "Bruno Paz",
+ totalPoints: 0,
+ active: false,
+ deletedAt: null,
+ }),
+ );
+ await assertFails(
+ setDoc(doc(as("u", ["Membership"]), "members/new_born_ghost"), {
+ name: "Bruno Paz",
+ totalPoints: 0,
+ active: true,
+ deletedAt: DELETED_AT,
+ }),
+ );
+ });
+ it("denies a position born soft-deleted or born with a deletedAt", async () => {
+ const born = (extra: Record) => ({
+ title: "Director de Finanzas",
+ titleFemale: "Directora de Finanzas",
+ category: "JDL",
+ grants: [],
+ term: 2026,
+ description: "Finanzas.",
+ ...extra,
+ });
+ await assertFails(
+ setDoc(
+ doc(as("admin-uid", ["Admin"]), "positions/new_born_dead"),
+ born({ active: false, deletedAt: null }),
+ ),
+ );
+ await assertFails(
+ setDoc(
+ doc(as("admin-uid", ["Admin"]), "positions/new_born_ghost"),
+ born({ active: true, deletedAt: DELETED_AT }),
+ ),
+ );
+ });
+ it("denies an ally born soft-deleted or born with a deletedAt", async () => {
+ await assertFails(
+ setDoc(doc(secretary(), "allies/a_born_dead"), {
+ companyName: "Nacida muerta",
+ active: false,
+ deletedAt: null,
+ }),
+ );
+ await assertFails(
+ setDoc(doc(secretary(), "allies/a_born_ghost"), {
+ companyName: "Fantasma",
+ active: true,
+ deletedAt: DELETED_AT,
+ }),
+ );
+ });
+
+ // The stored-side half. A non-bool `active` is the exact ghost this change exists for: it
+ // is dropped by the zod doc schemas (invisible in backstage) while every `!== false` /
+ // `!= null` reader treats it as live — which is how it reached the public Directiva.
+ it("denies every client update to a member whose stored active is a non-bool", async () => {
+ await assertFails(
+ updateDoc(doc(as("u", ["Membership"]), "members/m_badactive"), { profession: "Arquitecta" }),
+ );
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { name: "Hilda Paz" }),
+ );
+ // The members-positions lane (A) calls softDeleteSafe() too — all four lanes close
+ // together, which is the point of putting the check in the helper.
+ await assertFails(
+ updateDoc(doc(orgChart(), "members/m_badactive"), {
+ [`positions.${TERM}`]: {
+ cargoId: "pos_soft",
+ comisionIds: [],
+ assignedBy: ORG_CHART,
+ },
+ }),
+ );
+ });
+ it("denies every client update to a position whose stored active is a non-bool", async () => {
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_badactive"), { title: "Vocal" }),
+ );
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_badactive"), {
+ title: "Vocal",
+ }),
+ );
+ });
+ it("denies every client update to an ally whose stored active is a non-bool", async () => {
+ await assertFails(
+ updateDoc(doc(secretary(), "allies/a_badactive"), { companyName: "Reparada" }),
+ );
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_badactive"), {
+ companyName: "Reparada",
+ }),
+ );
+ });
+
+ it("denies updating a member whose stored doc has no active key", async () => {
+ await assertFails(
+ updateDoc(doc(as("u", ["Membership"]), "members/m_noactive"), { profession: "Bióloga" }),
+ );
+ });
+ it("denies updating a member whose stored doc has no deletedAt key", async () => {
+ await assertFails(
+ updateDoc(doc(as("u", ["Membership"]), "members/m_nodeletedat"), { profession: "Biólogo" }),
+ );
+ // active: true is well-formed here, so the self lane's own `active == true` check passes
+ // and the missing deletedAt is the only thing denying — the /me lane is closed too.
+ await assertFails(
+ updateDoc(doc(as("nodeletedat-uid", ["Member"]), "members/m_nodeletedat"), {
+ profession: "Biólogo",
+ }),
+ );
+ });
+ // The prefix is ONE helper across four lanes, but that sharing is a claim until each
+ // lane's denial is measured — only members had the missing-field tests. Same principals
+ // as the non-bool-active tests above, so the missing key is the only variable.
+ it("denies updating a position whose stored doc has no active key", async () => {
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_noactive"), { title: "Vocal II" }),
+ );
+ await assertFails(
+ updateDoc(doc(orgChart(), "positions/pos_noactive"), { description: "Editada." }),
+ );
+ });
+ it("denies updating a position whose stored doc has no deletedAt key", async () => {
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_nodeletedat"), {
+ title: "Vocal III",
+ }),
+ );
+ });
+ it("denies updating an ally whose stored doc has no active key", async () => {
+ await assertFails(updateDoc(doc(secretary(), "allies/a_noactive"), { companyName: "Editada" }));
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_noactive"), { companyName: "Editada" }),
+ );
+ });
+ it("denies updating an ally whose stored doc has no deletedAt key", async () => {
+ await assertFails(
+ updateDoc(doc(secretary(), "allies/a_nodeletedat"), { companyName: "Editada" }),
+ );
+ });
+
+ it("pins which malformed field a client can repair and which it cannot", async () => {
+ // Stated so nobody reads the denials above as "just write the right value". The two
+ // halves differ, and the difference is exactly why owner-op 4 is a console/admin-SDK
+ // audit rather than a UI affordance:
+ // active — NOT client-repairable. The one-way half demands
+ // resource.data.active == true || unchanged('active'), and a stored
+ // non-bool satisfies neither once the write changes it.
+ // deletedAt — client-repairable by stamping the null the doc should have had:
+ // unchanged('deletedAt') reads .get(…, null) on both sides, so
+ // absent -> null is a no-op to the one-way half and the merged doc then
+ // carries the key. A missing deletedAt is therefore self-healing; a
+ // malformed active is not.
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { active: true }),
+ );
+ await assertSucceeds(
+ updateDoc(doc(as("u", ["Membership"]), "members/m_nodeletedat_fix"), { deletedAt: null }),
+ );
+ });
+ it("pins the same repair asymmetry on positions and allies (one helper, four lanes)", async () => {
+ // active — not repairable even by Admin, on any lane:
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_badactive"), { active: true }),
+ );
+ await assertFails(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_badactive"), { active: true }),
+ );
+ // deletedAt — self-healing by stamping the null the doc should have had:
+ await assertSucceeds(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_nodeletedat_fix"), {
+ deletedAt: null,
+ }),
+ );
+ await assertSucceeds(
+ updateDoc(doc(secretary(), "allies/a_nodeletedat_fix"), { deletedAt: null }),
+ );
+ });
+
+ it("keeps the Admin takedown arm open on a malformed member (the remedy path)", async () => {
+ // The single most important assertion in this block. The takedown arm deliberately does
+ // not call softDeleteSafe(), so it is the ONE rules-level path that can unpublish
+ // exactly the malformed member this change is about. Moving the well-formedness check
+ // onto the arms instead of into the helper would have removed the remedy with the
+ // disease.
+ await assertSucceeds(
+ updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { publicProfile: false }),
+ );
+ });
+
+ it("leaves the /me self lane working on an ordinary well-formed member", async () => {
+ // The merge half: on an update request.resource.data is the MERGED doc, so a write that
+ // touches neither field still satisfies the prefix when the stored doc is well-formed.
+ // Without this the prefix would have locked every member out of their own profile.
+ await assertSucceeds(
+ updateDoc(doc(as("carlos-uid", ["Member"]), "members/m_positions"), {
+ profession: "Ingeniero",
+ }),
+ );
+ });
});
describe("firestore.rules — roles collection", () => {
@@ -2528,9 +3533,12 @@ describe("firestore.rules — roles collection", () => {
});
it("allows Admin to reactivate a deactivated built-in role", async () => {
// softDeleteSafe() hard-blocks active:false -> true, which is why the roles lane
- // uses roleLifecycleSafe() instead. softDeleteSafe itself must not change: four
- // other collections depend on its one-way semantics and member resurrection is
- // pinned denied by "denies resurrecting a soft-deleted member" above.
+ // uses roleLifecycleSafe() instead. softDeleteSafe() has since gained this function's
+ // WELL-FORMEDNESS prefix (active present and a bool, deletedAt present) — see the
+ // "soft-delete well-formedness (B)" block above. What must not change is its ONE-WAY
+ // semantics: four other lanes depend on them and member resurrection is pinned denied
+ // by "denies resurrecting a soft-deleted member" above. The coupling half below
+ // (active == true => deletedAt == null) is still roles-only.
await assertSucceeds(
updateDoc(doc(as("admin-uid", ["Admin"]), "roles/inactive_builtin"), {
active: true,
diff --git a/tools/scripts/audit-soft-delete-shapes.mjs b/tools/scripts/audit-soft-delete-shapes.mjs
new file mode 100644
index 00000000..24733e68
--- /dev/null
+++ b/tools/scripts/audit-soft-delete-shapes.mjs
@@ -0,0 +1,648 @@
+// The BLOCKING pre-deploy audit for the soft-delete well-formedness rules (owner-op 4 of
+// docs/specs/position-assignment-lane.md). Those rules make a members/positions/allies doc
+// missing `active` or `deletedAt`, or holding a non-bool `active`, admin-SDK-only to edit —
+// so the malformed count must be known (ideally zero) BEFORE the rules deploy.
+//
+// What this script provides is a blocking gate plus COMPLETE detection. It is NOT a public
+// takedown tool: `--repair` removes no boardShowcase row, and the one direction it CAN move
+// the public surface — adding a row — is announced per doc and withheld behind
+// `--allow-publish` (see the `--repair` contract below):
+// - A repaired doc declares the member LIVE (`active: true` / `deletedAt: null`), so the
+// re-fired onBoardMemberWritten RE-PUBLISHES it. The row correctly stays up.
+// - The shape that actually matters — a non-bool `active`, the string "false" — is REFUSED,
+// so nothing is written, no trigger fires, and a row already published under the old
+// fail-open projection SURVIVES. That one is remediated by hand; the script's job is to
+// name it. The two remedies are printed per doc: a Firebase console edit of `active`, or
+// an Admin `publicProfile: false` write — the members takedown arm in firestore.rules
+// deliberately does not call `softDeleteSafe()` and stays open on exactly these docs
+// (pinned by rules.test.ts, "keeps the Admin takedown arm open on a malformed member").
+//
+// Note the new rules do NOT deny every client write to such a doc: that takedown arm is left
+// open on purpose. What makes the remedy awkward is the client side — `memberDocSchema` drops
+// a malformed member, so backstage never lists it and offers no affordance. The write has to
+// come from the console, this script, or a direct admin/Admin-authenticated write.
+//
+// Read-only by default; exits non-zero when anything is found so it can gate a deploy.
+// exit 1 → the run completed and found malformed docs (the gate)
+// exit 2 → the run did NOT complete (a per-doc read/write failed, or repair was refused
+// confirmation). Distinct on purpose: a crash must not look like a clean gate.
+// Every one of those codes is set with `process.exitCode` and reached by RETURNING out of
+// main(), never with process.exit(): on POSIX a piped stdout is an ASYNC write, and
+// process.exit() drops whatever is still buffered. This script prints a per-doc worklist and
+// then exits non-zero — exactly the shape that loses its tail the moment anyone pipes it, in
+// CI or through `| tee`. The exit code survived, the operator's worklist did not.
+//
+// `--repair` fixes ONLY the unambiguous shapes and refuses to guess:
+// deletedAt missing → deletedAt: null (the value the create arm would have
+// stamped; missing can only mean "never soft-deleted" —
+// a deletion always writes the timestamp)
+// active missing, deletedAt → active: true (never deleted ⇒ live; every fail-open
+// null/missing reader has treated the doc as live all along)
+// active present but non-bool → NEVER coerced, even the string "false" — whether that
+// doc was meant to be inactive is a human call. `active:
+// null` counts as present and is refused too, while a
+// MISSING `active` on the same nullish `deletedAt` is
+// repaired: an explicit null is a value somebody wrote and
+// may have meant, absence is just absence.
+// active missing, deletedAt → reported for a human (the two fields disagree about
+// non-null what state the doc is in)
+// active: true AND deletedAt → the GHOST: reported for a human, same disagreement as
+// non-null the row above, mirrored. Unlike the other shapes this one
+// is client-REACHABLE (softDeleteSafe permits it) and
+// memberDocSchema accepts it, so the doc renders as an
+// ordinary live member while deletedAt-aware readers treat
+// it as gone.
+// deletedAt present, non-null, → reported, never repaired: junk (an ISO string, a number)
+// not a Timestamp that the zod doc-schemas reject and the rules pin
+// immutable — invisible and unwritable at once.
+// Repair is all-or-nothing per doc, by design: when `active` is ambiguous the unambiguous
+// `deletedAt: null` is withheld too, so the human who resolves the doc sees the shape the
+// audit reported rather than one this script half-changed underneath them.
+//
+// `--repair` can also PUBLISH, and that is opt-in. Writing `active: true` un-blocks the
+// fail-CLOSED projectBoard gate, so a member who also has publicProfile: true (the stamped
+// org-wide default), a uid, a pinned portrait and a current-term CEL/JDL cargo is ADDED to
+// the world-readable Directiva by the re-fired trigger — a new publication nobody asked for,
+// as a side effect of a shape fix. Every such member gets a `WILL PUBLISH:` line, and their
+// repair is WITHHELD (counted apart from the ambiguous refusals) unless `--allow-publish` is
+// passed. The forecast fails safe: any gate it cannot settle is reported as unknown and the
+// member is still announced, never quietly repaired.
+//
+// Targets PRODUCTION via Application Default Credentials, like seed-production.mjs. A
+// production `--repair` writes to members and, through the trigger, to the world-readable
+// Directiva, so it demands an explicit confirmation (typed, or `--confirm=` for a
+// non-interactive shell) — the same posture as seed-production.mjs and the
+// `confirm: "overwrite-builtin-roles"` token on reseedBuiltInRolePerms. Adding
+// `--allow-publish` changes the token to `repair-production-shapes-and-publish`: the string
+// the operator types names the consequence, so the one flag that can ADD public exposure is
+// not the one the confirmation is silent about.
+// gcloud auth application-default login && pnpm audit:soft-delete-shapes
+// Or the emulator, by setting the env first (no confirmation there):
+// FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 pnpm audit:soft-delete-shapes
+import { initializeApp, applicationDefault } from "firebase-admin/app";
+import { getFirestore, FieldPath } from "firebase-admin/firestore";
+import { createInterface } from "node:readline/promises";
+
+const COLLECTIONS = ["members", "positions", "allies"];
+// Page size and the getAll fan-out both mirror the beacon chunk() bound (guardrail #5).
+// MAX_PRINT caps ONLY the benign listing — a repairable doc with no public exposure. Ids
+// that need a hand fix (refused) and every PUBLISHED / unknown-publication line always
+// print: this output is the operator's worklist, and truncating it would hide exactly the
+// docs that must be acted on. The counts are always complete.
+const PAGE = 300;
+const GETALL_CHUNK = 300;
+const MAX_PRINT = 20;
+const CONFIRM_TOKEN = "repair-production-shapes";
+/** `--allow-publish` is the one flag that can ADD world-readable exposure, so it gets its
+ * own token: the string the operator types has to NAME the consequence they are accepting,
+ * or the confirmation prompt is silent about the only irreversible half of the run. */
+const PUBLISH_CONFIRM_TOKEN = "repair-production-shapes-and-publish";
+const CONFIRM_FLAG = "--confirm=";
+/** Opt-in for the one repair that ADDS public exposure — see the publication forecast. */
+const ALLOW_PUBLISH_FLAG = "--allow-publish";
+
+const EXIT_MALFORMED = 1;
+const EXIT_INCOMPLETE = 2;
+
+const REPAIR = process.argv.includes("--repair");
+const ALLOW_PUBLISH = process.argv.includes(ALLOW_PUBLISH_FLAG);
+const confirmArg = process.argv
+ .find((arg) => arg.startsWith(CONFIRM_FLAG))
+ ?.slice(CONFIRM_FLAG.length);
+/** What a `WILL PUBLISH:` line may truthfully claim the run is doing about that doc. The
+ * forecast prints in READ-ONLY mode too — where nothing is written no matter which flags
+ * were passed — so the outcome text has to branch on REPAIR before ALLOW_PUBLISH. */
+const publishOutcome = !REPAIR
+ ? `Read-only — nothing was written. A --repair run would ${
+ ALLOW_PUBLISH
+ ? "apply this repair and publish the member"
+ : `WITHHOLD it (no ${ALLOW_PUBLISH_FLAG})`
+ }.`
+ : ALLOW_PUBLISH
+ ? `${ALLOW_PUBLISH_FLAG} was passed — repairing it.`
+ : `Withheld: re-run with ${ALLOW_PUBLISH_FLAG} to repair these too, or set publicProfile: false on the member first (the opt-out this member never exercised).`;
+const emulator = process.env.FIRESTORE_EMULATOR_HOST;
+const projectId = process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "jci-oriente";
+
+console.log(
+ `Auditing soft-delete shapes in ${emulator ? `EMULATOR (${emulator})` : "PRODUCTION"} ` +
+ `project ${projectId}${REPAIR ? " — repair mode" : " — read-only"}\n`,
+);
+
+/** true → proceed. false → refused; the exit code is already set and the caller must return
+ * rather than exit, so the refusal (and everything printed before it) drains to a pipe. */
+async function confirmProductionRepair() {
+ // --allow-publish widens what this run may do, so it widens the token that authorizes it.
+ // The plain token never authorizes a publishing run: pass the flag and the plain token is
+ // rejected, exactly like any other wrong string.
+ const requiredToken = ALLOW_PUBLISH ? PUBLISH_CONFIRM_TOKEN : CONFIRM_TOKEN;
+ if (confirmArg === requiredToken) return true;
+ if (confirmArg !== undefined) {
+ console.error(`Refusing to repair: --confirm must be exactly "${requiredToken}".`);
+ process.exitCode = EXIT_INCOMPLETE;
+ return false;
+ }
+ if (!process.stdin.isTTY) {
+ console.error(
+ `Refusing to repair PRODUCTION project ${projectId} without confirmation. ` +
+ `No TTY to prompt on — re-run with ${CONFIRM_FLAG}${requiredToken}.`,
+ );
+ process.exitCode = EXIT_INCOMPLETE;
+ return false;
+ }
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
+ let answer;
+ try {
+ answer = (
+ await rl.question(
+ `About to WRITE to members/positions/allies in PRODUCTION project ${projectId}.\n` +
+ "A members write re-fires onBoardMemberWritten and re-projects the world-readable " +
+ "Directiva.\n" +
+ (ALLOW_PUBLISH
+ ? `${ALLOW_PUBLISH_FLAG} was passed: repairs that would ADD a member to the ` +
+ "world-readable Directiva will be applied, not withheld. Every such member is " +
+ "announced with a WILL PUBLISH line before it is written.\n"
+ : "") +
+ `Type ${requiredToken} to proceed: `,
+ )
+ ).trim();
+ } catch {
+ // Ctrl+D / a closed stdin rejects the question. Exiting through the normal abort path
+ // keeps that out of EXIT_MALFORMED, which an unhandled rejection would collide with.
+ answer = "";
+ }
+ rl.close();
+ if (answer !== requiredToken) {
+ console.error("Aborted — nothing was written.");
+ process.exitCode = EXIT_INCOMPLETE;
+ return false;
+ }
+ console.log("");
+ return true;
+}
+
+/** Assigned by main() — the admin SDK is initialized only after the production confirmation,
+ * so this cannot be a module-level `const`. */
+let db;
+
+/** Duck-typed Timestamp test, mirroring apps/beacon/src/firestore-util.ts hasToMillis(). Not
+ * `instanceof Timestamp`: the admin SDK's Timestamp class identity is not guaranteed across
+ * a duplicated firebase-admin install, and an identity miss here would report every healthy
+ * doc in the collection as junk. Shape, not class. */
+function isTimestamp(value) {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof value.toMillis === "function" &&
+ typeof value.toDate === "function"
+ );
+}
+
+/** Short, non-throwing rendering of a field value for the operator's worklist. */
+function preview(value) {
+ if (isTimestamp(value)) {
+ try {
+ return value.toDate().toISOString();
+ } catch {
+ // A Timestamp outside the JS Date range. Not worth a failure — name it and move on.
+ return "Timestamp (unrenderable)";
+ }
+ }
+ try {
+ return JSON.stringify(value) ?? String(value);
+ } catch {
+ return String(value);
+ }
+}
+
+/** Classify one doc. Returns null when well-formed, else { problems, repair | null }. */
+function classify(data) {
+ const problems = [];
+ const repair = {};
+ let ambiguous = false;
+
+ const hasActive = "active" in data;
+ const hasDeletedAt = "deletedAt" in data;
+ const deletedAtNullish = !hasDeletedAt || data.deletedAt === null;
+
+ if (!hasDeletedAt) {
+ problems.push("missing deletedAt");
+ repair.deletedAt = null;
+ } else if (data.deletedAt !== null && !isTimestamp(data.deletedAt)) {
+ // Reported, never repaired: the four zod doc-schemas require a Timestamp here, so this
+ // doc is already dropped from every backstage list, and the rules' one-way
+ // `resource.data.deletedAt == null || unchanged('deletedAt')` reads a non-null value and
+ // pins it — the doc is client-unwritable and invisible at once. Whether the junk encodes
+ // a real deletion (an ISO string somebody wrote by hand) or is a stray key is a human
+ // call, and coercing it would either resurrect a deleted member or bury a live one.
+ problems.push(`non-Timestamp deletedAt (${preview(data.deletedAt)})`);
+ ambiguous = true;
+ }
+ if (!hasActive) {
+ problems.push("missing active");
+ if (deletedAtNullish) repair.active = true;
+ else ambiguous = true; // deletedAt set but active missing: the fields disagree
+ } else if (typeof data.active !== "boolean") {
+ problems.push(`non-bool active (${JSON.stringify(data.active)})`);
+ ambiguous = true; // never coerced — a human decides what "false"-the-string meant
+ } else if (data.active === true && hasDeletedAt && data.deletedAt !== null) {
+ // The GHOST: live and deleted at once. Client-REACHABLE, unlike the shapes above —
+ // softDeleteSafe() permits it (its one-way half only fires once the STORED deletedAt is
+ // non-null, and its well-formedness prefix is satisfied here), and memberDocSchema
+ // accepts it, so backstage lists this doc as an ordinary live member while every
+ // deletedAt-aware reader treats it as gone. The two fields disagree about what state the
+ // doc is in and neither is more authoritative, so this is reported for a human exactly
+ // like "active missing, deletedAt set" — the same disagreement, mirrored.
+ problems.push(`active true with deletedAt set (ghost, deletedAt ${preview(data.deletedAt)})`);
+ ambiguous = true;
+ }
+
+ if (problems.length === 0) return null;
+ return { problems, repair: ambiguous ? null : repair };
+}
+
+/**
+ * Does the doc read as a LIVE member once `repair` is applied? That, not the repair itself,
+ * decides which direction the re-fired projection moves the public row: projectBoard is now
+ * fail-closed on `deletedAt != null || active !== true`.
+ */
+function liveAfterRepair(data, repair) {
+ const active = "active" in repair ? repair.active : data.active;
+ const deletedAt = "deletedAt" in repair ? repair.deletedAt : data.deletedAt;
+ return active === true && (deletedAt === null || deletedAt === undefined);
+}
+
+// ── Publication forecast ──────────────────────────────────────────────────────────────
+// Repairing a member missing `active` writes `active: true`, and projectBoard fail-CLOSES on
+// `active !== true` — so before the repair that member is NOT published, and after it they
+// may be. Publication defaults ON (`publicProfile` is stamped true server-side at create), so
+// this is not hypothetical: an unpublished board member with a portrait and a current-term
+// CEL/JDL cargo goes onto the world-readable Directiva as a SIDE EFFECT of a shape repair.
+// The script announces the takedown direction, so it must announce this one.
+//
+// These predicates MIRROR apps/beacon/src/showcase/project-board.ts (projectBoard +
+// currentCargoId + isMemberPhotoUrl) and packages/types/src/engine/board-public.ts
+// (isSurfaceableStatus, boardGroupFromCategory). A .mjs operator script cannot import
+// either — neither package resolves from the repo root — so this is a hand mirror, and it is
+// built to FAIL SAFE in the one direction that matters: every gate answers true / false /
+// UNKNOWN, and only a confident `false` suppresses the warning. Drift therefore over-warns
+// (a member announced who would not have published) rather than under-warns (a silent
+// publication). The unknown gates are named in the output instead of being guessed.
+const SURFACEABLE_STATUSES = new Set(["Activo", "Inactivo"]);
+const BOARD_CATEGORIES = new Set(["CEL", "JDL"]);
+const MEMBER_PHOTO_HOST = "firebasestorage.googleapis.com";
+
+/** Mirrors isSafeDocId (apps/beacon/src/firestore-util.ts) — an id this script is willing to
+ * interpolate into a `positions/${id}` path. */
+function isSafeDocId(id) {
+ if (typeof id !== "string" || id.length === 0 || id.includes("/")) return false;
+ if (id === "." || id === "..") return false;
+ if (id.startsWith("__") && id.endsWith("__")) return false;
+ return new TextEncoder().encode(id).length <= 1500;
+}
+
+/** Mirrors currentTermKey() — getUTCFullYear(), the same key the rules derive from
+ * request.time.year(). The trigger reads the term at PROJECTION time, so "now" is right. */
+function currentTermKey() {
+ return String(new Date().getUTCFullYear());
+}
+
+function currentCargoId(data) {
+ const positions = data.positions;
+ if (!positions || typeof positions !== "object" || Array.isArray(positions)) return null;
+ const term = positions[currentTermKey()];
+ if (!term || typeof term !== "object") return null;
+ return isSafeDocId(term.cargoId) ? term.cargoId : null;
+}
+
+/** Mirrors isMemberPhotoUrl: this project's own bucket AND this member's own object. */
+function isMemberPhotoUrl(value, memberId) {
+ if (typeof value !== "string" || !URL.canParse(value) || projectId.length === 0) return false;
+ const url = new URL(value);
+ if (url.protocol !== "https:" || url.hostname !== MEMBER_PHOTO_HOST) return false;
+ const object = encodeURIComponent(`members/${memberId}/profile.jpg`);
+ return [`${projectId}.appspot.com`, `${projectId}.firebasestorage.app`].some(
+ (bucket) => url.pathname === `/v0/b/${bucket}/o/${object}`,
+ );
+}
+
+/**
+ * The gates projectBoard applies that this script can settle from the member doc alone.
+ * Returns the names of the ones that definitively FAIL — empty means "nothing local stops
+ * this publication". The cargo gate is not here; it needs a positions read.
+ */
+function localPublicationBlockers(id, data, repair) {
+ const blockers = [];
+ if (!liveAfterRepair(data, repair)) blockers.push("not live after repair");
+ if (data.publicProfile !== true) blockers.push("publicProfile is not true");
+ if (typeof data.uid !== "string" || data.uid.length === 0) blockers.push("no uid");
+ if (!(data.status === undefined || SURFACEABLE_STATUSES.has(data.status))) {
+ blockers.push(`status ${preview(data.status)} is not surfaceable`);
+ }
+ if (typeof data.name !== "string" || data.name.length === 0) blockers.push("no name");
+ if (!isMemberPhotoUrl(data.profilePicture, id)) blockers.push("no pinned portrait URL");
+ return blockers;
+}
+
+/**
+ * Resolve the current-term cargo doc for every candidate, batched through getAll at the
+ * chunk() bound. Map value: the cargo data, or null when the doc is missing, or the string
+ * "unreadable" when the read itself failed — which is the UNKNOWN this must not flatten.
+ */
+async function cargoState(cargoIds) {
+ const state = new Map();
+ const ids = [...cargoIds];
+ for (let i = 0; i < ids.length; i += GETALL_CHUNK) {
+ const slice = ids.slice(i, i + GETALL_CHUNK);
+ try {
+ const snaps = await db.getAll(...slice.map((id) => db.doc(`positions/${id}`)));
+ snaps.forEach((snap, j) => state.set(slice[j], snap.exists ? snap.data() : null));
+ } catch (error) {
+ for (const id of slice) {
+ state.set(id, "unreadable");
+ failed.push({ ref: `positions/${id}`, op: "read", message: String(error) });
+ }
+ }
+ }
+ return state;
+}
+
+/** true / false / null(unknown) — does this cargo put the member on the Directiva? */
+function cargoPublishes(cargo) {
+ // `undefined` = this id was never resolved, which currentTermKey() straddling a UTC-year
+ // boundary between the two calls below can produce. Unknown, not benign: reported like an
+ // unreadable read rather than TypeError-ing on `cargo.category` — same fail-safe direction
+ // as every other gate here.
+ if (cargo === undefined || cargo === "unreadable") return null;
+ if (cargo === null) return false;
+ if (!BOARD_CATEGORIES.has(cargo.category)) return false;
+ return typeof cargo.title === "string" && cargo.title.trim().length > 0;
+}
+
+async function* scan(coll) {
+ let cursor = null;
+ for (;;) {
+ let q = db.collection(coll).orderBy(FieldPath.documentId()).limit(PAGE);
+ if (cursor) q = q.startAfter(cursor);
+ const snap = await q.get();
+ if (snap.empty) return;
+ yield* snap.docs;
+ if (snap.size < PAGE) return;
+ cursor = snap.docs[snap.docs.length - 1];
+ }
+}
+
+let found = 0;
+let repaired = 0;
+let refused = 0;
+/** Repairable, but the repair would newly PUBLISH the member, and --allow-publish was not
+ * passed. Counted apart from `refused`: those are shapes the script cannot resolve, these
+ * are shapes it can — it is the CONSEQUENCE that needs a decision. */
+let withheld = 0;
+/** Per-doc read/write failures. Non-empty ⇒ the run did not complete ⇒ EXIT_INCOMPLETE. */
+const failed = [];
+
+/**
+ * Publication state for every malformed member, batched through getAll at the chunk() bound
+ * rather than a get() per doc. Map value: true published, false absent, null unreadable.
+ */
+async function showcaseState(rows) {
+ const state = new Map();
+ for (let i = 0; i < rows.length; i += GETALL_CHUNK) {
+ const slice = rows.slice(i, i + GETALL_CHUNK);
+ const refs = slice.map(({ doc }) => db.doc(`boardShowcase/${doc.id}`));
+ try {
+ const snaps = await db.getAll(...refs);
+ snaps.forEach((snap, j) => state.set(slice[j].doc.id, snap.exists));
+ } catch (error) {
+ for (const { doc } of slice) {
+ state.set(doc.id, null);
+ failed.push({ ref: `boardShowcase/${doc.id}`, op: "read", message: String(error) });
+ }
+ }
+ }
+ return state;
+}
+
+/** The audit itself. Never calls process.exit(): it sets process.exitCode and returns, so
+ * every line it printed drains even when stdout is a pipe. */
+async function runAudit() {
+ for (const coll of COLLECTIONS) {
+ const rows = [];
+ try {
+ for await (const doc of scan(coll)) {
+ const data = doc.data();
+ const verdict = classify(data);
+ if (verdict) rows.push({ doc, data, ...verdict });
+ }
+ } catch (error) {
+ // A paging failure means this collection was only partially seen — never report its
+ // (partial) count as a clean one; the run is incomplete.
+ failed.push({ ref: coll, op: "scan", message: String(error) });
+ console.log(`${coll}: FAILED to scan — ${error}`);
+ continue;
+ }
+ found += rows.length;
+ console.log(`${coll}: ${rows.length} malformed doc(s)`);
+
+ const published = coll === "members" ? await showcaseState(rows) : new Map();
+
+ // Which repairable members the repair could NEWLY publish. Only members have a
+ // projection, and only a doc that is not already published can be newly published — but
+ // "already published" must be KNOWN, so an unreadable boardShowcase counts as a candidate
+ // (the same fail-safe direction as every gate below).
+ const publishCandidates =
+ coll === "members"
+ ? rows.filter(
+ ({ doc, data, repair }) =>
+ repair !== null &&
+ published.get(doc.id) !== true &&
+ localPublicationBlockers(doc.id, data, repair).length === 0,
+ )
+ : [];
+ const cargos = await cargoState(
+ new Set(
+ publishCandidates.map(({ data }) => currentCargoId(data)).filter((id) => id !== null),
+ ),
+ );
+ /** id -> { unknownPublication, unknownCargo } for every member the repair may publish. */
+ const willPublish = new Map();
+ for (const { doc, data } of publishCandidates) {
+ const cargoId = currentCargoId(data);
+ // No current-term cargo is a settled NO — projectBoard returns null on a null cargo.
+ if (cargoId === null) continue;
+ const verdict = cargoPublishes(cargos.get(cargoId));
+ if (verdict === false) continue;
+ willPublish.set(doc.id, {
+ unknownPublication: published.get(doc.id) === null,
+ unknownCargo: verdict === null ? cargoId : null,
+ });
+ }
+
+ let shownBenign = 0;
+ let hiddenBenign = 0;
+ for (const { doc, data, problems, repair } of rows) {
+ // `has ? get : false`, NOT `get(...) ?? false`: showcaseState uses null as the
+ // "publication unreadable" sentinel, and ?? falls back on exactly null — collapsing an
+ // UNKNOWN into "not published". That killed the branch below, dropped the promised
+ // UNKNOWN line, and reclassified the doc as benign, i.e. truncatable — losing from the
+ // worklist precisely the docs whose public exposure nobody knows. One rejected getAll
+ // chunk (a transient 503) is enough to trigger it.
+ const showcase = published.has(doc.id) ? published.get(doc.id) : false;
+ const publishing = willPublish.get(doc.id);
+ // Always print a doc a human must act on: ambiguous, publicly exposed, unverifiable, or
+ // about to be published by its own repair.
+ const mustShow = repair === null || showcase !== false || publishing !== undefined;
+ let show = mustShow;
+ if (!mustShow) {
+ if (shownBenign < MAX_PRINT) {
+ show = true;
+ shownBenign += 1;
+ } else {
+ hiddenBenign += 1;
+ }
+ }
+ if (show) console.log(` - ${coll}/${doc.id}: ${problems.join(", ")}`);
+
+ if (showcase === null) {
+ console.log(
+ ` UNKNOWN publication: boardShowcase/${doc.id} could not be read — treat this ` +
+ "member as possibly on the public Directiva and check by hand",
+ );
+ } else if (showcase === true) {
+ if (repair === null) {
+ console.log(
+ ` PUBLISHED: boardShowcase/${doc.id} exists and STAYS PUBLISHED. This doc is ` +
+ "ambiguous, so this script writes nothing for it — no members write, no " +
+ "onBoardMemberWritten, and the row survives under the old fail-open projection.\n" +
+ " Two remedies, both by hand:\n" +
+ ` 1. Firebase console → members/${doc.id}: set 'active' to a real boolean. ` +
+ "That admin write re-fires the now fail-closed projection, which drops the row " +
+ "when the member is not live.\n" +
+ ` 2. An Admin write of publicProfile: false on members/${doc.id}. The members ` +
+ "takedown arm in firestore.rules deliberately skips softDeleteSafe(), so it stays " +
+ "open on exactly these docs (a rules test pins it). Backstage will not list this " +
+ "member — memberDocSchema drops it — so make that write from the console or directly.",
+ );
+ } else if (liveAfterRepair(data, repair)) {
+ console.log(
+ ` PUBLISHED: boardShowcase/${doc.id} exists — this member is on the public ` +
+ "Directiva. The repair declares the doc LIVE (active true / deletedAt null), so " +
+ "the re-fired onBoardMemberWritten RE-PUBLISHES it from the repaired doc. That is " +
+ "correct, and it is NOT a takedown: to unpublish, write publicProfile: false as " +
+ "an Admin.",
+ );
+ } else {
+ console.log(
+ ` PUBLISHED: boardShowcase/${doc.id} exists — this member is on the public ` +
+ "Directiva. The repair leaves the doc NOT live, so the re-fired " +
+ "onBoardMemberWritten removes the row.",
+ );
+ }
+ }
+
+ if (publishing) {
+ console.log(
+ ` WILL PUBLISH: repairing members/${doc.id} writes active: true, which un-blocks ` +
+ "the fail-closed projectBoard gate. This member holds a current-term CEL/JDL " +
+ "cargo, a pinned portrait and publicProfile: true, so the re-fired " +
+ `onBoardMemberWritten ADDS boardShowcase/${doc.id} to the world-readable ` +
+ "Directiva. That is a NEW publication, not a repair side effect anyone asked " +
+ // REPAIR first: this forecast prints in read-only mode too, where NOTHING is
+ // written and "--allow-publish was passed — repairing it" would be a false
+ // statement about what the run just did.
+ `for.\n ${publishOutcome}`,
+ );
+ if (publishing.unknownCargo !== null) {
+ console.log(
+ ` (positions/${publishing.unknownCargo} could not be read — the cargo half of ` +
+ "the forecast is a GUESS-FREE unknown, reported rather than assumed benign)",
+ );
+ }
+ if (publishing.unknownPublication) {
+ console.log(
+ ` (boardShowcase/${doc.id} could not be read either, so this may be a ` +
+ "re-publication rather than a new one)",
+ );
+ }
+ }
+
+ if (!REPAIR) continue;
+ if (repair && (!publishing || ALLOW_PUBLISH)) {
+ try {
+ await doc.ref.update(repair);
+ repaired += 1;
+ if (show) console.log(` repaired: ${JSON.stringify(repair)}`);
+ } catch (error) {
+ failed.push({ ref: `${coll}/${doc.id}`, op: "repair", message: String(error) });
+ console.log(` FAILED to repair ${coll}/${doc.id}: ${error}`);
+ }
+ } else if (repair) {
+ withheld += 1;
+ console.log(` WITHHELD (would publish) — re-run with ${ALLOW_PUBLISH_FLAG} to apply`);
+ } else {
+ refused += 1;
+ console.log(" REFUSED to repair (ambiguous) — fix by hand");
+ }
+ }
+ if (hiddenBenign > 0) {
+ console.log(
+ ` … and ${hiddenBenign} more repairable, unpublished doc(s) not listed ` +
+ "(counts are complete; ambiguous, PUBLISHED and WILL PUBLISH docs are never truncated)",
+ );
+ }
+ }
+
+ const remaining = REPAIR ? found - repaired : found;
+ console.log(
+ `\n${found} malformed doc(s) found` +
+ (REPAIR
+ ? `; ${repaired} repaired, ${refused} refused (ambiguous), ${withheld} withheld (would publish)`
+ : "") +
+ `; ${remaining} remaining`,
+ );
+
+ if (failed.length > 0) {
+ console.log(`\n${failed.length} failure(s) — THE RUN DID NOT COMPLETE:`);
+ for (const { ref, op, message } of failed) console.log(` - ${ref} (${op}): ${message}`);
+ console.log(
+ "Counts above are partial and the scan may have stopped early. Fix the cause and re-run; " +
+ "repairs already written have already fired their triggers.",
+ );
+ process.exitCode = EXIT_INCOMPLETE;
+ return;
+ }
+
+ if (remaining > 0) {
+ console.log(
+ REPAIR
+ ? `Docs still needing a human — see above${withheld > 0 ? ` (${withheld} of them only need the ${ALLOW_PUBLISH_FLAG} decision)` : ""}.`
+ : "Run with --repair, or fix by hand.",
+ );
+ process.exitCode = EXIT_MALFORMED;
+ return;
+ }
+ console.log("Audit clean — the soft-delete rules can ship.");
+}
+
+async function main() {
+ if (REPAIR && !emulator && !(await confirmProductionRepair())) return;
+
+ initializeApp(emulator ? { projectId } : { credential: applicationDefault(), projectId });
+ db = getFirestore();
+ try {
+ await runAudit();
+ } finally {
+ // Dropping process.exit() means the process ends only when the event loop drains, and the
+ // admin SDK's gRPC channel keeps it alive indefinitely. Closing it is what lets the run
+ // exit on its own with the code runAudit() set — and lets stdout finish flushing first.
+ try {
+ await db.terminate();
+ } catch (error) {
+ // Never silent: a channel that refuses to close is why a CI job would hang here.
+ console.error(`Failed to close the Firestore connection: ${error}`);
+ }
+ }
+}
+
+await main();
diff --git a/tools/scripts/lib/audit-soft-delete-shapes.test.mjs b/tools/scripts/lib/audit-soft-delete-shapes.test.mjs
new file mode 100644
index 00000000..06555228
--- /dev/null
+++ b/tools/scripts/lib/audit-soft-delete-shapes.test.mjs
@@ -0,0 +1,163 @@
+// The audit script is a BLOCKING pre-deploy gate whose product is a per-doc worklist printed
+// to stdout. It used to end on `process.exit(...)`, which returns the right code and drops
+// whatever stdout still had buffered — and on POSIX a pipe (any CI capture, any `| tee`) is an
+// async write. The bigger the worklist, the more of it is lost. This test runs the REAL script
+// against a stubbed firebase-admin, with stdout piped, and asserts the tail survives.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SCRIPT = fileURLToPath(new URL("../audit-soft-delete-shapes.mjs", import.meta.url));
+/** Enough malformed docs that the worklist far exceeds a 64 KiB pipe buffer. Each one prints
+ * its problem line plus the ~900-char PUBLISHED remedy block. */
+const DOC_COUNT = 400;
+
+const STUB_APP = `export function initializeApp() {}
+export function applicationDefault() {
+ return {};
+}
+`;
+
+const STUB_FIRESTORE = `const DOC_COUNT = ${DOC_COUNT};
+
+export const FieldPath = { documentId: () => "__name__" };
+
+// Every members doc is a non-bool \`active\`: the ambiguous shape, so it is never truncated
+// from the listing, and every one of them is PUBLISHED, so each prints the long remedy block.
+function docsIn(coll) {
+ if (coll !== "members") return [];
+ return Array.from({ length: DOC_COUNT }, (_, i) => {
+ const id = "m" + String(i).padStart(3, "0");
+ return {
+ id,
+ data: () => ({ active: "false", deletedAt: null, name: "Member " + id }),
+ ref: { update: async () => {} },
+ };
+ });
+}
+
+function query(coll, state) {
+ return {
+ orderBy: () => query(coll, state),
+ limit: (size) => query(coll, { ...state, size }),
+ startAfter: (cursor) => query(coll, { ...state, after: cursor.id }),
+ get: async () => {
+ const all = docsIn(coll);
+ const from = state.after ? all.findIndex((d) => d.id === state.after) + 1 : 0;
+ const docs = all.slice(from, from + (state.size ?? all.length));
+ return { empty: docs.length === 0, size: docs.length, docs };
+ },
+ };
+}
+
+export function getFirestore() {
+ return {
+ collection: (coll) => query(coll, {}),
+ doc: (path) => ({ path }),
+ getAll: async (...refs) => refs.map(() => ({ exists: true, data: () => ({}) })),
+ terminate: async () => {},
+ };
+}
+`;
+
+function stubbedCopyOfTheScript() {
+ const dir = mkdtempSync(join(tmpdir(), "audit-shapes-"));
+ const pkg = join(dir, "node_modules", "firebase-admin");
+ mkdirSync(pkg, { recursive: true });
+ writeFileSync(
+ join(pkg, "package.json"),
+ JSON.stringify({
+ name: "firebase-admin",
+ version: "0.0.0-stub",
+ type: "module",
+ exports: { "./app": "./app.mjs", "./firestore": "./firestore.mjs" },
+ }),
+ );
+ writeFileSync(join(pkg, "app.mjs"), STUB_APP);
+ writeFileSync(join(pkg, "firestore.mjs"), STUB_FIRESTORE);
+ // A copy, so the stub resolves — but a byte-for-byte copy of the real script, so a
+ // process.exit() reintroduced there fails this test.
+ const script = join(dir, "audit-soft-delete-shapes.mjs");
+ copyFileSync(SCRIPT, script);
+ return script;
+}
+
+/**
+ * Run the script with stdout piped, and start READING it late. The delay is the whole point:
+ * an instantaneous reader keeps the 64 KiB pipe buffer empty, so the child's writes complete
+ * synchronously and even a process.exit() looks fine — which is exactly why this bug survives
+ * casual testing. A consumer that is merely normal (a CI log collector, `| tee`, a terminal)
+ * lets the buffer fill, and every byte past it is queued in the child. process.exit() throws
+ * that queue away; process.exitCode does not.
+ */
+async function run(script, { args = [], env = {}, readDelay = 0 } = {}) {
+ const child = spawn(process.execPath, [script, ...args], {
+ // Pipes on both, never inherit: on POSIX a TTY write is synchronous, so a truncation
+ // test that inherits the terminal tests nothing.
+ stdio: ["ignore", "pipe", "pipe"],
+ env: { ...process.env, FIRESTORE_EMULATOR_HOST: "127.0.0.1:0", ...env },
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+
+ let code;
+ // 'close' = the child exited AND its stdio closed. With the reader attached in time that is
+ // after the last chunk; with a child that exited early it fires at once, on whatever little
+ // it managed to push — which is the failure this test is here to see.
+ const closed = new Promise((resolve, reject) => {
+ child.on("error", reject);
+ child.on("exit", (status) => (code = status));
+ child.on("close", resolve);
+ });
+ // With readDelay the stream stays paused: a Readable with no consumer never starts reading
+ // the handle, so the pipe fills and the child's remaining writes queue inside the child.
+ const read = () => {
+ child.stdout.on("data", (chunk) => (stdout += chunk));
+ child.stderr.on("data", (chunk) => (stderr += chunk));
+ };
+ if (readDelay > 0) setTimeout(read, readDelay);
+ else read();
+
+ await closed;
+ return { code, stdout, stderr };
+}
+
+test("the malformed-doc worklist survives a piped stdout", async () => {
+ // readDelay is load-bearing, not tuning. With a reader attached from the start the pipe
+ // drains as fast as the child fills it, every write completes, and process.exit() would
+ // have nothing left to drop — the test would pass against the very bug it exists for
+ // (measured: it did). Leaving the stream paused fills the 64 KiB pipe and queues the rest
+ // INSIDE the child, which is the state process.exit() discards.
+ const { code, stdout, stderr } = await run(stubbedCopyOfTheScript(), { readDelay: 250 });
+
+ assert.equal(stderr, "");
+ assert.equal(code, 1, "the gate still exits 1 on malformed docs");
+ // The volume is the point — a worklist that fits in one pipe write proves nothing.
+ assert.ok(stdout.length > 200_000, `expected a large worklist, got ${stdout.length} bytes`);
+ assert.match(stdout, new RegExp(`members: ${DOC_COUNT} malformed doc\\(s\\)`));
+ // The LAST doc of the listing and the closing summary are what process.exit() dropped.
+ assert.match(stdout, /members\/m399: non-bool active \("false"\)/);
+ assert.match(stdout, /PUBLISHED: boardShowcase\/m399 exists and STAYS PUBLISHED/);
+ assert.match(stdout, new RegExp(`\\n${DOC_COUNT} malformed doc\\(s\\) found`));
+ assert.match(stdout, /Run with --repair, or fix by hand\./);
+});
+
+test("a refused confirmation still prints its reason to a pipe and exits 2", async () => {
+ // No FIRESTORE_EMULATOR_HOST: the production confirmation gate is what must refuse here.
+ const { code, stdout, stderr } = await run(stubbedCopyOfTheScript(), {
+ args: ["--repair", "--confirm=nope"],
+ // Empty, not absent: `run` spreads process.env, so an absent key would inherit a real
+ // FIRESTORE_EMULATOR_HOST from the caller's shell and silently take the emulator path.
+ env: { FIRESTORE_EMULATOR_HOST: "" },
+ });
+
+ assert.equal(code, 2);
+ assert.match(stdout, /Auditing soft-delete shapes in PRODUCTION/);
+ assert.match(stderr, /Refusing to repair: --confirm must be exactly "repair-production-shapes"/);
+});
diff --git a/tools/scripts/lib/role-seed.mjs b/tools/scripts/lib/role-seed.mjs
index 69ee69ce..8a01fbb0 100644
--- a/tools/scripts/lib/role-seed.mjs
+++ b/tools/scripts/lib/role-seed.mjs
@@ -31,6 +31,7 @@ export const BUILT_IN_ROLE_PERMS = {
"manage:Activity",
"checkIn:Attendance",
"read:Ally",
+ "update:Showcase",
],
ActivityManager: ["manage:Activity", "checkIn:Attendance"],
Secretary: ["manage:Notification", "manage:Lead", "manage:Ally"],