SpectreSkills
← All skills

cms-tenancy

v1.0.0

Tenant a multi-tenant CMS by resolving a workspace you own — membership is yours (Clerk authenticates a person and nothing else), resolution never creates a workspace, the active-workspace cookie is a hint, and billing gates on the write. Trigger when adding workspaces, domains, seats, Stripe, or host routing to a page builder.

CMS & Page Builderscmstenancybillingmembership
Install
npx @spectre-apps/skills add cms-tenancy

Writes .claude/skills/cms-tenancy/SKILL.md. Add --user to install globally.

What it does

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.

WhereResolves from
The dashboard workspace helpera cookie, validated against your membership rows
The dashboard role checkthe role on that membership row
The read-seam APIthe ?host= it was called with
The public rendererthe 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.
  • email on a membership is display-only. userId is 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.

  • FREE is 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.
  • plan and subscriptionStatus are 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 / unpaid withdraws 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. publishTheme holds the gate, not the publish route — a save on the live theme is a publish. See cms-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_taken does 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.