auth-hygiene-fixesrun.md01_define/output/spec.mdThe auth system carries live defects that are independent of the wider access-and-permissions
redesign, so they can and should be fixed now. The worst is a security-class gap: persona
dashboard routes are unguarded — requireRole() exists (apps/web/lib/auth.ts) and protects admin
settings pages, but no persona dashboard segment calls it. Root entry redirects by role, yet with no
per-route guard a stale redirect on account switch or direct navigation can render another role's
dashboard (e.g. a customer seeing the admin dashboard). Alongside it sit four smaller hygiene
defects: the CSM portfolio-snapshot cron can never run in production (the proxy 302s /api/cron/*
to sign-in before the handler's CRON_SECRET check runs); an unrecognized Clerk org role is
JIT-provisioned as admin (?? "admin" — latent privilege escalation); ROUTE_POLICIES ordering
is manual and untested with a docstring that misdescribes the actual deny behaviour; and the role
enum is duplicated in ≥4 places with no shared constant. This is Phase 0 of the
platform-access-permissions epic (Build the Bridge → 2026-Q2 Objective 3, Validate Technical
Infrastructure & Payout Flow): making the existing role system trustworthy is the prerequisite for
the multi-role work that follows, and G2/G5 are shared prerequisites of that track. The role-guard
gap is also catalogued in the qa-backlog report, which explicitly cross-links the fix to this
epic rather than duplicating it — this run owns and closes it, so the two do not double-track.
Fix the Phase 0 set, with no behaviour change for legitimate users:
/api/cron(.*) to the proxy's public-route matcher so the request
reaches the handler; the handler keeps its CRON_SECRET gate as the sole authorization check.?? "admin" JIT-provisioning fallback; an
unmapped Clerk org role provisions no role and lands the user on /no-role.ROUTE_POLICIES ordering safe (sort by specificity at
module load, or a unit test asserting no entry shadows a longer/more-specific one) and correct the
stale getAllowedRoles docstring so it matches the proxy's actual deny-by-default behaviour.UserRole source of truth from packages/services (its
shared entry point) and consume it in apps/web/lib/auth.ts, the Mongoose user enum, and
apps/demo/lib/personas.ts, removing the independent definitions./no-role hygiene (minor): style the /no-role page to match the app and stop rendering
the raw Clerk userId./api/cron/csm-portfolio-snapshot reaches the handler and is
rejected only by the CRON_SECRET check — no redirect to /sign-in./no-role.ROUTE_POLICIES entry shadows a more specific one, and the
getAllowedRoles docstring matches the proxy's actual deny behaviour.UserRole source of truth exported from packages/services; a grep finds no
independently-defined role enums in web, demo, or services./no-role page is styled consistently with the app and no longer renders the raw Clerk
userId.clerk-role-sync feature.02_build/output/notes.mdapps/web/app/(app)/{csm,sdm,customer,expert,vendor}/layout.tsx (new) — per-segment
layout guards calling requireRole("<persona>"). These five segments are single-persona
(every sub-route belongs to that one role), so a blanket segment guard is safe and is the
report-preferred approach.apps/web/app/(app)/admin/dashboard/page.tsx — in-page await requireRole("admin"). The
admin segment is the exception: /admin/settings/*, /admin/industries, /admin/status
are intentionally shared with CSM/SDM, so a segment-level requireRole("admin") layout would
lock legitimate CSM/SDM users out (a behaviour change we must avoid). admin/page.tsx
re-exports the dashboard page, so guarding the page covers both /admin and /admin/dashboard
without touching the shared sub-routes. This is defence-in-depth on top of the existing proxy
policy — a stale role on account switch or direct navigation can no longer render another
persona's dashboard.apps/web/proxy.ts — added /api/cron(.*) to the isPublicRoute matcher. The handler keeps
its CRON_SECRET bearer check as the sole authorization; the proxy no longer 302s the cron
request to /sign-in before that check runs.apps/web/lib/resolve-app-user-for-tenant.ts — removed the ?? "admin" JIT-provisioning
fallback. An unmapped Clerk org role now returns { ok: false } (no provisioning) instead of
silently minting an admin. The proxy already redirects an unmapped-role user to /no-role
before they reach the app; this closes the defence-in-depth escalation for any other entry path.
All callers already handle the ok: false branch (discriminated union), so no caller change.apps/web/lib/route-policies.ts — the authored rules are now ROUTE_POLICY_RULES, and the
exported ROUTE_POLICIES is that list sorted by path length descending (then exact-first).
Shadowing can only happen when a non-exact rule's path is a prefix of another's, and a prefix is
always strictly shorter — so the sort guarantees the more-specific rule is evaluated first,
regardless of authoring order. This prevents shadowing by construction, which is stronger
than a test — and the repo has no test infrastructure (CONVENTIONS bans writing tests), so the
stub's "unit test" alternative was not an option. Fixed the stale getAllowedRoles docstring
(and the header comment) which claimed a non-matching path "passes through unrestricted" — the
proxy actually treats null as deny-by-default and redirects to the user's home.packages/services/src/shared/roles.ts (new) — canonical USER_ROLES tuple + derived
UserRole type, in the isomorphic /shared entry point.packages/services/src/shared/index.ts — export the new module.packages/services/src/db/models/types.ts — UserRole is now imported from shared/roles and
re-exported, so every existing models/types importer resolves unchanged.packages/services/src/db/models/user.ts — Mongoose role enum is now [...USER_ROLES] instead
of a hardcoded array.apps/web/lib/auth.ts — removed the local VALID_ROLES tuple + UserRole type; imports both
from @sustentus/services/shared and re-exports UserRole so the many @/lib/auth importers
are unaffected.apps/demo/lib/personas.ts — kept dependency-free (per the standalone-dashboards-app rule and an
explicit product decision); added a comment tying its persona ids to the canonical USER_ROLES
and marking it the single local source of truth for the demo.apps/web/app/(auth)/no-role/page.tsx — restyled with Typography + a centred card and no
longer renders (or even reads) the raw Clerk userId.requireRole./api/cron/csm-portfolio-snapshot reaches the handler, rejected only by
CRON_SECRET — cron path added to the proxy public matcher./no-role — ?? "admin"
removed (fail closed) + existing proxy redirect to /no-role.UserRole source of truth in packages/services; consumed in web + Mongoose enum —
shared/roles.ts. The remaining full-six-role literals in the codebase are role-subset
allow-lists (typed UserRole[], not enum definitions) and one pre-existing test fixture
(apps/web/lib/workspace/capabilities.test.ts), left untouched (no drive-by refactor;
demo left dependency-free by decision)./no-role no longer renders the raw Clerk userId — page rewritten./admin/* has routes shared with CSM/SDM). See the role-guard
section above.apps/demo was deliberately NOT wired to the shared UserRole — it is the standalone,
dependency-free dashboards app, and importing @sustentus/services would regress that. Confirmed
as a product decision during Build.03_release/output/investor-update.mdWho it's for: Admins — and, indirectly, all six personas. What shipped: Every persona dashboard is now role-guarded, the daily portfolio-snapshot job can run in production, and an unrecognised sign-in can no longer be granted admin access. Why it matters: We closed a set of access-control gaps before real money moves — hardening the technical infrastructure that Build the Bridge depends on (2026-Q2 Objective 3).
Dig deeper: <merged-PR URL>