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
| Role | Manage members | Delete org | Transfer ownership |
|---|---|---|---|
| Owner | Yes | Yes | Yes |
| Admin | Yes | No | No |
| Member | No | No | No |
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.
| Endpoint | Method | Role | Purpose |
|---|---|---|---|
/orgs | POST | any session | Create an org; caller becomes Owner |
/orgs | GET | any session | List orgs the caller belongs to |
/orgs/:id | GET | any member | Org details + caller’s role |
/orgs/:id | DELETE | Owner | Delete the org |
/orgs/:id/members | GET | any member | List members |
/orgs/:id/members/:user_id | PUT | Owner/Admin | Change role |
/orgs/:id/members/:user_id | DELETE | Owner/Admin (or self) | Remove member |
/orgs/:id/invites | POST | Owner/Admin | Send email invite |
/orgs/:id/invites | GET | Owner/Admin | List pending invites |
/orgs/:id/invites/:invite_id | DELETE | Owner/Admin | Revoke pending invite |
/invites/:token/accept | POST | invited user | Accept an invite |
/select-org | POST | any session | Switch the session’s active tenant |
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):
| Code | Meaning |
|---|---|
INVITE_NOT_FOUND | Token doesn’t match any invite |
INVITE_EXPIRED | TTL passed |
ALREADY_ACCEPTED | Token was already burned |
WRONG_EMAIL | Signed-in account’s email doesn’t match the invite |
ALREADY_MEMBER | Caller is already a member of the org |
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.| Endpoint | Method | Role | Purpose |
|---|---|---|---|
/orgs/:id/sso | GET | any member | Read redacted OIDC SSO config |
/orgs/:id/sso | PUT | Owner | Configure OIDC SSO |
/orgs/:id/sso | DELETE | Owner | Remove SSO config |
/orgs/:id/saml | GET | any member | Read SAML config |
/orgs/:id/saml | PUT | Owner | Configure SAML |
/orgs/:id/saml | DELETE | Owner | Remove SAML config |
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.