Skip to Content

← All archived runs

Run: tenant-app-access

run.md

Run: tenant-app-access

  • branch: claude/pipeline-tenant-app-access-3zft1y
  • pr: #886

02_define/output/spec.md

Spec: Standalone console app with invitation-only access

  • slug: tenant-app-access
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console (new), apps/web/proxy.ts, turbo.json, package.json
  • complexity: complex

Problem

Sustentus 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.

Proposed change

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.

Acceptance criteria

  • 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.
  • Every console route other than sign-in and invitation acceptance is deny-by-default: an unauthenticated request is redirected to the console's own sign-in, and no page content, route list, or API response is reachable without a marker.
  • A signed-in Sustentus staff member can invite a named person as Sustentus staff; the invitee receives Clerk's invitation email, completes acceptance on the console, and is recognised as staff — while belonging to no Clerk organization.
  • A signed-in Sustentus staff member can invite one named person as a partner; that person completes the same flow and is recognised as a partner — also belonging to no Clerk organization.
  • Inviting an email that already belongs to a Clerk user grants that existing user the chosen marker instead of failing, and they can sign into the console immediately with their existing credentials.
  • A signed-in user carrying neither marker is refused: they see a plain "no access" page and nothing else — no tenant data, no navigation, no other route reachable.
  • The console exposes no self-registration path: its sign-in surface offers no sign-up link, and the sign-up route refuses any request without a valid Clerk invitation ticket.
  • A staff member can revoke a pending invitation, after which its link no longer grants access.
  • A staff member can revoke a granted person's access, after which that person's very next console request is refused — no waiting for their session to expire.
  • A staff member can see who currently has access: pending invitations and granted people, each with their audience and a revoke action.
  • A partner cannot invite or revoke anyone — the access surface is Sustentus-staff-only.
  • A signed-in console user's home names their audience: "Sustentus" for staff, their own name for a partner.
  • An org-less platform user carrying either marker who lands on 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.
  • With the console's URL unconfigured in the platform's environment, apps/web behaves exactly as it does today — the redirect fails safe to /no-role.
  • The platform's org roles (the admin role included), session-token claims, session-token template, and Clerk webhook sync are unchanged; no Clerk instance-level sign-up restriction is introduced.
  • Every environment variable the new app or the redirect reads is declared in turbo.jsonglobalEnv.

Out of scope

  • Any tenant data on screen — the directory, tenant detail, lifecycle, users, plans and the activity dashboard are stubs 2–7 of this batch. The console's home is a placeholder this run.
  • A partner-firm entity — the person is the partner in v1. The partner is identified by their Clerk user id; display uses their own Clerk name and email. No firm name, tiers, certification, or more than one person per partner firm.
  • A complete roster of every marker holder. Clerk's user API cannot filter by 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.
  • Notifications of any kind [Q-12] — including telling anyone that access was granted or revoked. Clerk's own invitation email is not a notification we build.
  • Any change to platform org roles, the admin role included, and any change to the platform's claims, session-token template, or webhook sync.
  • Clerk satellite / multi-domain configuration. Settled by the subdomain choice above; it would only be needed if the console ever moved to a different root domain, and Clerk requires sign-in and sign-up to complete on the primary domain in that case.
  • Adding 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.
  • Partner or staff access inside tenant platform surfaces, including any cross-tenant impersonation.
  • Recording invite and revoke in an activity trail — the trail arrives with stub 7 (tenant-activity-dashboard), which is where every administrative action starts being recorded [Q-10].

Open questions

  • none

03_build/output/notes.md

Build notes: tenant-app-access

  • commits: shared access marker · apps/console · platform redirect + wiring
  • ci: GREENQuality 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.

What changed

  • 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.tsreadViewer / 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.
  • WiringCONSOLE_APP_URL + CONSOLE_AUTHORIZED_PARTIES in turbo.jsonglobalEnv; 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.

Tests

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.

Acceptance criteria status

  • Own Vercel project at its own hostname — the operator's step, in progress. The code side is complete (workspace package, 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.
  • Deny-by-default on every route — proxy.ts, not a layout, so it covers route handlers too; AGENTS.md carries the rule forward for later stubs.
  • Invite a named Sustentus staff member — Clerk invitation carrying the marker in publicMetadata, which Clerk copies onto the user on acceptance.
  • Invite one named partner — same path, audience: "partner".
  • Inviting an existing Clerk user grants that account instead of failing — Clerk refuses an invitation for an existing address, so the action writes the marker onto the account directly and says so.
  • A user with neither marker is refused — /no-access and nothing else.
  • No self-registration path — no sign-up link on sign-in; /accept requires a ticket; no Clerk instance-level restriction, so the platform's own self-registration is untouched.
  • Revoke a pending invitation — invitations.revokeInvitation.
  • Revoke a granted person's access — marker cleared; denied on the next request because the marker is read live rather than from a session claim.
  • See who has access — pending invitations and granted people, with audience and a revoke action. See the roster limitation under Notes for Verify.
  • A partner cannot invite or revoke — enforced in proxy.ts and in requireStaff(), so a hand-posted server action is refused too.
  • Home names the audience — "Sustentus" for staff, their own name for a partner.
  • A marked org-less platform user is redirected to the console — unmarked org-less users still get /no-role, and a user with an org is untouched on every path.
  • Unconfigured console URL → apps/web behaves exactly as today.
  • Platform roles, claims, session-token template and webhook sync unchanged — the whole platform diff is one branch on the existing no-role path.
  • Every env var declared in turbo.jsonglobalEnv.

Notes for Verify

  • The roster's known gap, and what covers it. Clerk's user API cannot filter by 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.
  • The project still needs finishing before that preview would work: the 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.
  • GitHub Actions stopped creating 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.
  • Bootstrap is a manual Clerk dashboard step, as the stub set out: mark the first staff member's publicMetadata by hand, then everyone else is invited in-app. Nobody can reach /access until that first marker exists.