#115 extends the existing PromoCode (voucher) admin surface so a
sovereign-admin role can issue, list, and revoke vouchers on a
franchised Sovereign. No new endpoints, no new schema, no new CRD —
all the changes are role-gating widenings on the existing surface.
Backend (core/services/billing/handlers/handlers.go):
- New `requireVoucherIssuer` helper accepts both `superadmin` and
`sovereign-admin`. Used by AdminListPromos, AdminUpsertPromo, and
AdminDeletePromo only. All other admin endpoints (Stripe settings,
revenue, orders) keep the existing `requireAdmin` (superadmin-only).
UI (core/admin/src/components/AdminShell.svelte + BillingPage.svelte):
- AdminShell now accepts both roles. Sidebar nav is filtered by role:
superadmin sees Revenue / Catalog / Tenants / Orders / Billing;
sovereign-admin sees only Billing. Filtering is via a
`superadminOnly` flag on each nav item (defence-in-depth: even if
a sovereign-admin guesses a URL, the backend's requireAdmin will
return 403).
- BillingPage hides the Stripe Configuration section for
sovereign-admin (it would 403 from GET /billing/admin/settings
anyway). The Vouchers (Promo Codes) section is shown to both roles
with a small label tweak ("Issued vouchers are scoped to this
Sovereign" for sovereign-admin).
Per docs/INVIOLABLE-PRINCIPLES.md §1 (target-state shape, no MVP)
and §3 (follow documented architecture exactly) — this matches the
FRANCHISE-MODEL.md design where "every franchised Sovereign runs the
same admin app" with role-based gating.
Closes#115.