Skip to content

fix(backstage,beacon,types): board-seat delegation audit — one regression, the email hole, and a rules-parity test - #225

Open
arkgast wants to merge 25 commits into
mainfrom
fix/board-seat-delegation-audit
Open

fix(backstage,beacon,types): board-seat delegation audit — one regression, the email hole, and a rules-parity test#225
arkgast wants to merge 25 commits into
mainfrom
fix/board-seat-delegation-audit

Conversation

@arkgast

@arkgast arkgast commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Audit of the board-seat / member-login delegation shipped in #224, and the fixes it turned up. #224 merged while this ran, so these land as a follow-up.

One regression #224 introduced, now in production. positionsLockedForEditor mirrors positionsAssignmentSafe()'s OLD-side conjunct (currentCargoGrantsEmpty, Admin-role only, deliberately never delegated). Both member forms keyed it on allowPowerGrants, which #224 widened from isAdmin to isAdmin || update:BoardSeat. So an update:BoardSeat delegate opening a member seated on a power-granting cargo gets a full picker, an enabled Guardar, and a guaranteed permission-denied — the render-then-die shape assignable-cargo.ts exists to prevent, reintroduced inside it. Only reachable once someone actually holds the delegated code.

The transversal-email rule had one hole. The profile page's "Invitar acceso" returned early on the action link and never sent the reset mail — the single surface where an invite produced no email. The row menu and the invite drawer were always correct.

Three server refusals rendered as "try again". beacon tags reprovision-requires-admin, granted-member-requires-admin and power-seat-requires-admin; the UI mapped only a fourth, so every delegate 403 read as transient and invited infinite retries.

Plus: the invite affordance mirrored one of beacon's four non-Admin refusals (now one shared predicate at all three entry points); assignedBy reached auth.getUser() unscreened, where a malformed value throws permanently under retry:false and kills that member's claims sync forever; member.email reached getUserByEmail unscreened with the same failure shape; the refusal reasons were five hand-copied string literals across two apps, now one typed union in @luminova/types.

The deeper fix

assignable-cargo.ts hand-mirrors firestore.rules, and its unit test never reads firestore.rules — change the rules and every assertion stays green. That mirror drifted twice on #224 alone (the option list, then the lock flag), both caught by review rather than by a test.

tests/firestore-rules/cargo-assignment-parity.test.ts asserts the implication instead: for every (principal, member fixture, cargo), if the editor would let it be submitted, the real emulator must allow the write. 17 principals — all built-in roles derived from ROLES, never hand-listed, plus 8 perms-only customs — × 6 member fixtures, both lanes, assignedBy self-stamped as the mapper does. 160 offered triples, each a real write. Both historical drifts turn it red with a message naming the drifted file.

Per docs/engineering-guardrails.md this is the "small parser + parity test" prescription with the parser half deliberately skipped: these are boolean predicates, not field lists, and the emulator is the parser.

