SaaS-Stack integration

Sitebin Enterprise is a first-class app of the open-source IT-Trail SaaS Stack — onboard it like any of your apps and it inherits single sign-on, consent, subscription billing and GDPR self-service across your whole portfolio.

This is an Enterprise feature (included in every paid tier). It is built from independent pieces — generic OIDC sign-in, a PayGate subscription source, and the stack's consent and GDPR contracts — so the sign-in also works on its own: the OIDC provider accepts any spec-compliant issuer (Keycloak, Okta, Authentik, Microsoft Entra).

What you get

  • Single sign-on — users log in to Sitebin with the same identity they use for every other app on your stack, via the stack's Auth Gateway.
  • Consent at sign-in — the stack asks each user to accept your platform's terms once, and then Sitebin's own documents (on sitebin.io: the terms of service and the data processing agreement), on a page it hosts inside the sign-in flow. Sitebin renders no consent screen of its own.
  • One subscription — the tier a user bought through the stack (PayGate, in front of whatever processor the stack uses) governs their Sitebin quotas. Sitebin never learns which processor that is.
  • One place to manage it — "Manage account" and "Manage subscription" on the Sitebin dashboard are links to the stack's account console and hosted plan page, not screens of Sitebin's own.
  • One erasure — a user who deletes their account or asks for their data at the stack has the order carried into Sitebin by two signed webhooks.
  • One suspension — an operator who suspends an abuser in the stack shuts them out of Sitebin too: every way in is refused and every site they own is locked, by a third signed webhook.

1 · Let Sitebin onboard itself

There is no app to create by hand. Give Sitebin the stack URL, an app id and the stack's platform admin key, and it announces itself to the Platform API on every start:

SITEBIN_STACK_URL=https://platform.saas-stack.example.com
SITEBIN_STACK_APP_ID=sitebin
SITEBIN_STACK_ADMIN_KEY_FILE=/run/secrets/stack_admin_key   # the stack's PLATFORM_ADMIN_KEY, from a docker secret
# (or SITEBIN_STACK_ADMIN_KEY=… — either way it is scrubbed from the process environment once read)

# this deployment's consent documents, asked at sign-in in this order after the platform's own
SITEBIN_STACK_CONSENTS='[{"key":"terms","version":"2026-09-29","url":"https://sitebin.io/terms/","title":{"en":"Sitebin Terms of Service"}},{"key":"dpa","version":"2026-09-29","url":"https://sitebin.io/dpa/","title":{"en":"Data Processing Agreement"}}]'

# the secret the stack signs its GDPR orders with — required whenever SITEBIN_STACK_URL is set
SITEBIN_STACK_GDPR_SECRET=…                 # at least 32 characters

The declaration is convergent — run it a thousand times and the stack simply matches what Sitebin declared — and it never blocks startup: an unreachable stack logs an error, Sitebin serves sites anyway, and the next start converges again. Configuration by deploying, not by clicking.

