Skip to content

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/pageRules collection pattern from PR #424"). T-022 was marked done when RBAC_ENABLED flipped, 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, BADGES registry, claims, strict rules, RBAC_ENABLED live 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() reads rbac/pageRules/keys/{key} in the aote-system DB, merges over DEFAULT_PAGE_RULES, caches 60 s, invalidatePageRulesCache() on write, fails open to the code defaults on any Firestore error/empty. (Note: deliberately no import 'server-only' β€” Pages-Router compat.)
  • API: pages/api/rbac/page-rules.ts β€” admin-gated GET (merged map) + write (upsert), strips audit metadata, validates keys against ALL_PAGE_KEYS.
  • Hook: lib/rbac/usePageRules.ts.
  • UI: the custom-role CRUD (RolesTab in SettingsApp.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 (make BadgeCatalog editable for admins, or a sibling tab).
  • Firestore path: aote-system β†’ rbac/badges/keys/{badgeId} (parent doc rbac/badges, keys subcollection β€” 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)

  1. βœ… DONE (2026-07-02). lib/rbac/badgesOverlay.ts (pure merge/validation β€” no firebase, unit tested) + lib/rbac/badgesStore.ts (async getBadgesMap() = code BADGES βˆͺ 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.)
  2. βœ… 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 the rbac:badges:configure@all badge. 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.
  3. βœ… 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 live isValidGrant validation) wrapping a props-extended BadgeCatalog; 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).
  4. βœ… DONE (2026-07-03, with the T-025 phase-4 cutover, commits 0cd266ca + 97390ecb). Server: canPerformActionMerged resolves every action gate from the merged code+overlay registry β€” an overlay edit in Settings β†’ Permissions is enforced with no deploy. Client: useBadges() + canPerformActionIn with 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 BADGES registry 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 of badgesStore.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. Branch claude/t150-badges-editor.
  • 2026-07-02 Phase 2 DONE β€” pages/api/rbac/badges.ts (admin-gated GET/PUT/DELETE with validateBadgeInput, 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 β€” useBadges hook + BadgeEditor (create/edit/reset/delete modal, validated tag grants) + BadgeCatalog extended 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.