auth.tenantId. No external “teams” service to bolt on.
Apps customize the org / member / invite schema. As of v0.3.74, the framework’s /api/auth/orgs/* surface reads + writes through manifest-declared entities (Org, OrgMember, OrgInvite by default — names configurable). Add logo, industry, billingEmail, plan, anything you want — the framework reads the required fields it needs and leaves your custom fields alone.
Declaring the entities
Add three entities to your schema. Required fields per entity are listed; apps add any others freely.Renaming the entities
If your codebase usesOrganization instead of Org (or you have a legacy schema), point the framework at your names via the manifest:
Disabling the framework’s org surface
Apps that implement org management entirely in their own TypeScript (like older pylon-cloud builds) can opt out of/api/auth/orgs/*:
disabled: true, the routes return 501 ORG_NOT_CONFIGURED and the framework’s OrgStore is a no-op. Use this when you want full control of the schema + flow.
Roles
Multiple owners are allowed — the convention is to promote a successor before stepping down.
Endpoints
All under/api/auth/. Org management requires a session. API-key auth is refused with 403 API_KEY_AUTH_FORBIDDEN — a leaked pk.* key can’t create orgs or change member roles.
Non-member callers get
404 ORG_NOT_FOUND on /orgs/:id/* — by design, so probing can’t enumerate org ids.
Creating an org
Owner. created_by is set to the caller’s user id and is immutable.
Listing user’s orgs
Inviting a teammate
EMAIL_PROVIDER (or ctx.email). The plaintext token is only returned in the response in dev mode; in production the inviter sees the invite in their dashboard and the invitee gets the email.
Invites are:
- Argon2-hashed at rest — a DB read can’t extract active invite links.
- Single-use —
accepted_atis CAS-stamped before the membership is created so two parallel accepts can’t both succeed. - Email-bound — the accepting user’s account email must match the invite’s
email, lowercase compare. Wrong account signed in →400 WRONG_EMAIL. - 7-day TTL —
created_at + 7 * 24 * 60 * 60. Expired invites return400 INVITE_EXPIREDon accept.
Accepting an invite
The invitee signs into Pylon (any method — magic code, password, OAuth), then:400 except 401 for unauthenticated):
The original invite row is kept (not deleted) with
accepted_at stamped — preserves the audit trail.
Managing roles
- Only Owners can promote to Owner. Admins can’t self-promote.
400 BAD_ROLEfor unknown roles,403 FORBIDDENfor “only owners can promote a member to owner”. - The last Owner cannot be demoted. Demote attempt that would leave 0 owners returns
400 LAST_OWNER. Promote someone else to Owner first, then demote. - The last Owner cannot be removed. Same
400 LAST_OWNERonDELETE /members/:user_id.
Active tenant — auth.tenantId
A session can have an active org. The active tenant is what policies, change-event filters, and ctx.auth.tenantId see:
org_a1b2, it returns 403 NOT_A_MEMBER. Clients can’t impersonate an org they don’t belong to.
Pass null to leave the org (drop back to the “no active tenant” state):
tenantId flows through:
- Policies —
data.orgId == auth.tenantIdrow-scopes reads + writes. - TenantScopePlugin — auto-stamps
tenantIdon insert; rejects non-admin cross-tenant inserts atbefore_insert. ctx.auth.tenantIdin TypeScript functions.- WS / SSE change-event broadcasts — per-client filtered by
policy.check_entity_read(entity, &client.auth, &row)so subscribers only see events for rows they can read.
Tenant scoping in your schema
The convention is to add atenantId field on org-scoped entities and let TenantScopePlugin handle stamping:
ctx.db.insert("Document", { tenantId: "other-org", ... }) gets 403 CROSS_TENANT_INSERT from the plugin before the row hits the DB.
Per-org SSO
Each org can have its own SSO IdP. See SSO for OIDC + SAML configuration.Security guarantees
- Membership-check on every
/orgs/:id/*route — non-members see404 ORG_NOT_FOUNDregardless of role. - API-key auth refused for all org-management routes. Real session required.
- Invites are Argon2-hashed at rest with single-use CAS on accept.
- Email-bound invites — accepting user’s email must match the invite.
- Last-Owner protection — demote/remove that would orphan the org is rejected.
- Object-level auth on
DELETE /orgs/:id/invites/:invite_id— the URL’sorg_idis matched against the invite row before revoke, so an admin of org A can’t revoke an invite from org B even if they know the id.