auth.tenantId. You do not need an external teams service.
Apps can customize the org, member, and invite schema. As of v0.3.74, the framework’s /api/auth/orgs/* surface reads and writes through manifest-declared entities (Org, OrgMember, OrgInvite by default; names are configurable). Add logo, industry, billingEmail, plan, or any field you want. The framework reads only the fields it needs and leaves your custom fields alone.
Declaring the entities
Add three entities to your schema. Required fields per entity are listed below. Add any other fields you want.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 and flow.
Roles
Multiple owners are allowed. The convention is to promote a successor before stepping down.
Apps can declare additional least-privilege roles directly in
buildManifest:
auth: auth({ orgRoles: [...] }). Role slugs must match [a-z][a-z0-9_-]{0,63}. Built-in roles are always available. Do not redeclare them.
Custom roles are exact-match labels. They do not inherit member, admin, or any other permission. The framework still reserves member management for owners or admins. Grant application permissions explicitly with auth.hasRole("reviewer") in policies or ctx.requireMember(orgId, { role: ["reviewer"] }) in functions.
Endpoints
All endpoints are under/api/auth/. Org management requires a session. API-key auth is refused with 403 API_KEY_AUTH_FORBIDDEN. A leaked pk.* key cannot create orgs or change member roles.
Non-member callers get
404 ORG_NOT_FOUND on /orgs/:id/*. This is by design, so probing cannot enumerate org ids.
Creating an org
Owner automatically. created_by is set to the caller’s user id and cannot change.
Listing user’s orgs
Inviting a teammate
EMAIL_PROVIDER (or ctx.email). The plaintext token appears in the response only 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 database read cannot extract active invite links.
- Single-use:
accepted_atis CAS-stamped before the membership is created, so two parallel accepts cannot both succeed. - Email-bound: the accepting user’s account email must match the invite’s
email(case-insensitive). Signing in with the wrong account returns400 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, or OAuth), then makes this request:400 except 401 for unauthenticated):
Pylon keeps the original invite row (it does not delete it) and stamps
accepted_at. This preserves the audit trail.
Managing roles
- Only Owners can promote to Owner. Admins cannot self-promote.
400 BAD_ROLElists the built-in and manifest-declared roles when a role is unknown.403 FORBIDDENis returned when a non-owner tries to promote a member to owner. - The last Owner cannot be demoted. A demotion that would leave zero owners returns
400 LAST_OWNER. Promote someone else to Owner first, then demote. - The last Owner cannot be removed.
DELETE /members/:user_idreturns the same400 LAST_OWNERerror.
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. The caller’s exact membership role in that org appears consistently in /api/auth/me, SSR PageAuth.roles, policy auth.hasRole(...), and function ctx.auth.roles:
org_a1b2, it returns 403 NOT_A_MEMBER. Clients cannot impersonate an org they do not 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 and writes. - TenantScopePlugin: stamps
tenantIdautomatically on insert, and rejects non-admin cross-tenant inserts atbefore_insert. ctx.auth.tenantId: available in TypeScript functions.- WS and SSE change-event broadcasts: filtered per client by
policy.check_entity_read(entity, &client.auth, &row), so subscribers see only 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 reaches the database.
Per-org SSO
Each org can have its own SSO IdP. See SSO for OIDC and 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. Pylon requires a real session.
- Invites are Argon2-hashed at rest with single-use CAS on accept.
- Email-bound invites: the accepting user’s email must match the invite.
- Last-Owner protection: Pylon rejects a demotion or removal that would orphan the org.
- Object-level auth on
DELETE /orgs/:id/invites/:invite_id: Pylon matches the URL’sorg_idagainst the invite row before revoke, so an admin of org A cannot revoke an invite from org B, even with the id.