Tenancy, membership, and the write gate
The schema is never the problem. Every table carries instanceId from
the first commit. Productizing changes how that id is resolved, in
exactly four places, and almost nothing else.
| Where | Resolves from |
|---|---|
| The dashboard workspace helper | a cookie, validated against your membership rows |
| The dashboard role check | the role on that membership row |
| The read-seam API | the ?host= it was called with |
| The public renderer | the Host header, as the cache key |
The workspace is the organization, and it is yours. Clerk
authenticates a person and does nothing else — no Clerk organization,
no active-org session claim, no orgId anywhere. Membership that lives
in Clerk cannot be joined, cannot enforce seats without a Backend API
round trip, and cannot express owner.
Resolution never creates a workspace
Self-healing from a missing slug is right for an empty dev database
and catastrophic the moment the input is a host header a stranger
controls. A value that resolves a workspace regardless of who is
asking is the same bug, even if you only meant it as a local
convenience — gate any slug fallback on NODE_ENV, not on the env
var being set.
The public-seam copy of that fallback must not touch the session.
Bearer routes are exempt from session middleware, so calling auth()
there throws; routing the fallback through the session-based resolver
turns every unknown host in development into a 500 instead of a 404.
Export a session-free helper for exactly this.
The cookie is a hint, never an authority
Every resolution re-checks membership. A forged or stale cookie naming someone else's workspace resolves to nothing. An account with one workspace never needs the cookie — fall back to "your only workspace" so the switcher is not a prerequisite. With several and no valid cookie, ask; guessing which of someone's workspaces they meant is worse than asking.
A cookie rather than a /w/<slug>/… path segment is a choice: one
resolver, no route that knows about workspaces. The cost, named:
two tabs on different workspaces share one cookie, so the second
tab's next navigation follows the first.
Membership rules that are real failures if broken
- The workspace and its owner are created in one statement. A workspace that exists for even a moment with no members is one nobody can reach — resolution is membership.
- The last owner cannot be demoted or removed. A workspace with no owner is a billing relationship with no counterparty.
- Ownership is transferred, never invited. Otherwise "invite" is a one-click way to hand away a billing relationship.
- The invitation link is the credential, so only its hash is stored. The plain token exists exactly once, in the response that made it — show it as something to copy, and a re-invite replaces the open invitation and kills the old link.
- An invitation is addressed. The email is checked on accept, so a forwarded link does not admit a colleague.
- Open invitations count against
seats. An invitation is a seat already spent; deferring the refusal to acceptance refuses the wrong person. - Every member action re-checks the role in the action, not only where the buttons are drawn. A server action is callable by anyone with a session.
emailon a membership is display-only.userIdis the identifier; nothing looks a member up by address.
Billing is a catalog, and the webhook is the only writer
The plan catalog has zero dependencies — no Stripe SDK. The pricing page, the upgrade screen, and the enforcement check all read it. Three sources of truth, and the one customers notice is whichever promised more.
FREEis the sentinel, not a tier. It means "no active subscription" and carries zero of everything. A FREE that is both the entry tier and the lapsed state cannot express a customer who stopped paying without also granting them capacity.planandsubscriptionStatusare separate columns. A plan alone is the last thing the customer chose; every gate wants what is in force. One function collapses them.- Past due keeps working. A failed card is a dunning problem;
taking a customer's website down over one is a support incident
and a refund, not leverage. Only
canceled/unpaidwithdraws the plan. A cancelled workspace keeps its plan column so an admin can say "your Growth plan ended" and a re-subscribe lands where they were. - The webhook is the only writer of billing state. Not the checkout return (a customer who closes the tab still paid; a forged return has paid nothing), not the portal, not an admin form. Read the raw body, gate on a unique insert of the event id, answer 2xx for anything you cannot use, and 5xx only on a real fault — deleting the idempotency row first so the retry may run.
- Entitlement is checked on the write, never on the route.
createPage,importSection,addDomain,publishTheme. A disabled button is a courtesy; the form behind it is a POST anyone can replay.publishThemeholds the gate, not the publish route — a save on the live theme is a publish. Seecms-publish-and-seam. - No secret key means nothing is enforced. One switch, and it is the thing that would actually be missing. A separate flag can disagree with it, and that disagreement is either a locked-out developer or a production deploy giving everything away.
An unpaid workspace is inert. Every gate resolves it to FREE
and it cannot add a page, import a section, or publish. A
workspace with no plan costs a row; a payment with no workspace
is a support incident. That is why onboarding is account →
workspace → payment even when the plan document said the other
order — a Checkout Session's client_reference_id is the only
thing tying the resulting customer to a tenant, and a workspace
has to exist to be referenced.
Domains: verification is yours, certificates are the platform's
Two independent steps. Verification — proof the customer controls
the name — is yours, over a DNS TXT record, and it sets
verifiedAt. Certificate issuance is the platform's, reported
into sslStatus, and gates nothing. Splitting them lets a
provider-less deployment still verify and route, and keeps the
routing gate off a third party's uptime.
- The verification token is minted once and never regenerated on a re-check.
- A verified domain is never un-verified. A flaky resolver must not take a live customer domain off the internet.
- Only a verified host can be primary. A primary that resolves to nothing is a "visit your site" button that 404s.
host_takendoes not say who has it — that is a fact about someone else's account.
Pair with cms-publish-and-seam for the host-keyed cache, which
is the other half of making tenancy a security property rather
than a performance one.