Skip to main content
Pylon ships a complete org, workspace, and team layer in the binary. Users create orgs, invite teammates by email, and change member roles. Users select an active tenant per session, and policies read the active tenant as 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 uses Organization 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/*:
With 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:
The equivalent helper form is 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

Response:
Pylon adds the caller as Owner automatically. created_by is set to the caller’s user id and cannot change.

Listing user’s orgs

Response:
A user can belong to any number of orgs. Orgs are not exclusive.

Inviting a teammate

Response:
Pylon sends the email automatically using the configured 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_at is 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 returns 400 WRONG_EMAIL.
  • 7-day TTL: created_at + 7 * 24 * 60 * 60. Expired invites return 400 INVITE_EXPIRED on accept.

Accepting an invite

The invitee signs into Pylon (any method: magic code, password, or OAuth), then makes this request:
Response:
Error cases (all 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

Guardrails enforced server-side:
  • Only Owners can promote to Owner. Admins cannot self-promote. 400 BAD_ROLE lists the built-in and manifest-declared roles when a role is unknown. 403 FORBIDDEN is 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_id returns the same 400 LAST_OWNER error.
Any member can remove themselves, except a last Owner with no successor.

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:
The server verifies membership before committing. If the caller is not a member of 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.tenantId row-scopes reads and writes.
  • TenantScopePlugin: stamps tenantId automatically on insert, and rejects non-admin cross-tenant inserts at before_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.
See RBAC for the policy-DSL side.

Tenant scoping in your schema

The convention is to add a tenantId field on org-scoped entities and let TenantScopePlugin handle stamping:
A non-admin caller who tries 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 see 404 ORG_NOT_FOUND regardless 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’s org_id against the invite row before revoke, so an admin of org A cannot revoke an invite from org B, even with the id.

Where to go next

  • Sessions: select-org, multi-tenant session state
  • RBAC: policies that read auth.tenantId and auth.hasRole(...)
  • SSO: per-org OIDC and SAML