What it declares is only what Sitebin alone knows: its identity, the OIDC callback it will actually use (https://sitebin.example.com/account/auth/oidc/callback), the page a sign-out returns to (postLogoutRedirectUris: https://sitebin.example.com/account/signed-out), its tier catalogue from tiers.json, its consent documents, its GDPR endpoints, and — where MCP is served with an OAuth issuer — its MCP resource and scopes. It never declares identity providers, password policy, MFA or realm registration: those are realm-wide settings shared with every app on the stack, and an app that set them would overwrite an operator's choice on each restart. Of a tier it declares the amount, never a price identifier — the stack creates the product in whatever processor it uses, and a tier with no amount declares no payment product, which is how free plans (and plans whose pricing is not settled) announce themselves. Registration merges: a block that is absent leaves what the stack already holds untouched.

The admin key is the stack's master credential. Prefer SITEBIN_STACK_ADMIN_KEY_FILE — a docker secret or mounted file — so it never sits in the container's environment where docker inspect and every child process could read it.

2 · Point Sitebin at the stack

# SSO through the stack's Auth Gateway (generic OIDC)
# The ISSUER is the realm — it is what `iss` says. The DISCOVERY URL is the
# gateway, and it is what puts sign-in behind the stack's consent gate.
SITEBIN_OAUTH_OIDC_ISSUER=https://auth.example.com/realms/saas-stack
SITEBIN_OAUTH_OIDC_DISCOVERY_URL=https://auth-gw.saas-stack.example.com/api/v1/sitebin
SITEBIN_OAUTH_OIDC_CLIENT_ID=sitebin-app
SITEBIN_OAUTH_OIDC_CLIENT_SECRET=…
SITEBIN_OAUTH_OIDC_LABEL="Example SSO"      # login-button text

# (recommended) SSO only — local accounts would bypass PayGate billing
SITEBIN_LOCAL_AUTH=false

# Tiers from PayGate (requires SITEBIN_ACCOUNT_MODE=tiers)
SITEBIN_BILLING=paygate                     # which backend may charge customers
SITEBIN_PAYGATE_URL=https://paygate.saas-stack.example.com
SITEBIN_PAYGATE_APP_ID=sitebin
SITEBIN_PAYGATE_API_KEY=ssk_live_…
SITEBIN_PAYGATE_CACHE_TTL=5m                # per-user tier cache
# "Manage subscription" -> https://auth.example.com/apps/sitebin/plan — derived, nothing to set
The discovery URL matters more than it looks. The consent gate lives on the gateway's authorization endpoint, and the gateway's discovery document is the only thing that names it. An instance that discovers straight from the identity provider skips the gate entirely, and silently — sign-in works, nobody is ever asked anything. The document's own issuer must still equal SITEBIN_OAUTH_OIDC_ISSUER, so this cannot trust another realm by accident.

3 · Match the tier ids

PayGate decides which tier a user has; Sitebin's tiers.json decides what that tier means (sites, storage, files, domains, WebDAV). The convention is simply that stack tier ids equal tiers.json ids — a user with the stack subscription pro gets the tiers.json tier pro. There is no mapping table to maintain.

Consent: two documents, asked at sign-in

Consent is the stack's, not Sitebin's. The stack gates sign-in on the platform's own terms, once per user, and then on each document the app declares, once per app — on a page it hosts inside the sign-in flow, themed and localized. Sitebin's whole side of it is SITEBIN_STACK_CONSENTS: an ordered list of documents, each with a key, a version, a url and an optional title map by locale. There is no page to render, no endpoint to expose and no callback to implement.

  • key is the document's identity for ever; changing it declares a new document and asks everybody again.
  • version is opaque and immutable once recorded: raising it re-asks every user for that document — per document, so the DPA's version can move without touching the terms' — and re-declaring a recorded version with different content is refused.
  • required defaults to true on the stack; false makes a document that is shown and recorded but does not block (a marketing consent).
  • Absent = declare nothing, and the stack keeps whatever it already holds. An empty list is refused at startup: it would mean "this app asks for nothing", a real state an operator sets on the stack on purpose.

Two things have to be true for a user to actually see the documents: the variable is set, and SITEBIN_OAUTH_OIDC_DISCOVERY_URL points at the Auth Gateway, as above.

Self-service on the stack's pages

Once a user is signed in through the stack, "manage my account" and "manage my plan" are links, not screens — and both addresses are derived from the issuer the instance signs in against, exactly as the stack's own SDK builds them. There is nothing to configure, so nothing that can disagree with the stack.

  • Manage account on the dashboard is the stack's account console, <issuer>/account/?referrer=<client id>: password, sign-in methods, sessions and devices, second factors, data export and account deletion.
  • Manage subscription posts to /account/billing/portal, which with PayGate redirects to the stack's hosted plan page, <issuer origin>/apps/<app id>/plan: current plan, the plans it could move to, change, cancel, resume, invoices — themed as Sitebin and built by nobody here. A plan change is an order like a first purchase: Upgrade for a customer who already pays opens the stack's order page for exactly that change, with the new price, what is due today and an order button that names the obligation to pay, and the stack mails the confirmation.
  • Deleting a stack account happens at the console, not in Sitebin. The dashboard's danger zone sends a stack user there, and a POST /account/delete from a stack account redirects there too. The stack erases the identity and orders Sitebin to erase its half (next section). Local accounts, which the stack has never heard of, keep local deletion: a confirmation page of its own, and with a direct Stripe/Paddle backend the live subscription is cancelled first, with immediate effect — if it cannot be, the account is kept and the page says why.
  • Sign out ends the stack session too. Sitebin revokes every session of the account, then sends the browser through the stack's end_session_endpoint with client_id, the sign-in's ID token as id_token_hint and post_logout_redirect_uri=<base>/account/signed-out. The stack ends the single sign-on without asking and returns to "You are signed out", whose Sign in starts a fresh request (prompt=login) — so the stack's login page appears and a different account can be chosen. Without the hint (a session older than the week it is kept) the stack may ask "Do you want to log out?" first. Signing in from the stack's hub stays one click.
The return address is declared as the registration's auth.postLogoutRedirectUris, and the stack's registration schema is strict: an instance that declares it to a stack that does not know the field is refused registration (it logs the refusal and keeps serving). Run a stack that knows it before you upgrade Sitebin past it.

GDPR: the stack orders, Sitebin erases

A data subject asks the stack — in the account console, or an operator does it for them in the stack's user directory — and the stack calls every app the person belongs to. These, and the suspension below, are the only calls the stack ever makes into a Sitebin instance. Sitebin's side is two endpoints, mounted and declared as the registration's gdpr block when SITEBIN_STACK_GDPR_SECRET is set — and without the secret neither exists, because a URL the stack could call but nothing could verify would be an unauthenticated deletion endpoint.

EndpointWhat it does
POST /account/gdpr/exportEverything this instance holds about the user, as JSON: the account record (never a password hash), the metadata of every site it owns (id, URL, mode, custom domains, origin, size, files, created, expires), the metadata of every API token (never a secret — none is stored), what is held about sessions (nothing: they are signed cookies the browser keeps), and the account's provenance — its own log (sign-up, sign-ins, tokens minted, sites created and deleted) as provenance.account, and from each site it owns the entries this account made as provenance.sites. Entries made on its sites with an edit password are not exported: nothing says they were this person. An unknown user exports an empty document, not an error.
POST /account/gdpr/deleteRemoves the account, its ownership markers, its sites (with their provenance logs), its account provenance log, its API tokens and — by removing the record every cookie is validated against — its sessions. Idempotent: a user with no account here is a 200, because the stack reads every other status, 404 included, as "the app still holds the data" and never deletes the identity. A site that cannot be deleted stops the order with a 500 and keeps the account, so the stack keeps the identity and the operator retries; whatever was deleted before stays deleted and the retry steps over it. An account that owns a site the operator has locked is refused whole with a 409 naming the sites, and nothing is deleted: the lock is an evidence hold, released only by the operator deleting those sites.

Both take {"userId": "<stack user id>", "email": "…"} and are authenticated by nothing but the signature: X-Signature: sha256=<hex HMAC-SHA256 over "<X-Timestamp>.<body>"> with the shared secret, compared in constant time, the timestamp (Unix seconds) accepted within five minutes either way. No session, admin key or API token is accepted there, and a request that fails verification is refused before its body is parsed. The user id is the OIDC subject; the stack knows nothing about local accounts and never orders anything for them.

Suspension: the stack shuts an abuser out

The stack's Suspend user disables the identity, ends its sessions and revokes its offline tokens — but Sitebin's own session cookie, its API tokens and its MCP tokens do not ask the identity provider. So the stack also calls every app the user belongs to, and Sitebin takes the order through the same door as the GDPR ones:

EndpointWhat it does
POST /account/gdpr/suspendDeclared to the stack as gdpr.suspendUserUrl — https://<base domain>/account/gdpr/suspend — next to the delete and export URLs, and like them mounted and declared only when SITEBIN_STACK_GDPR_SECRET is set. Body {"userId", "email", "suspended": true|false, "reason"}, signed exactly like the GDPR orders above.
  • "suspended": true marks the account suspended (a repeated order keeps the first date), ends every browser session at once, and from then on refuses the account on every path: sign-in, the dashboard and the owner's edit page, account API tokens, and MCP OAuth tokens — checked after a token resolves, so no token hands the account back. Every site it owns is locked with the reason: served to nobody, frozen, kept past its expiry — up to the lock retention.
  • "suspended": false clears the suspension and unlocks only the sites the suspension locked. A site the operator locked by hand — or kept locked with Keep in the register — stays locked.
  • A user with no account on this instance is a 200, so the order is idempotent; a body without suspended is a 400, never a guess; a lock or unlock that fails is a 5xx, and because the account's own state is written first, the stack's retry converges.
  • The instance register badges the owner of such sites Suspended. A suspension does not cancel a paid subscription; that stays a billing decision in the stack.
The stack's registration schema is strict: an instance that declares suspendUserUrl to a stack that does not know the field is refused registration (it logs the refusal and keeps serving). Run a stack built with user suspension before you upgrade Sitebin past it.

How resolution behaves

  • Tiers resolve via PayGate only for accounts signed in through the generic OIDC provider — their OIDC subject is the stack user id. Local, Google and Microsoft accounts keep Sitebin's stored tier, and a local account has no subscription on the stack and never will — which is why SITEBIN_LOCAL_AUTH=false is the recommendation on a stack instance.
  • Lookups use PayGate's admin-by-user-id endpoint with the app API key — Sitebin never stores user JWTs.
  • active, trialing and past_due subscriptions are honored; anything else (and unknown tier ids) falls back to the account's stored tier, then SITEBIN_DEFAULT_TIER.
  • Results are cached per user (SITEBIN_PAYGATE_CACHE_TTL, default 5 minutes; failures 30 seconds) and fail open: a PayGate outage never blocks publishing.
  • Exactly one billing backend runs. PayGate and the built-in Stripe/Paddle checkout are alternatives, not layers: SITEBIN_BILLING names the one that may charge customers (paygate, stripe or paddle). With only one configured it is inferred and you can leave the variable out; configure two and start without it and Sitebin refuses to start rather than guess which processor bills your users.
  • With PayGate as the backend, SITEBIN_TIER_SELF_SELECT is bypassed for SSO accounts — the stack owns their subscription.
Anonymous drops are untouched by all of this: no-login publishing remains exactly as documented, stack or no stack.