routes-permission-cutoverrun.md01_define/output/spec.mdAuthorization on apps/web is still role-only and enforced by four uncoordinated layers. The route
gate is one of them: apps/web/lib/route-policies.ts is ~50 hand-ordered { path, roles } entries
plus three hardcoded special cases in getAllowedRoles (the service-lead-detail regex, /api/brd/,
/api/onboarding/), and the two AI route handlers (api/brd/chat, api/onboarding/chat) hand-roll
their own role !== "…" checks. None of it is connected to the permission model landed by
permission-foundation, so route reachability and action access can silently disagree (findings §2.3
Layers 1 & 3, §5.3, defect G6). This is the route-layer half of the epic's enforcement cutover under
Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow: until
every layer reads one resolved permission set, "who can reach this" drifts from "who can do this", and
a role-template change has to be chased through hand-written route code.
Make the route gate read from the permission model where the model covers the surface, and give the AI route handlers a shared, permission-based gate — while keeping the proxy a coarse edge check per Clerk's guidance that middleware is not the resource-level gate.
requiredPermission for the surfaces the registry models. For the
shared workspace-feature routes (leads, proposals, activity, finances, csat, people), each policy
names the permission it requires; the allowed-roles set the proxy uses is derived from that
permission against the code-defined default role templates (PLATFORM_ROLE_TEMPLATE_DEFAULTS) —
a static inversion computed at module load, not a per-request database read. Admin is included for
every permission (it holds the full registry), mirroring the resolver's admin allow-all.reason (see scope decision 1 below). Extending the registry to cover them is
permission-foundation (stub #3), out of scope here.ui-capability-cutover, stub #7). Per-tenant template customisations and per-user overrides are
not resolved in the proxy./api/brd becomes a
brd.view policy entry, /api/onboarding an explicit {admin,vendor} entry, and the
service-lead-detail regex is removed — the /service-leads prefix rule already covers detail pages.requirePermission() helper for route handlers (apps/web/lib/api/require-permission.ts)
and adopt it in api/brd/chat with brd.view, replacing its hand-rolled role !== "customer"
check; its lead-ownership check stays (among brd.view holders only the lead's own customer passes
it, so the endpoint stays customer-scoped). api/onboarding/chat keeps a documented role gate — no
registry feature models fresh-tenant onboarding. The cron route keeps its CRON_SECRET gate.requiredPermission; those routes'
proxy roles are derived from that permission against the default role templates, not hand-written.
Routes with no registry feature keep an explicit role list, each annotated with a reason.requiredPermission) changes
which personas the proxy admits to the corresponding routes without editing route code,
demonstrated for at least one route/permission pair.getAllowedRoles are gone: /api/brd and /api/onboarding
are policy-table entries, and the service-lead-detail regex is removed (covered by the
/service-leads prefix rule).api/brd/chat enforces access via the shared requirePermission() helper (returning 403 for a
principal lacking the permission) with its lead-ownership check intact; api/onboarding/chat
keeps its role gate with a documented reason (no registry feature to require).null (deny-all), and no route becomes reachable without a valid
session and role. Where derivation broadens a route's reachable role set, that is the accepted
coarse-proxy behaviour (decision 2), contained by stub #7 downstream.actions-permission-cutover, stub 5) and workspace UI visibility
(ui-capability-cutover, stub 7) — the latter is what contains the coarse-proxy broadening.permission-foundation (stub 3), which this run consumes unchanged.CRON_SECRET gate unchanged.@sustentus/services/shared — was needed so the
middleware-bundled route policies can derive without importing the DB-backed /server resolver.02_build/output/notes.mdThe permission-foundation registry that this run consumes is an 18-feature workspace/delivery
vocabulary (lead, invoice, proposal, csat, people, activity, …) — it does not model the
~30 admin/config/settings, role-home and persona-specific routes in route-policies.ts, and its
feature-view permissions are coarser than the old route allow-lists. Two decisions were confirmed
with the owner (via AskUserQuestion) before coding and are reflected in the revised spec.md:
requiredPermission; the ~30 routes with no feature keep an explicit role list
with a documented reason. Extending the registry to cover them is stub #3, not this run.view makes several
routes (e.g. /service-leads, /proposals, /finances) reachable by more personas at the proxy.
This is the stub's stated coarse-proxy design; the finer read-gating that contains it lands with
ui-capability-cutover (stub #7). Accepted interim exposure.packages/services/src/shared/index.ts — re-export the pure half of the permission engine
(Permission type, PLATFORM_ROLE_TEMPLATE_DEFAULTS) via /shared. route-policies.ts is bundled
into the Clerk middleware, so it cannot import the DB-backed /server barrel (Mongoose) to derive
roles. /shared is the isomorphic, edge/Node-middleware-safe entry; the modules re-exported carry no
DB dependency.apps/web/lib/route-policies.ts — rewritten. A policy is now either { requiredPermission }
(roles derived from the default role templates by a static rolesForPermission() inversion) or
{ roles, reason } (explicit, for surfaces the registry doesn't model). Derived: /workspace,
/service-leads → lead.view; /proposals → proposal.view; /activity → activity.view;
/finances/invoices, /finances → invoice.view; /finances/approvals → invoice.approve;
/users → people.view; /csat → csat.view; and the folded /api/brd → brd.view. The
specificity sort (auth-hygiene G4 invariant) and getAllowedRoles' deny-by-default are preserved.getAllowedRoles — the three hardcoded branches removed: /api/brd and /api/onboarding are
now policy-table entries; the service-lead-detail regex is gone (the /service-leads prefix rule
covers detail pages, now at lead.view = all six).apps/web/lib/api/require-permission.ts — new shared route-handler gate mirroring the
server-action resolveActionContext permission path: resolves principal + tenant, runs the DB-backed
resolveEffectivePermissions, enforces one permission (401/403), returns { userId, tenantId, appUser, scope } on success so handlers keep their resource-level checks.apps/web/app/api/brd/chat/route.ts — adopts requirePermission("brd.view"); the hand-rolled
auth() + role !== "customer" checks are replaced. Lead-ownership check retained → still
customer-scoped (only the lead's own customer, among brd.view holders, passes it).apps/web/app/api/onboarding/chat/route.ts — role gate retained with a documenting comment (no
registry feature models onboarding; that's stub #3).requiredPermission and derive their roles via
rolesForPermission(); unmodelled routes keep explicit roles + a reason — route-policies.ts.lead.view
from the sdm template in PLATFORM_ROLE_TEMPLATE_DEFAULTS drops sdm from /workspace and
/service-leads on the next module load, no edit to route-policies.ts.getAllowedRoles.api/brd/chat enforces via requirePermission("brd.view") (403 on failure) with lead-ownership
intact; api/onboarding/chat keeps a documented role gate.null that previously
matched. Broadening on some derived routes is the accepted coarse-proxy behaviour (decision 2).apps/web now imports Permission + PLATFORM_ROLE_TEMPLATE_DEFAULTS
from @sustentus/services/shared; turbo's typecheck depends on ^build, so services rebuilds its
dist/shared/*.d.ts first — expected to resolve cleanly./users and /activity are deliberately not derived — their pages render the full tenant
directory / platform-wide activity log with no requireRole guard and no per-persona scoping, so
deriving from people.view / activity.view would admit own-scoped personas (customer, and
expert/vendor for activity) to every user's PII (emails, phone numbers). They keep today's staff-only
{admin,csm,sdm} allow-list with a documented reason, restoring the exact pre-cutover access for
these two routes. All other broadening (decision 2) stands.
/service-leads, /proposals, /finances, /csat (and the
lead.view detail pages) become reachable by more personas at the proxy until stub #7 adds
page-level view gating — the accepted coarse-proxy behaviour. /users and /activity are carved
back to staff-only (see above) because their pages leak PII with no downstream containment. If the
same staff-only treatment is wanted for the workspace-list routes too, say so and I'll pin them.brd.view on /api/brd: staff (csm/sdm/admin/expert) now pass the proxy and the
requirePermission check but 403 at lead-ownership; vendor (no brd.view) is blocked at the proxy.
Net behaviour is still customer-only, now permission-derived./shared packaging change: exposing the pure permission vocabulary isomorphically is the minimal
way to let the middleware derive without the DB barrel — no registry change.03_release/output/investor-update.mdWho it's for: All six personas — platform-wide enforcement. What shipped: The web app's route gate and AI chat endpoints now derive access from the central permission model, replacing ~50 hand-maintained role rules. Why it matters: One authorization source across every layer hardens the infrastructure behind the payout flow, and lets a role change reroute access without code edits.
Advances Build the Bridge — Objective 3: validate technical infrastructure & payout flow.
Dig deeper: <merged-PR URL> · <technical docs URL>
03_release/output/release.mdapps/docs/app/technical/packages/services/page.mdx (Permissions section — pure vocabulary now also on /shared; route policies + AI handlers now enforce via the model)03_release/output/investor-update.md)/code-review high)/api/brd and /api/onboarding were added (folding the hardcoded getAllowedRoles
branches), and the service-lead-detail regex is subsumed by the /service-leads prefix rule (same
all-six result). Specificity sort + deny-by-default unchanged. getAllowedRoles's only consumer is
proxy.ts (signature unchanged); the RoutePolicy→RoutePolicyRule/ResolvedPolicy rename has no
external importers./users and /activity deriving from
people.view/activity.view would leak full-tenant PII to own-scoped personas. Fixed in 879f64c
(carved back to staff-only with REASON_FULL_TENANT_PII); VADE marked it ISSUE_RESOLVED and
confirmed /csat stays safely derived (CsatTable self-scopes). Thread resolved./service-leads, /proposals, /finances,
/csat — owner decision 2; finer read-gating lands with stub #7. Surfaced to the owner.gate.scope is unused in api/brd/chat (handler does explicit lead-ownership); the
resolver does one internal user lookup after findByClerkUserId (mirrors resolveActionContext,
cached). Neither warrants a change.requiredPermission; roles derived via rolesForPermission()
(inverting PLATFORM_ROLE_TEMPLATE_DEFAULTS); unmodelled routes keep explicit roles + reason.getAllowedRoles branches are folded/removed (verified: /api/brd +
/api/onboarding entries present; regex gone; all 46 paths retained).api/brd/chat enforces via requirePermission("brd.view") (403 on failure) with lead-ownership
intact; api/onboarding/chat keeps a documented role gate.null; broadening is the
accepted coarse-proxy behaviour; the PII routes carved back to staff-only.