T 150
uid: T-150 title: Badges editor β Firestore overlay so admins add/edit badges without a deploy (T-022 step 6b) status: done area: auth-rbac created: 2026-07-02 updated: 2026-07-02 owner: girafeev1 assignee: User Management succeeds: T-022 related: T-025
Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW Attestation: read
AGENTS.md; this is the last unbuilt slice of the T-022 badge model β step 6b ("Settings gains a Badges editor tab so the owner can add/edit badges without a code deploy β¦ mirrors the/rbac/pageRulescollection pattern from PR #424"). T-022 was markeddonewhenRBAC_ENABLEDflipped, so this orphaned sub-step gets its own task.
Goal¶
Let an admin add / edit / remove badges (label, description, kind, grant strings) from the
Settings UI, persisted to a Firestore overlay, without a code deploy β exactly as page-access
rules already work via aote-system/rbac/pageRules/keys/{pageKey}. The code registry in
lib/rbac/badges.ts (BADGES) stays the shipped baseline and the fail-safe fallback.
What already shipped (context)¶
- The badge model (T-022 steps 1-5, 7): grammar, matcher,
BADGESregistry, claims, strict rules,RBAC_ENABLEDlive since 2026-06-25. - Step 6a β per-user assignment (Edit User β Badges picker) and, new on 2026-07-02, the
read-only badge catalog (
components/settings/BadgeCatalog.tsx, PR #850) + the badge-aware action gates (PR #844, T-025 phase 3). - So the ONLY missing piece is authoring badges from the UI.
The pattern to mirror (pageRules overlay, PR #424)¶
- Store:
lib/rbac/pageRulesStore.tsβgetPageRulesMap()readsrbac/pageRules/keys/{key}in theaote-systemDB, merges overDEFAULT_PAGE_RULES, caches 60 s,invalidatePageRulesCache()on write, fails open to the code defaults on any Firestore error/empty. (Note: deliberately noimport 'server-only'β Pages-Router compat.) - API:
pages/api/rbac/page-rules.tsβ admin-gated GET (merged map) + write (upsert), strips audit metadata, validates keys againstALL_PAGE_KEYS. - Hook:
lib/rbac/usePageRules.ts. - UI: the custom-role CRUD (
RolesTabinSettingsApp.tsx) is the closest editor precedent. - Badge analogues:
lib/rbac/badgesStore.ts,pages/api/rbac/badges.ts,lib/rbac/useBadges.ts, and a Badges editor (makeBadgeCatalogeditable for admins, or a sibling tab). - Firestore path:
aote-systemβrbac/badges/keys/{badgeId}(parent docrbac/badges,keyssubcollection β same shape as pageRules).
β The one architectural fork (needs owner/product intent before code)¶
pageRules works cleanly because its gate (requireRbac / getPageRulesMap) is async β it can
Firestore-read the overlay per request. Badges are different: the action gates
canPerformAction / expandBadges are synchronous and read the static BADGES constant, and
several run client-side (ProjectsApp, ProjectShowApp, FiscalYearConfig) against
session.user.badges + the bundled registry. A user carries badge ids in their claims; grants
are resolved from the registry at gate time. So "where do overlay-defined grants get enforced?" is
a real decision:
- (A) Author-only overlay (smallest, safest). The overlay drives the catalog display and
the assignment picker only; the enforced grants still come from the code
BADGES. New/edited badges are visible + assignable immediately, but their grants only take effect at the gates after a deploy that copies them into code. Honest but partial β "no-deploy" applies to authoring, not enforcement. Least risk to live authz. - (B) Overlay enforced server-side, code-default on client. Server gates resolve grants from the async merged registry (overlay βͺ code, cached + fail-open like pageRules); client gates keep the bundled code-default (lag until the next deploy/session). Server authz honours edits with no deploy; client affordances lag. Medium effort; the split is a documented wrinkle.
- (C) Fully overlay-enforced. Bake resolved grants (not just ids) into custom claims at onboarding/refresh so both server and client see overlay grants without a registry lookup. Cleanest UX, but claims have a ~1000-byte ceiling (grants can blow past it) and it re-shapes the claim contract. Most work + most risk.
DECISION (owner, 2026-07-02): (B), upgraded β "hide until confirmed". Server gates enforce the merged overlay immediately (the real security boundary). On the client, instead of letting buttons lag on a stale bundled default, action buttons stay HIDDEN until the client has fetched the authoritative (overlay-aware) answer, then reveal β so no wrong button ever flashes. Cost: on the first load, action buttons appear a beat after the page (once the permission fetch resolves), then cached so it feels instant. (C) is deferred unless first-paint client accuracy becomes a real ask.
Phasing (each its own PR; every phase keeps the code BADGES as the guaranteed fallback)¶
- β
DONE (2026-07-02).
lib/rbac/badgesOverlay.ts(pure merge/validation β no firebase, unit tested) +lib/rbac/badgesStore.ts(asyncgetBadgesMap()= codeBADGESβͺ overlay, 60 s cache,invalidateBadgesCache(), fail-open to code defaults). Overlay may add new ids AND override existing ones; anything invalid is dropped so the merged registry is never corrupt. No caller yet β zero behaviour change. (Mirrors pageRulesStore, pure logic split out for mock-free testability.) - β
DONE (2026-07-02).
pages/api/rbac/badges.ts: GET (merged registry β any authenticated user, since the catalog + client hook need it) + PUT/POST/DELETE (upsert / remove an overlay badge), gated on admin/super_admin role OR therbac:badges:configure@allbadge.validateBadgeInput(in badgesOverlay, unit-tested) enforces a slug id, known kind, non-empty label, and every grant valid with a known action/subsidiary. Writes go via Admin SDK, invalidate the cache, and audit-log. Still inert β no gate reads the merged map yet. - β
DONE (2026-07-02).
lib/rbac/useBadges.ts(client hook for the merged registry, fail-safe to code defaults) +components/settings/BadgeEditor.tsx(add / edit / reset / delete via a modal, tag-based grant entry with liveisValidGrantvalidation) wrapping a props-extendedBadgeCatalog; wired into Settings β Permissions. Code badges get "Reset to default"; overlay-only badges delete outright. tsc + lint clean; 105/105 rbac tests. Wants a visual pass in preview (no runtime click-through here). - β
DONE (2026-07-03, with the T-025 phase-4 cutover, commits
0cd266ca+97390ecb). Server:canPerformActionMergedresolves every action gate from the merged code+overlay registry β an overlay edit in Settings β Permissions is enforced with no deploy. Client:useBadges()+canPerformActionInwith hide-until-confirmed on the three action-button components (read-only banner suppressed while loading too). Overlay collection writes: covered by aote-system's default-deny (client writes blocked; only the admin-gated server endpoint via Admin SDK writes) β no explicit rule needed. T-150 complete; the editor UI got its visual pass by the owner on the 2026-07-03 deploy (feedback applied: engineers' grant disclosure removed).
Hard constraints¶
- The code
BADGESregistry is ALWAYS the baseline + the fail-open fallback β an empty/unreachable overlay must behave exactly as today. Never fail closed on a Firestore hiccup at an auth gate. - Admin-only writes; the overlay collection is server-SDK-write-only in the rules (mirror pageRules).
- Don't route the overlay read through the legacy
GOOGLE_*fallback βfirebase-adminsdk-fbsvc@only (service@deletion window; see T-025 / T-148). - Keep
import 'server-only'OUT ofbadgesStore.ts(Pages-Router transitive-import trap; see the pageRulesStore header note).
Decision (resolved)¶
Enforcement model (B) + "hide until confirmed" (owner, 2026-07-02) β see the fork section above. Phases 1-3 are unaffected by the choice; only phase 4 wires it in.
Log¶
- 2026-07-02 created + scoped by User Management (owner said "proceed to the next thing" after the T-025 phase-3 + badge-catalog PRs merged).
- 2026-07-02 decision (owner): (B) + hide-until-confirmed. Phase 1 DONE β
badgesOverlay.ts(pure merge/validation) +badgesStore.ts(cached, fail-open reader) + unit tests; dormant/additive, no caller yet. Branchclaude/t150-badges-editor. - 2026-07-02 Phase 2 DONE β
pages/api/rbac/badges.ts(admin-gated GET/PUT/DELETE withvalidateBadgeInput, audit + cache-invalidation). 10/10 overlay tests; tsc + lint clean. Still no gate reads the overlay β inert. Same branch/PR (#853). - 2026-07-02 Phase 3 DONE β
useBadgeshook +BadgeEditor(create/edit/reset/delete modal, validated tag grants) +BadgeCatalogextended with data/actions props, wired into the Permissions tab. tsc + lint clean; rbac 105/105. Editor UI wants a visual check in preview. Same branch/PR (#853). Phase 4 (enforcement + hide-until-confirmed) is the last one.