tenant-app-accessrun.md02_define/output/spec.mdSustentus staff and partners have no surface of their own for overseeing tenants. Tenant
administration is trapped inside the tenant-facing platform, and partners have nowhere to work at
all, which blocks the partner-led onboarding motion that Scale the bridge → Establish
product-market fit with vendor partners (2026-Q2, Objective 1) depends on. Neither actor even has
an identity home yet: nothing in the platform's Clerk instance distinguishes a Sustentus staff
member or a partner from any other user, and an org-less user who signs into the platform today
lands on the /no-role dead end. This run stands the app up and gives both audiences an identity —
the foundation the other six stubs in this batch build on.
Add apps/console (@sustentus/console) — a Next.js app in the monorepo, deployed as its own
Vercel project at console.sustentus.com, a subdomain of the platform's root domain. It signs
in against the same Clerk instance as apps/web using the same publishable and secret keys.
Because Clerk shares authentication across subdomains of one root domain by default, no satellite
(multi-domain) configuration is needed and the app hosts its own sign-in at its own address.
Both audiences are org-less Clerk users distinguished by one user-level publicMetadata marker:
publicMetadata.consoleAccess = { audience: "sustentus" | "partner" };
Absent marker → no access. Neither audience joins any Clerk organization, so Sustentus never appears as a tenant of its own platform and partners never enter tenant orgs.
Access is invitation-only [Q-1, Q-2]. A signed-in Sustentus staff member invites a named person
and picks their audience. For an email with no Clerk user, this creates a Clerk invitation carrying
the marker in its publicMetadata (Clerk copies it onto the user on acceptance); for an email that
already belongs to a Clerk user — a platform user, or the bootstrap staff member marked by hand in
the Clerk dashboard — it grants by writing the marker onto that existing user, who can then sign in
with the credentials they already have. Revoking removes access either way: a pending invitation is
revoked in Clerk, and a granted user has the marker cleared. The console re-reads the marker
server-side on every request, so a revoked person is denied on their next request without waiting
for a session to expire.
The console hosts no self-registration path: its sign-in offers no sign-up link, and the only
sign-up route (/accept) works solely with a valid Clerk invitation ticket. The Clerk instance's
own sign-up settings are not changed — the platform's own self-registration keeps working, and
an uninvited person who creates a platform account and then visits the console simply carries no
marker and gets nothing.
apps/web learns one thing: in proxy.ts, where an org-less signed-in user is currently sent
to /no-role, a user carrying either marker is redirected to the console instead. Everything else
about the platform's identity model is untouched — no role changes, no new session claims, no
webhook changes, no user migration.
Deployment note — a stale Vercel project already exists. The sustentus team already holds a
Vercel project named tenant-management (prj_AC4YFUysMSYfRZUDoW0AoLM1ivCR), created ahead of this
work but pointed at apps/demo as its root directory, never made live, and carrying no custom
domain. It is harmless today — turbo-ignore skips it — but under that name it would silently
deploy the demo app the moment anyone treats it as the console. Standing the console up means
either repointing that project's root directory at apps/console and giving it the console
hostname, or creating a fresh project and deleting this one. Whichever is chosen, this run does not
leave a Vercel project named for this app building a different app.
Signed-in console users land on a placeholder home that names their audience ("Sustentus" or the partner's own name) until the dashboard arrives in a later stub.
apps/console builds and deploys as its own Vercel project served at its own hostname, a
subdomain of the platform's root domain, using the same Clerk publishable and secret keys as
apps/web and no Clerk satellite configuration.apps/web is redirected to the
console instead of /no-role. An org-less user with neither marker still gets /no-role, and
a user with an org is unaffected on every path.apps/web behaves exactly
as it does today — the redirect fails safe to /no-role.admin role included), session-token claims, session-token
template, and Clerk webhook sync are unchanged; no Clerk instance-level sign-up restriction is
introduced.turbo.json →
globalEnv.publicMetadata, so the access list is derived from the invitations this app created plus the
people it granted. A marker set by hand in the Clerk dashboard — the bootstrap staff member — does
not appear in the list until that person is re-granted through the app. Bootstrapping the first
staff member is a one-time manual Clerk dashboard step, as the stub sets out.admin role included, and any change to the platform's
claims, session-token template, or webhook sync.authorizedParties to apps/web's middleware. The console sets it for itself;
hardening the platform's own middleware is a separate change to a surface this run leaves alone.tenant-activity-dashboard), which is where every administrative action starts being recorded
[Q-10].03_build/output/notes.mdapps/console · platform redirect + wiringQuality Project (format · lint · typecheck · tests), Vercel – web
and Vercel – tenant-management all pass. The console's preview is the first one
apps/console has ever produced.packages/services/src/shared/console-access.ts (new) — the one home for the
publicMetadata.consoleAccess = { audience: "sustentus" | "partner" } marker:
parseConsoleAccess, consoleAccessGrant, consoleAccessRevocation,
isConsoleStaff, and resolveConsoleOrigin. It lives in /shared rather than in
the console because two apps read the same marker — the console authorizes on
it and the platform redirects on it — and a second copy of an access rule is how
the two drift apart. Pure and isomorphic, so middleware can call it. Parsing is
fail-closed: anything that is not exactly a known audience reads as no access,
which is also what makes revocation (writing null) work.apps/console/ (new) — @sustentus/console, Next 16, dev port 3006, its own
Vercel project via vercel.json's turbo-ignore. Signs in against the same Clerk
instance and keys as apps/web; no satellite configuration anywhere, because the
app is served from a subdomain of the platform's root domain.proxy.ts — the deny-by-default gate, covering every route rather than only a
gated segment: unauthenticated → /sign-in; signed in without a marker →
/no-access and nothing else; /access additionally requires the staff marker.app/(auth)/sign-in — its own sign-in, footer action hidden so no sign-up link.app/(auth)/accept — the only sign-up surface; refuses any request without a
Clerk invitation ticket.app/(console)/ — the gated shell, plus a placeholder home that names the
viewer's audience, and the staff-only /access surface (invite, roster, revoke).lib/console-access.ts — readViewer / requireViewer / requireStaff, reading
the marker live from Clerk (React-cached per request), which is what makes a
revocation deny the very next request instead of waiting for a session to expire.apps/web/lib/console-redirect.ts (new) + one branch in apps/web/proxy.ts —
the single thing the platform learns. On the already-terminal no-role path, an
org-less user carrying a marker is redirected to the console. Fails safe in
every direction (unset URL, no marker, any Clerk error → today's /no-role), and
costs nothing on any route a tenant user actually takes.CONSOLE_APP_URL + CONSOLE_AUTHORIZED_PARTIES in turbo.json →
globalEnv; apps/console globals block in eslint.config.ts; console:build in
the root package.json; pnpm-lock.yaml updated for the new workspace package;
.env.example for both apps; apps/console/AGENTS.md.packages/services/src/shared/console-access.test.ts — written from the acceptance
criteria, not the implementation: that a staff member and a partner are each
recognised, that an uninvited or revoked user is refused, and that a malformed,
unknown or non-object marker is refused rather than tolerated. Plus the origin
resolver's fail-safe cases (unset, not a URL, non-http protocol). Not run locally —
the Quality workflow's Run tests step is the signal.
vercel.json turbo-ignore, same
Clerk keys, no satellite config); the existing tenant-management project is
being repointed from apps/demo to apps/console by Jamie, who also sets the
hostname and env vars. Verify smokes it on the preview.proxy.ts, not a layout, so it covers route
handlers too; AGENTS.md carries the rule forward for later stubs.publicMetadata, which Clerk copies onto the user on acceptance.audience: "partner"./no-access and nothing else./accept requires a
ticket; no Clerk instance-level restriction, so the platform's own
self-registration is untouched.invitations.revokeInvitation.proxy.ts and in
requireStaff(), so a hand-posted server action is refused too./no-role, and a user with an org is untouched on every path.apps/web behaves exactly as today.turbo.json → globalEnv.publicMetadata, so "people with access" is derived from the invitations this app
created. Someone granted directly onto an existing account — including the staff
member bootstrapped by hand in the Clerk dashboard — leaves no invitation behind and
does not appear in that table. The "Check an address" lookup on the same page is
the answer: it reads any account's live marker and revokes it. Worth a look during
review, since the spec's Out-of-scope note describes the gap slightly more loosely
than what shipped.touches: grew by one path. The spec named apps/console, apps/web/proxy.ts,
turbo.json, package.json. The marker also landed in
packages/services/src/shared/ because both apps read it and CONVENTIONS.md
forbids a second copy of a shared helper. Also touched, mechanically:
eslint.config.ts, pnpm-lock.yaml, and the two .env.example files.CONSOLE_AUTHORIZED_PARTIES is deliberately opt-in. Pinning Clerk's
authorizedParties to a fixed origin would 401 every preview deployment, whose
per-commit hostname no allowlist can predict. Unset it is Clerk's default
behaviour; set in production it shuts the sibling-subdomain door. Both states are
documented in .env.example.apps/console/vercel.json uses --fallback=main, deliberately unlike its
siblings. I first copied the house pattern, --fallback=HEAD^1, and it turned out
to be wrong for a new app: turbo-ignore falls back only when Vercel has no
previously-deployed SHA for the project on that branch, which is exactly the case
before an app's first successful preview — and HEAD^1 then compares against the
immediately previous commit. On this branch the introducing commit (a912667) was
soon several commits back, so every subsequent push compared a docs-only diff, found
no apps/console change, and skipped again. Two heads in a row were Ignored that
way, including one caused by the commit that first documented the problem. It is
self-perpetuating: without a commit touching the app, the app that has never
deployed never deploys. --fallback=main asks the right question on a feature
branch — "what does this differ from on the base branch?" — and for a new app the
answer is "everything", so it builds. The existing apps keep HEAD^1 because they
have deployment history and the trap cannot reach them; this is not a drive-by
change to them.console.sustentus.com domain is not attached (only the default *.vercel.app
hostnames), and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY and
CONSOLE_APP_URL need values on it. Without the Clerk keys the app builds but
cannot sign anyone in.pull_request runs mid-run. For a stretch of
~40 minutes no workflow run was created for new heads at all (head c313a97 had
zero), while older runs sat queued from 15:16 onward and never started. Nothing
had failed — the runs simply did not exist, so there was nothing to re-run.
quality.yaml declares workflow_dispatch, so Quality was dispatched manually on
the branch; it started immediately, which proves runners were free and the
pull_request events were the thing being dropped. Same workflow, same commit, same
checks — not an empty commit and not a re-run papering over a failure. If a later
push here reads PENDING with no run created, dispatch Quality on the branch rather
than pushing again; and it is worth checking whether other PRs in the repo are
sitting without required checks for the same reason.publicMetadata by hand, then everyone else is invited in-app.
Nobody can reach /access until that first marker exists.