hraness

one identity, many products: shared identity, per-app entitlement

product-local sessions stay product-local

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

A suite of products wants one sign-in and one profile, but it does not want one product’s bug to become every product’s breach. The temptation is a shared session cookie or a shared users table that every app reads. The failure mode is that the products stop being products: a permission in one is suddenly a permission in all, and the seam between them is wherever the last developer decided to put it.

The Hraness answer is a sharper split: one account authority owns identity and entitlements; each product owns its own sessions, data, roles, and permissions. The authority proves who the user is. The product decides what that means inside its own boundary, and the two never have to agree on anything except the account ID. The seam is a signed, audience-bound token, not a shared cookie, not a shared database, and never the browser. The whole design is the line between those two halves.

the authority line

Accounts is the sole authority for exactly four things: the user record, OAuth client registration, identity links, and entitlements. Concretely that is one Better Auth user table, one opaque suite account identity, the suite Stripe Customers, and the catalog-derived entitlements. Everything else (a product’s own permissions, its data, its roles, its historical commerce) stays in the product. That is the whole split, and it is narrower than it looks. Accounts does not know what a post is, what a forum is, or what a credit buys. It knows who the account is and what the catalog says they are entitled to.

The protocol surface is standard OIDC (authorization code with S256 PKCE, exact state and nonce, issuer and audience checks, rotating refresh tokens) but the trust surface is closed. Issuer, JWKS, resource, protocol version, wire version, client-ID format, and signature algorithms are all derived from package data in @hraness/suite-accounts, never from caller input or runtime discovery. The issuer is https://account.hraness.com; the shared resource is https://hraness.com/suite. A product cannot decide it trusts a different issuer because the factory will not produce a configuration for one.

The authority is narrow in the other direction too. Sign-in is email OTP only: there are no password routes to phish and no social-login redirects to hijack, and the disabled routes return 404 rather than silently existing. A user’s handle is a generated adjective-and-noun like quiet-willow, not an email or a provider subject, so the public profile does not leak the identity the sign-in is bound to. The products never see that sign-in at all; they see only what Accounts decides to sign.

the product binding

A product does not register itself. It presents the exact public fields that were assigned to it, and createSuiteAccountsClientConfiguration returns a frozen configuration or an error. The Oompa binding is the documented example:

const configuration = createSuiteAccountsClientConfiguration({
  authMode: "oidc-rp",
  callbackUrl: "https://oompa.app/api/suite-auth/callback",
  clientId: "hraness:hra:production:v1",
  consumer: "hra",
  environment: "production",
  origin: "https://oompa.app",
});

The checked result carries the issuer and resource from closed data (provider.issuer is https://account.hraness.com, provider.resource is https://hraness.com/suite) and any field that disagrees with the registered client fails before the product starts a flow. A misspelled consumer, an unregistered origin, a retired client ID, or a caller-supplied trust value is a rejected binding, not a warning. The consequence that matters operationally: generated Vercel Preview origins cannot do normal Suite authentication, because the registered origin is exact and a *.vercel.app URL is not it.

sessions stay local

The browser never holds an OAuth access token. Accounts keeps bearer custody on the server: the product’s server-side client holds the token and exchanges it, and the browser receives a bounded session (cookies or an equivalent) that is scoped to the product and does not contain the OAuth material. That is why “product-local sessions stay product-local” is the dek rather than a slogan: the session a product issues is its own artifact, bound to its own origin and its own lifetime, and a sign-out or revocation in one product does not reach sideways into another.

The token that crosses between Accounts and a product is short-lived and audience-bound. A downstream product token is minted for that product only, with issuer, audience, client, token-use, and account checks run on every protected call. The product re-derives identity from the verified token inside the function that crosses its public boundary, never from a route username, a submitted handle, an email, or a browser-supplied claim. Elders, for example, mints a five-minute child token from a live Accounts session, and its bridge refuses to mint another once the central session is revoked: revocation propagates because the check is live, not because the cookie expired. Sup applies the same rule to ownership: suite_account_id is the sole ownership key, and the canonical username is parsed from the verified access token inside the function that needs it: a route param is a locator for the page, never a claim about who is asking.

This is also what makes adding a product cheap without making it dangerous. A new product gets a registered client and a checked binding; it does not get a copy of the accounts table, a shared session store, or a say in which issuer it trusts. The cost of joining the suite is conformance to the boundary, not custody of the authority.

entitlements are receipts

An entitlement is a cached authorization claim, not a billing record. When Accounts says a product may treat an account as subscribed, that claim is minted from a recent canonical Stripe observation (the provider’s current view of the subscription) and it expires no later than the paid period end. A positive entitlement requires that recent observation; an entitlement without one is not honored, and a receipt older than the freshness bound is not reissued as fresh.

This is the difference between “Accounts says you’re a subscriber” and “Stripe’s current state says you’re a subscriber and Accounts recorded it.” The receipt is the product-facing artifact; the Stripe observation is the authority behind it. Products consume the receipt (suite_account_id is the ownership key) and never see the OAuth tokens, provider secrets, or webhook evidence that produced it. That is also why an entitlement cannot be borrowed across products: the receipt names the account and the entitlement it grants inside this product’s boundary; it is not a claim about what another product should do.

The migration case is where the design earns its name. A product with an existing user base (Oompa, PeopleBlade) does not import users by email, because email equality is not identity proof. An identity link is created only when the product proves an exact issuer-plus-subject pair it controls, and Accounts binds that external subject to a Suite account as an alias. The product’s historical owner IDs remain as immutable aliases so old receipts and records still resolve, but they cannot create new authority. A predecessor product code is a bounded alias for the current product ID, not a second principal.

That rule is what keeps the system honest under drift. “The user signed in with this email” is a hint; “the product produced a signed proof for this exact issuer and subject” is a link. The former is how accounts get merged by accident; the latter is how they get linked on purpose. The same posture runs through the support surface: a handoff from a product carries only declared product IDs and closed source values, and a query parameter is never allowed to become a return URL or a payment instruction. An email field is prefilled only after the verified Accounts session matches the expected address exactly: a prefill is not consent, and it is never treated as proof of which account should receive support.

The pattern that survives is “one authority, many products, narrow claims.” Accounts is the only place that knows who someone is; each product is the only place that knows what they may do; the token between them is the narrowest claim that can carry the answer: an audience-bound assertion about a suite_account_id, not a blanket statement about the user. When the next product joins the suite it does not get a copy of the users table; it gets a registered client and a binding it cannot misconfigure.

sources

  • suite-accounts: createSuiteAccountsClientConfiguration, the closed issuer/resource/registry, and the product-binding contract in src/client-configuration.ts.
  • Accounts (account.hraness.com): the authority for account records, client registration, identity links, and entitlements.
  • The monorepo behind this site (projects): the Sup and Elders consumers that keep product-local sessions and derive ownership from suite_account_id.