Known, documented, not fixed

  • Revocation is not immediate. Claims decode only in onAuthStateChanged off a cached token, and both the rules and the callable read request.auth.token — a revoked code keeps working until the ID token expires (~1h). Fixing it is app-wide auth-store surgery, outside this diff. Spec records the latency and revokeRefreshTokens as the immediate cut.
  • The ceiling with all three codes held together is "everything but Admin". The self-assignment guard blocks one-write self-promotion, not the multi-write puppet form. Mutual exclusion would close it but contradicts the requirement that the seat delegate also send emails. Stated plainly in the spec, with the post-revocation checklist (a puppet's Auth account survives revocation; nothing in the codebase deletes or disables one).
  • /permisos renders ~10 inert checkboxes for these two subjects. Pre-existing pattern, tripled by feat: delegable board-seat and member-login permissions #224. Tracked, not fixed.
  • comisionIds: null drops the whole term entry, unseating the member. Kept fail-closed rather than fail-open, now logged. A one-line change if the owner prefers the other direction.
  • The client mirror is deliberately stricter in one spot: parseDocs drops a position for any schema violation, so a cargo that exists with grants: [] but a malformed description locks the editor out of a takedown the rules would allow. Availability only, needs a malformed catalog doc, and an Admin can reassign. Documented at the predicate.
  • Residual beacon items, recorded not fixed: auth/invalid-uid still surfaces opaque (different field, needs a console-written uid); the power-seat loop reads one cargo per distinct term serially with no ceiling (rules are term-pinned, and it fails closed); no beacon callable sets enforceAppCheck (consistent across all five, pre-existing).

Test plan

  • backstage 913 · beacon 359 · types 331 · ui 110
  • firestore-rules 541 (5 files, emulator on 4011 — the dev emulator on 4010 was never displaced)
  • lint · typecheck · knip · prettier clean
  • eager JS 160 kB gz (budget 162), CSS 13 kB gz (budget 15) — -9 B vs the pre-fix baseline, measured byte-for-byte against a scratch build of the base commit rather than the rounded gate number
  • every new guard mutation-tested — see the table below. This line was false when first written, which is the finding I care most about in this PR; the two guards it was false for are fixed and now falsifiable.
  • ci pass

Mutation evidence

Guard Mutation applied Result
CEL conjunct (firestore.rules) delete && category != 'CEL' 10 rows red, both lanes
boardSeatDelegate() widen hasPermcanDo (so manage:all satisfies it) 3 rows red
fail-closed cargo lock drop the unresolvable disjunct 2 rows red
link withholding + cache invalidation return actionLink on success / drop onSettled 4 rows red
null-vs-throw port contract also swallow auth/internal-error its own row red
log-sample truncation drop .map(truncateForLog) its own row red
surrogate trim neutralize the orphan check its own row red

The last three were invisible to the entire suite before this round.

Reviews

  • security-review
  • firestore-security-reviewer
  • firebase-functions-reviewer
  • code-review
  • simplify
  • react-best-practices
  • bundle-budget-watcher

/code-review cannot be launched programmatically, so the author ran it manually at max effort and an equivalent adversarial pass covered the delta afterwards. Every review above was re-run on the final delta; firestore-security-reviewer and firebase-functions-reviewer both returned SHIP with no Critical/High, and their Low/Medium items were taken.

What the last /code-review max found

Twelve findings, including three correctness bugs in member-profile-page.tsx alone — all in code this branch rewrote:

  1. The invite mail could be dropped entirely. It was sent from a component-scoped mutate(id, {onSuccess}); TanStack v5 runs those only if (this.#mutateOptions && this.hasListeners()) — verified in the installed query-core 5.101.0 — so an operator who navigated away, or merely switched members (this branch's own key={member.id} made that an unmount), got the account created and the uid linked with no mail and no error. Provisioning and the mail are one mutationFn now; removeObserver never aborts it.
  2. The Admin's action link was invalidated by the mail fired right after it — Firebase keeps only the most recent reset oobCode valid — while the new copy offered that link as the fallback. It is now returned only when the mail failed.
  3. Both member forms lacked key={member.id} while a display-only banner had one, so a cache-warm A→B navigation could save A's identity onto B's document.

It also found the test-plan claim above was falsifiable and false, and that the BLOCKING test named for the #224 flag-conflation regression could not fail on flag conflation.

Reviews were run in four rounds. Every round found real defects in the previous round's fixes, and the last two found them in code written during this audit. The shape was the same almost every time: a claim the code did not have — "strict subset" (wasn't), "namespaced" (still collided), "make every fail-closed path say so" (missed the widest), "an Admin can still clear it" (they must reassign instead). What caught them was mechanical — the emulator parity test, mutation testing, reviewers treating comments as hypotheses. What kept creating them was prose. The later fixes therefore convert claims into things a machine can falsify: a required parameter instead of a convention, a compile error instead of a comment, an extracted module instead of a documented import cycle.

arkgast and others added 25 commits August 28, 2026 08:08
…ned seat flag

positionsAssignmentSafe() gates its two cargo conjuncts on DIFFERENT principals
and this branch collapsed them onto one flag.

  NEW side (cargoAssignableByNonAdmin, the cargo written IN)  — delegated,
    boardSeatDelegate() lifts it. That is the feature.
  OLD side (currentCargoGrantsEmpty, the cargo REPLACED)      — Admin ROLE only,
    deliberately never delegated: a principal who can overwrite a sitting Admin's
    cargo can strip every Admin claim in the chapter.

Both forms typed `!allowPowerGrants && positionsLockedForNonAdmin(...)`, which was
correct only while allowPowerGrants MEANT isAdmin. Widening it to canAssignBoardSeat
unlocked the editor for a delegate opening a member seated on a power-granting
cargo — full picker, enabled Guardar, guaranteed PERMISSION_DENIED, and the generic
"Intenta de nuevo" that every retry earns. The render-then-die shape assignable-cargo
exists to prevent, reintroduced in the file that prevents it.

The flag is now a PARAMETER of positionsLockedForEditor rather than &&-ed per call
site, so the mirror cannot be keyed on the wrong conjunct again, and all four call
sites pass allowReplacePowerCargo={isAdmin} alongside allowPowerGrants.

Also adds the note for the one outcome in this lane that fails SILENTLY: a delegate
may write an Admin-granting cargo and the save succeeds, but resolveTrustedGrants
refuses to honor it from a non-Admin assigner, so the seat publishes and mints
nothing. Nothing told the editor.

Mutation-tested: reverting either call site, or neutralizing either flag, turns
exactly the new BLOCKING tests red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fired

The invite affordances gated on `isAdmin || !member.uid`, which mirrors ONE of the
four refusals provisionMember applies to a non-Admin. So a delegate was offered
"Invitar a la app" on any uid-less member an Admin had already seated on a granting
cargo — the normal state between being seated and being invited — and every click
403'd. The invite drawer had a pre-check for exactly this; the row menu and the
profile header did not.

provision-gate.ts is now that predicate, once: adoption, direct grants
(roleIds/permissionOverrides.grant, matching beacon's hasDirectGrants — a
revoke-only override mints nothing) and a power-granting cargo in ANY term, since
syncMemberClaims reads the current term at trigger time and a future-term seat
mints on the year rollover. Fails closed on an unresolvable cargo id, as the
callable does on an unreadable one.

beacon already tagged all four refusals with details.reason and the UI dropped
three, so every delegate 403 read as a transient failure worth retrying. They now
render what actually blocked. The table is a Map, not an object literal: `reason`
arrives inside an error payload, and {...}["toString"] resolves to
Object.prototype's function, which `?? fallback` would hand to React as a message.

Also fixes the one surface where an invite sent no mail at all — the profile
header returned early on the action link, so an Admin inviting from there produced
a link and no email, against the rule that mail goes to every new user. The link
is the manual fallback on top; its copy button gained the failure state it lacked,
and the dialog copy now tracks whether the mail actually went.

Pins the three client conjuncts that mirror server guards: a test-quality pass
found all three would survive deletion with the suite green, because every existing
case rendered either a uid-less member or an Admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
beacon: resolveTrustedGrants screened cargoId with isSafeDocId and explained at
length why — a permanent throw under retry:false kills that member's claims sync
forever — then passed assignedBy straight to auth.getUser(), which rethrows
anything but auth/user-not-found. A console- or admin-SDK-written value Auth
rejects therefore threw permanently, and only on power-granting cargos: exactly
the members whose claims matter. Screened with a uid-shaped check, NOT isSafeDocId,
which allows 1500 bytes where getUser caps at 128 and rejects the `/` and `.` a
custom uid may legitimately contain. An unusable value is untrusted: mint nothing.

ui: Combobox takes aria-describedby. Both cargo notes sit after the field in the
DOM, so a screen-reader user reaching an empty or disabled trigger heard only "Sin
resultados" and never met the explanation.

The empty-picker note now derives the permission name from
permissionLabel("update:BoardSeat") instead of hardcoding it. It is the one place
a user is told which permission to go ask an Admin for, and its own comment
admitted that nothing enforced the match.

Adds coverage for the port/adapter seam, which had none at any level: the narrowed
createUser catch (quota and disabled-provider errors now surface instead of
masquerading as user-not-found) and getAssignerClaims, the wire that carries a
delegate's perm into the trust gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d guards

tests: three beacon guards are justified by "firestore.rules never constrains
members.email" — the adoption guard, the power-seat guard and their comments all
lean on it, and nothing pinned it. A characterization test now asserts the exposure
directly, so adding an email pin later cannot silently make those guards
over-strict, and loosening something adjacent cannot pass unnoticed. Also retargets
four test names that no longer described their bodies and drops one create-lane
test strictly subsumed by its Admin twin.

rules comments: the takedown reference pointed "below" at logic that is above, and
"a delegate cannot displace a power-cargo holder" was stated unqualified when it
holds only within the current term — currentCargoGrantsEmpty() reads
positions[currentTermKey()], so the UTC-year rollover empties a sitting Admin's
slot. The spec already disclosed the caveat; the rules did not.

plan doc: marked SUPERSEDED, with its two guard descriptions corrected inline. It
still recommended, verbatim and under a heading that reads as current, the
trust-gate form that was rejected for stripping the seeded president's Admin — a
near production outage — plus a provisioning guard looser than what shipped.

spec: operator notes for what the audit surfaced. Revocation is not immediate —
claims decode only in onAuthStateChanged off a cached token, and both the rules and
the callable read request.auth.token, so a revoked code keeps working until the ID
token expires; an immediate cut needs revokeRefreshTokens. The self-assignment
guard blocks the one-write self-promotion, not the multi-write puppet form, so the
ceiling with all three codes held together is "everything but Admin" — stated
plainly rather than left implied. And nothing in the codebase deletes or disables
an Auth user while syncMemberClaims ignores membership status, so a puppet account
outlives revocation until the Firebase console removes it.

Also delivers the two cross-references the plan's own Slice 8 promised and never made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntract

The four `details.reason` tags were hand-copied across five places — beacon's
throws, beacon's tests, the client's message map, and both test files — with
nothing coupling them. Renaming one in beacon degraded the client silently to its
generic fallback, which is the exact failure the client mapping was added to
eliminate. PROVISION_BLOCK_REASONS now lives in @luminova/types and both sides
reference it, so a rename is a compile error on both. Verified by renaming one and
watching beacon and backstage each fail to typecheck.

Also closes the same mistake-class this branch already fixed twice: `member.email`
reached `auth.getUserByEmail` shape-unchecked, and the Admin SDK's auth/invalid-email
is not one of the codes nullIfUserNotFound swallows — so it rethrew as an opaque
`internal` and left that member unprovisionable through the callable until someone
edited the stored value in the console. Screened with firebase-admin's OWN isEmail
predicate, copied verbatim so nothing Firebase accepts is refused here, and tagged
`member-email-malformed` so the operator is told what to fix.

Pins the ACCEPTANCE half of the assignedBy screen. Only the rejection half was
tested, so a future "reuse isSafeDocId for consistency" edit would have passed every
test while denying legitimate uids — the screen exists precisely because a uid is
not a doc id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gine

assignable-cargo.ts is a hand-written mirror of positionsAssignmentSafe() and
createPositionsSafe(). Its unit test is thorough but tests the mirror against a
hand-written truth table and never reads firestore.rules — change the rules and
every assertion stays green. That mirror drifted TWICE on this branch alone: the
option list offered a CEL seat the rules deny, and the lock flag was keyed on the
conjunct that got widened. Both were caught by review, not by a test.

cargo-assignment-parity.test.ts asserts the implication instead: for every
(principal, member fixture, cargo), if the editor would let it be submitted then the
emulator must allow the write. 17 principals — all nine built-in roles derived from
ROLES, never hand-listed, plus eight perms-only customs — against six member
fixtures, driving both lanes with assignedBy self-stamped as the mapper does it. 160
offered triples, each a real write. An implication, not equality, so deliberate
client strictness is never flagged.

Both historical drifts mutation-tested: reverting either turns it red with a message
naming the drifted file.

Needed a module split — the rules-test package cannot load @luminova/types at
runtime — so the pure predicates moved to assignable-cargo-core.ts with zero imports
(a structural CargoLike rather than Pick<Position,…>). assignable-cargo.ts re-exports
them and wraps the slot logic with the labelling it owns; no call site changed.

It cannot see a call-site flag swap, since it recomputes the claims-to-props mapping
rather than loading the React hook — that case stays with the component tests. An
emulator-driven counterfactual pins the cost of getting that wiring wrong.

Also restores the create-lane delegate ride-along test dropped earlier as a "strict
subset" of its Admin twin. It is not one: an Admin's perms are manage:all, never the
exact code, so a hasPerm('update:BoardSeat') disjunct grafted onto the term conjunct
leaves the Admin test denied while reopening the ride-along for the perms-only
principal this feature introduces. Both mutations verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the audit's own fixes found the mint-pending note mirrored only ONE of
resolveTrustedGrants' two refusals. It caught an Admin-granting cargo but not a
SELF-assignment of any granting cargo, which is the recommended update:Position +
update:BoardSeat pairing: a delegate could seat themselves on a Secretario cargo,
publish to the Directiva, mint nothing, and be told nothing. Found independently by
three reviewers after I wrote it. The predicate takes isSelfAssignment now, and the
truth table is enumerated rather than sampled — the missing term lived in exactly two
cells, both the shape every existing case already asserted silent, so a sampled suite
agreed with the bug.

The note itself, and the note-priority derivation both forms had re-typed, are now
shared (MintPendingNote, cargoNoteId) — the same drift shape assignable-cargo exists
to prevent. aria-describedby now covers all four picker states, not two.

memberProvisionBlocked / draftProvisionBlocked take callerIsAdmin instead of three
call sites typing `!isAdmin &&`. The function is named ForNonAdmin; the conjunct
belongs inside it. They also no longer skip an EMPTY-STRING cargoId: beacon's
readCargoIds pushes "" deliberately and then refuses it, so treating it as "no cargo"
promised an invite and 403'd describing a cargo that does not exist.

InviteAccess: the reset mail was an untracked floating promise, so the button
re-enabled while it was in flight and two clicks could leave "enviada" and "no se
pudo enviar" both rendered, or overwrite a real failure with a stale success. A
sending flag and an attempt ref close it. `open` is gone — it was only ever set
alongside `link`, and keeping them in sync by hand is what left `link` out of the
reset. The mail failure is repeated inside the dialog because the modal's aria-hidden
takes the header alert out of the accessibility tree.

useCopyToClipboard is shared by both copy affordances and wraps the call in try/catch:
navigator.clipboard is undefined outside a secure context, so the property access
throws SYNCHRONOUSLY and the bare .catch() never ran — the failure fallback existed
and could not render.

The drawer's "invítalo desde el menú de su fila" now keys on blocked-ness alone: with
the checkbox unticked a blocked delegate landed there, and the row action is hidden
from them for the same reason, so it pointed at an affordance that is not present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review of the previous commit found it stopped one conjunct short of its own
rationale. The row item is gated on `canProvisionLogin && !provisionBlocked`; the
done-screen fallback was made to respect the second and still ignored the first. A
`create:Member` holder without `create:MemberLogin` reaches this drawer — the trigger
only asks `Can I="create" a="Member"` — never sees the checkbox, and was then sent to
a row action that is hidden from them. The same dead end, on the other axis.

Also corrects cargoNoteId's co-firing comment, which was wrong in both directions:
`takedown ∧ mintPending` is impossible (one needs grants.length === 0, the other > 0,
off the same selection), while `locked ∧ mintPending` is the pair that IS reachable
and was unmentioned. The order was already right — locked outranks mintPending, and
the mint note renders under `!locked` — but a comment saying these cannot co-fire is
an invitation to reorder them, which would point aria-describedby at an element that
is not in the DOM.

And states the CargoLike tradeoff the split accepted: `category`/`grants` are wider
than Position's literal unions because narrowing them means importing those unions —
the one thing that module may not do — or re-declaring them, and a re-declared union
drifts. The direction that can actually break is already enforced: cargoSlotsForEditor
is generic in `P extends CargoLike` and gets handed `Position[]`.

Mutation-tested: collapsing the three-way branch back to two turns exactly the new
test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/simplify found the previous commit re-explaining two facts that already had a
home: why handing Position[] to a CargoLike-generic function is the enforcement
that matters (now only in assignable-cargo-core, next to the interface it is
about), and why provisionBlocked rather than blockedByCargo answers the row-menu
gate (now only on the field's own doc-comment). Two copies of a rationale drift
exactly the way two copies of a predicate do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/simplify's altitude pass argued against extracting a shared invite predicate —
the done screen needs a REASON, not a boolean, and the two flags mirror different
server gates that fail closed in different directions. Recorded here rather than
acted on; the trigger for revisiting is a 4th consumer or a 3rd conjunct on the
row-menu gate.

Three of its findings were worth taking:

The new drawer test's principal could not reach that drawer in production —
/members gates on an unconditional read:Member, so a create-only holder is
redirected off the page hosting it. The fix is right for the reachable principal;
the fixture now names it.

cargoNoteId's comment said "exactly one pair CAN co-fire" while arguing three of
six. The other three are unreachable for reasons OUTSIDE that file (the Combobox
is disabled while locked; grant-free CEL is never offered without allowPowerGrants),
so a future change to either makes `locked ∧ takedown` reachable with nothing
saying the exclusion was ever load-bearing.

And the CargoLike tradeoff understated itself: a miscased `category` in a fixture
is invisible to the PARITY TEST too, not just to the compiler — the same object is
fed to the predicate and seeded into the emulator, so both sides agree wrongly and
the suite stays green. The factory now pins the literal union, which is the only
guard available, and both comments say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviews: 3c1df34 security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher
/code-review (run by the user against this PR) found the fail-closed screens in
resolveTrustedGrants are silent: a malformed cargoId or assignedBy drops the
member's cargo-derived roles, computeMemberRoles mints only Member, the trigger
completes successfully, and the member is published on the Directiva with no
permissions and no signal anywhere. Guardrail #4 is exactly this shape.

Both screens now log through the existing injectable sink, with the id truncated —
isSafeDocId tolerates 1500 bytes and Cloud Logging drops an over-large entry whole,
which is the failure the neighbouring sampleRejectedIds already existed to avoid.
The cargoId log is guarded on a non-null id, or it would fire on every write to
every member holding no seat and bury the real anomaly. readPositionGrants' three
null returns log too.

The DESIGNED refusal now logs as well, which was outside the finding: it is reached
on an ordinary delegate path — an Admin-granting cargo, or a self-assignment — and
is the likeliest real cause of "they are on the Directiva with no permissions". The
meta names which half denied, because the remedy differs: an Admin re-saves the
slot for a self-assignment, but only an Admin may seat an Admin-granting cargo at
all. Paired with a negative so a log on the success path cannot creep in.

Restores two tests deleted earlier in this session on "subsumed by" claims that do
not hold:
- callable-auth's "keeps the two delegations independent" — the survivor is a
  same-subject WRONG-ACTION probe; nothing else exercises the callable gate with the
  sibling delegation code, so a widening keyed on subject family would pass.
- provision-member-login's grant-free-cargo ALLOW — the fixture said to subsume it
  has no positions map at all, so the power-seat loop body never executes. It was
  the only test driving a resolved cargo through that loop and out the allow side.

That is the third false subsumption claim on this branch. Each new comment now says
why the test is NOT redundant, so the argument is not repeated a fourth time.

Every log mutation-tested: delete it and exactly its own assertion goes red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng its own result

Two behavioral findings from /code-review on this PR, both of which would have
shipped.

The invite drawer was the third entry point and the only one still storing the
callable's raw message, so a delegate saw the server's English prose ("this member
already has a login; only an Admin can re-provision or link one"). The PR claimed
three refusals were mapped at three entry points; it was two. The raw message stays
as the FALLBACK, so an untagged failure keeps the one diagnostic it gives.

InviteAccess was mounted behind !inviteBlocked — a flag its own success FLIPS.
beacon writes member.uid, the next useMember refetch makes memberProvisionBlocked
true, the gate closes, and the component's sent/error state dies with it: for a
delegate whose password mail failed, that alert is the only notice the account
exists without one, and it vanishes mid-flight. `blocked` is now a prop that hides
the BUTTON; the feedback stays mounted. Admins never saw this — the predicate
short-circuits for them, so it bit exactly the delegate path.

Also from that review:
- isSelfMember extracted; three sites had it typed by hand, in the PR whose thesis
  is that copied predicates drift. The uid !== undefined half is the whole point —
  an unprovisioned member and an unresolved caller must not read as "yourself".
- The CargoLike widening silently deleted a compile-time guard: `category !== "CEL"`
  against a structural `string` becomes always-true if POSITION_CATEGORIES renames
  that literal, offering every CEL cargo to a non-delegate while the rules keep
  denying. Pinned where Position is importable, cross-referenced from the comparison.
- cargoNoteIds(prefix) replaces the four-entry ids literal both forms hand-copied.
  The earlier namespacing was HALF a fix and its comment claimed a property the code
  did not have: the two shared notes are shared COMPONENTS, so two mounted forms
  emitted byte-identical ids. All four are prefix-derived now.
- The pass-through re-export in assignable-cargo.ts is gone. It read as convenience
  and was a barrel that erased the one distinction the split exists to make legible —
  whether the parity test holds a predicate to firestore.rules.
- useCopyToClipboard's closures memoized; a cargo-lookup parameter renamed off a
  shadow of the created member's doc id.

Both behavioral fixes mutation-tested: reverting either turns exactly its own tests
red, and the un-prefixed id turns four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m outcome

The previous commit claimed to make EVERY fail-closed claims-sync path say so.
It missed the widest one, and the claim was the bug: parseMember runs upstream of
every screen that commit instrumented, so on a malformed doc the new lines never
fire — the shape was already erased before resolveTrustedGrants saw it.

firestore.rules never type-checks comisionIds. A console or migration write storing
it as a string drops the WHOLE term entry, including a valid cargoId and a genuine
Admin assignedBy, so the sitting president's Admin is stripped on their next member
write with no line anywhere. Same class for a non-map positions, a non-object term,
a non-string cargoId, and a mixed-type roleIds — which also made the existing
"roleIds entries cannot be a doc id" log unreachable for the shape it names, since
the junk was replaced with [] upstream. Five bounded lines now, keyed on the DOC id
rather than uid (uid is itself a field the parser can find missing, so a line keyed
on it is blank for exactly the docs an operator must open), term drops aggregated
into one sampled line because positions has no key cap.

The context is a REQUIRED parameter, not optional. Optional is how a future call
site silently reinstates the silent drop, which is the shape this branch keeps
paying for.

Severity split, because one of the six lines was not an anomaly at all: the trust
gate's designed refusal fires when the feature works as specified, before the
sameClaims short-circuit, so a member parked in that state emitted an ERROR on
every write forever — including one per check-in, via the totalPoints mirror. That
and the perms-cap breach are logWarn now; malformed shapes stay logError. Extended
to the legacy no-assignedBy seat on the same argument: a pre-field doc shape is
steady-state, not corruption.

Both sinks are optional on the deps interface, so their presence in production
rested on one factory with nothing asserting it — omitting logWarn would compile
and pass every unit test, since the fakes supply their own. Pinned.

readPositionGrants takes an injected sink instead of hard-coding console.error;
it is the function deliberately shared by two ports, and hard-coding its sink is
what a later change has to unpick twice. Its test drops the console spy.

Every log mutation-tested 1:1, including the silence guards: neutralize one and
exactly its own assertion goes red.

Deliberately NOT changed: comisionIds: null still drops the term. Treating it as
the empty array would keep the seat and mint its grants — fail-open on a path where
the current behavior fails closed. Now logged, so it is diagnosable either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he headline

Two findings from /code-review on this PR, both in fixes made earlier in this same
audit.

The InviteAccess mount gate was `!inviteBlocked`, and moving that flag to a prop
fixed the bug where a successful invite erased its own result — but the gate had
ALSO been the thing resetting the component between members, and nothing replaced
it. TanStack Router renders the same MemberProfilePage instance across
/members/A → /members/B (no key on Match), and the isLoading unmount is skipped
whenever B is warm in cache. So B's header could show A's "Invitación enviada"
under B's name — and with the dialog left open, A's password-reset link, a bearer
credential, rendered on B's page one click from being copied. `key={member.id}` is
stable across the refetch that sets member.uid, so it does not reintroduce the
flip-erases-its-own-result bug.

Routing the drawer's refusals through provisionErrorMessage put the Spanish in the
"Detalle:" slot UNDER a headline that still said "Podrás invitarlo desde el menú de
su fila" — and for reprovision-requires-admin that row action really is offered,
since memberProvisionBlocked keys hasLogin on member.uid and a just-created doc has
none: beacon refused on the Auth directory, which the client cannot see. So the fix
for the infinite-retry loop still sent the operator into it, in larger type. The
refusal is the headline now.

Also corrects two claims of mine that the code did not have:
- useCopyToClipboard's comment described a causal chain — reset → close → Sheet's
  onOpenChange — that does not exist: those are unmemoized functions and an inline
  arrow, so nothing downstream observes the stability. Kept the memoization as a
  cheap property of a shared hook; rewrote the reason to be true and dropped the
  test's BLOCKING label.
- "the uniqueness is structural instead of circumstantial" was false. Module-scope
  ids make the two form TYPES disjoint, not two instances of one form; that is still
  safe, but only because of memberEditMode and Radix unmounting closed portals —
  facts in other files, which is what the sentence claimed to have eliminated. Names
  the assumptions and points at useId for when they stop holding.

Drops a tautological test added in the same round: cargoNoteIds("member") equals
itself passes for every implementation short of one embedding a counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviews: cadf1aa security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher
…unting

/code-review max on #225 found three correctness bugs in member-profile-page.tsx
alone, all in code this branch rewrote.

1. The reset MAIL was sent from a component-scoped `provision.mutate(id, {onSuccess})`
   callback. TanStack Query v5 runs those only `if (this.#mutateOptions &&
   this.hasListeners())` — so an operator who navigated away, or merely switched to
   another member (this branch's own `key={member.id}` made that an unmount), got the
   Auth account created and the uid linked with NO mail sent and no error anywhere.
   The member then has a login nobody told them about, and memberProvisionBlocked
   hides the retry from the delegate who caused it. Provisioning and the mail are one
   mutationFn now, which has no such condition.

2. The Admin's action link was invalidated by the mail fired right after it. Firebase
   keeps only the most recent password-reset oobCode valid, and the dialog's new copy
   offered that link as the fallback for "si no le llega el correo" — dead on exactly
   the branch that promised it works. The hook returns `fallbackLink` only when the
   mail did NOT go out, so no call site can re-derive which of two secrets is live.

3. MemberForm and MemberPositionsForm had no `key={member.id}` while a display-only
   banner got one. RHF reads defaultValues once at mount and the page is not remounted
   across a /members/A -> /members/B param change when B is warm in cache, so
   "Guardar cambios" wrote A's name/email/status onto B's document.

Also: the hook is the only members mutation with no invalidateQueries, while the
`blocked`-as-a-prop design is justified by "the next refetch makes it true". With a
5-minute staleTime that flip never happened in-session, so a second click 403'd on
the adoption guard. And the invite drawer set `done` unconditionally after two awaits
— close it mid-submit and the next "Invitar miembro" opened on the previous member's
done screen, action link included.

usePositions' isError is now read on both pages (guardrail #3): the invite gate fails
CLOSED on an unresolvable cargo, so a failed catalog query silently removed the
affordance from every seated member with nothing said.

The profile-page test now runs the REAL hook with only its two edges mocked. A
hand-written fake that invokes opts.onSuccess unconditionally models a mutation that
always has listeners, which is precisely the thing that is not true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
positionsLockedForEditor took `Position | undefined` and asked only whether it
confers power, so a held cargo whose id resolves to NOTHING read as "no cargo" and
unlocked the picker. The rules have no such gap: currentCargoGrantsEmpty() get()s the
real doc and a missing one errors the rule, which denies. So the client offered a full
picker and an enabled Guardar for a write firestore.rules always rejects — the
render-then-die shape this module exists to prevent, one input short.

Reachable without a console edit. The catalog is parseDocs(positionDocSchema, …),
which DROPS any doc failing the schema, so the very corruption that makes a cargo's
power unknowable is what removes it from the array the client searches.

`heldCargo(positions, cargoId)` is now the one way to build that input — it keeps the
id alongside the lookup, so "seated on something unreadable" and "not seated" stop
being the same value. It also replaces the `positions.find(...)` each form had typed
for itself. `cargoSlotsForEditor` and `draftProvisionBlocked` lose their truthiness
tests on the id for the same reason: "" is an id that resolves to nothing, not "no
cargo", and beacon's readCargoIds manufactures exactly that value so its own guard
refuses it. The provision-gate test enshrined that divergence as deliberate; the
reasoning it gave ("the draft schema cannot produce one") makes the case unreachable,
not the fail-open answer correct.

MemberForm's four authority props are REQUIRED now. They were optional with `= false`
defaults, which are not safe in the same direction: `isSelfAssignment = false`
suppresses the mint-pending warning, `allowReplacePowerCargo = false` locks an Admin's
picker, and a call site that forgot either compiled clean.

The BLOCKING test named for the #224 flag conflation could not fail on flag
conflation — one assertion duplicated an earlier test verbatim and the other was
decided by the cargo alone. It now pins that the two flags DISAGREE for the same
principal, which is the property collapsing them destroys. cargoTakedownOnly gets the
truth table it never had.

ui: MultiSelect takes aria-describedby, like Combobox. Both forms disable the
comisiones picker on the same `locked` flag, and only the cargo picker was explaining
itself — a screen-reader user met a dead control with no way to tell a permission
ceiling from a broken widget.

Mutation-tested: dropping the unresolvable clause turns exactly the two new BLOCKING
rows red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irroring buildCan

Two defects in this branch's headline deliverable.

It asserted `client offers => rules allow` and nothing else, which catches a rules
TIGHTENING and structurally cannot catch a LOOSENING. Delete `&& category != 'CEL'`
from cargoAssignableByNonAdmin(), or widen boardSeatDelegate() from
hasPerm('update:BoardSeat') to canDo('update','BoardSeat') so manage:all satisfies it,
and all 429 lines stayed green — including the row named "the delegation is live",
which reads as a rules property and asserts only about the client. The converse cannot
be asserted wholesale (the client is deliberately stricter about comisiones, retired
and inactive cargos, and flagging that curation would make the test an obstacle to
it), but it can be for the three cargos the delegation is ABOUT, where the gap is not
curation but the boundary. Both loosenings now turn those rows red — verified by
making each mutation against firestore.rules and re-running the suite.

And `gatesFor` hand-re-implemented buildCan's claims -> flags mapping: the exact
mirror class this file exists to delete, of the exact flag whose widening caused the
#224 regression. It would have kept agreeing with itself while use-can.ts drifted. The
derivation is now `capabilityFlags()`, split out of use-can.ts (React, so this package
cannot load it) and spread back into buildCan — one function, both sides.

Also corrects a comment that justified the local category union by claiming CargoLike
widens it to `string`. It does not; assignable-cargo-core declares PositionCategory
and documents at length why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fiable

"member has no email" was the one refusal thrown with no details.reason — so the UI
degraded it to the generic "No se pudo…", verbatim the dead end PROVISION_BLOCK_REASONS
was created to remove, and it SHADOWED the tagged member-email-malformed refusal for
the empty-string case, which is the likelier of the two (memberDocSchema's email is a
bare z.string()). Absent, empty and malformed have the same operator remedy, so they
are one tagged check now.

ADMIN_SDK_EMAIL_SHAPE was copied verbatim from the Admin SDK on the argument that
tightening it would start rejecting addresses Firebase accepts. True in general, but
`[^@]` matches \n, \r, \t, spaces and NUL, and Identity Toolkit rejects those
server-side anyway — so "pres@jci.bo\n" passed this screen AND the SDK's own check,
reached the API, and came back as an opaque `internal` with no reason. That IS the
unprovisionable-with-no-hint failure the constant exists to prevent. \s and \p{C} are
excluded now; the three SDK-accepted fixtures still pass.

Two log guards could not fail:

- The BLOCKING "bounds the log" test appended its one oversized id LAST in a 10,001
  entry array, outside sampleRejectedIds' .slice(0, 10) window. The per-entry length
  assertion only ever saw 9-character ids, so deleting .map(truncateForLog) left it
  green — while a member whose FIRST junk roleId is 1,500 bytes serializes raw, the
  >256 KB entry-dropped failure the test's own comment names. It goes first now.
- "stays serializable when the cut lands mid-surrogate-pair" could not produce a lone
  surrogate: an all-astral fixture is pairs on even indices and the cap is 64, so the
  cut always landed ON a boundary. Both its assertions were unconditionally true
  besides (JSON.stringify has not thrown on a lone surrogate since ES2019, and
  toBeTruthy() holds for every object). A leading BMP char shifts the pairs, and
  truncateForLog now drops the orphan rather than shipping an ill-formed log field.

And provision-deps.ts held a byte-identical second copy of the logError sink whose
sibling's doc comment reads "defining a second console.error wrapper at each of those
call sites would be the copy guardrail #1 forbids". Both now come from firestore-util,
so routing beacon's logs elsewhere is one edit that reaches every port.

Mutation-tested: neutralizing the surrogate trim or the sample truncation turns
exactly its own test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block added under "Confirmed against the shipped code, not restated from memory"
had every one of its line citations invalidated by the same PR that wrote them — stale
by 25-45 lines. The worst was `sync.ts:126-134`, cited as "the real trust computation,
unchanged since", which now points at a logging block inserted afterwards: an auditor
following the doc to verify the power-seat guard lands on unrelated code and can
reasonably conclude the guard was removed.

Symbols instead. Guardrail #6 is about claims that stay true, and a line number in a
file under active change is not one. The pre-implementation refs elsewhere in the doc
are left alone — they are labelled as such and are historically accurate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hape screen

firebase-functions-reviewer, Medium. Tightening ADMIN_SDK_EMAIL_SHAPE narrowed the
reason-less surface without closing it, because the screen is a SHAPE test and Identity
Toolkit's is a SEMANTIC one. "a@.", ".a@b.co", "a..b@c.co" each carry one @, no
whitespace and no control characters — so they pass this screen AND the Admin SDK's own
isEmail, reach the API, and come back auth/invalid-email, which nullIfUserNotFound
rethrew as an opaque `internal` with no details.reason. The operator got the generic
"No se pudo…" and the member stayed unprovisionable with no hint: the same dead end the
previous commit removed, reached by a different road.

Tagged at the PORT rather than by chasing regex precision, so the class is closed
however the pattern evolves — the regex is a cheap pre-filter now, not the sole
guarantee. Both Auth entry points route through it (getUserByEmail and createUser), and
one exported factory raises the refusal so the two layers cannot word it differently.

Also retargets two comments that this branch made false (guardrail #6):
- the "do NOT tighten this regex" test comment, written against the old pattern, now
  distinguishes the tightening that is safe (whitespace + \p{C}, which the server
  rejects anyway) from the one that is not (RFC structure).
- docs/engineering-guardrails.md cited firestore-deps.ts:169 for getRolesByIds, which
  now lives at :310. Cited by symbol.

NOT fixed, recorded instead: the power-seat loop reads one cargo per distinct term
serially with no ceiling (needs a console-written positions map with hundreds of terms;
rules are term-pinned, and it fails closed), no logWarn names the orphaned uid when
linkUid fails after createUser, and no beacon callable sets enforceAppCheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
firestore-security-reviewer, Low. `assertFails` is REASON-BLIND: the new converse rows
asserted only "denied", so a later change denying those writes for an unrelated reason —
a tightened softDeleteSafe(), a lost perm, a renamed fixture — would keep every row
green while the CEL/power boundary silently stopped being what denies them. That is the
green-for-the-wrong-reason failure this whole file was written to end, reintroduced by
the fix for it.

Each row now writes a grant-free JDL seat FIRST and asserts it succeeds: same principal,
same lane, same fixture shape, one cargo apart, so the conjunct under test is the only
difference between the ALLOW and the DENY.

Extended to the create lane too. createPositionsSafe() applies the same
`(boardSeatDelegate() || cargoAssignableByNonAdmin())` conjunct, and create:Member alone
reaches the boundary ONLY there — it has no update lane at all, so the update-only loop
never probed it.

Re-ran both rules mutations against the hardened rows: dropping the CEL conjunct turns
10 tests red across both lanes, widening boardSeatDelegate() to canDo() turns 3 red
including the manage:all create row that did not exist before.

Also documents the one place this mirror is deliberately STRICTER than the rules, which
the reviewer surfaced and I am not fixing: "unresolvable" here means absent from the
PARSED catalog, and parseDocs drops a doc for any schema violation — so a cargo that
exists with grants: [] but a malformed `description` locks the editor out of a takedown
currentCargoGrantsEmpty() would have allowed. Availability only, needs a malformed doc,
Admin can still clear it, and resolving around the schema would mean reading positions
past the validation that keeps unparsed data out of the client. The corruption that
matters — a bad `grants` — locks on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t cycle

Re-review of the previous commit. Verdict was fix-then-ship; this is the fix half.

M1 — `nullIfUserNotFound`'s contract ("null ONLY when the account does not exist —
a transient Auth error must throw, or a blip would misread a live linked account as
safely deleted") was asserted in two docblocks and pinned by NOTHING. `getUserByUid`,
the one the relink guard actually calls, had no coverage in this file at all, and
provision-member-login.test.ts drives hand-written fakes that bypass the port entirely.
The invisible mutation: widen the null branch to also swallow auth/internal-error and
the whole beacon suite stays green while an Identity Toolkit blip lets a caller
re-provision over a live account. Both lookups are now pinned both ways, and that exact
mutation turns the new BLOCKING row red.

L1 — the invalid-email tagging made provision-deps.ts and provision-member-login.ts a
real two-node import cycle, safe only because every cross-module reference sat inside a
hoisted function body. One top-level `const` reading across it — an ordinary-looking
edit — would throw at module evaluation, and index.ts pulls this graph into the shared
entry, so it would take out every trigger in the bundle at cold start. Nothing in the
repo lints for cycles. The refusal factories move to provision-errors.ts and the cycle
is gone, not documented; the remaining edge is type-only and erased.

L2 — the tagged throw discards the underlying Auth error, and firebase-functions treats
a thrown HttpsError as an expected refusal, so it emits no "Unhandled error" line. That
left the failure class with zero trace in Cloud Logging (guardrail #4). Logged now, code
only, never the address.

Three comments corrected, all of them claims of mine this branch made false — the
mistake-class this whole PR is about:
- two present-tense comments still said auth/invalid-email "is rethrown as an opaque
  internal", which is what the previous commit removed. They are the stated justification
  for the shape screen, so a reader checking them would conclude one of the two guards is
  redundant and delete the wrong one.
- "an Admin can still clear it" was false: an unresolvable cargo is absent from the
  option list and Combobox clears by re-selecting the selected option, so the Admin's
  remedy is reassignment.
- "a bad grants locks on BOTH sides" overreached: CEL defines .size() on strings and
  maps, so `grants: ""` or `{}` reads as empty to the rules.

And two vacuity guards on the converse block: a renamed principal label would have
silently emitted no `it` for the create-Member row — the one that block exists for — and
a principal reaching no lane would vanish rather than fail.

Not taken, recorded: auth/invalid-uid still surfaces opaque (a different field, needs a
console-written uid), and the create-lane fixture is a minimal doc rather than the
mapper's full output (full-payload create parity lives in rules.test.ts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviews: 0239ddf security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant