Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt

Use this file to discover all available pages before exploring further.

Pylon ships a complete org / workspace / team layer in the binary. Users create orgs, invite teammates by email, change member roles, select an active tenant per session, and policies see the active tenant as 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.
import { entity, field } from "@pylonsync/sdk";

// REQUIRED fields: id (auto), name, createdBy, createdAt
export const Org = entity("Org", {
  name: field.string(),
  createdBy: field.id("User"),
  createdAt: field.string(),  // ISO 8601

  // Custom fields — your call:
  logo: field.string().optional(),
  industry: field.string().optional(),
  billingEmail: field.string().optional(),
  plan: field.string().optional(),  // "free" | "pro" | "enterprise"
});

// REQUIRED fields: id (auto), orgId, userId, role, joinedAt
export const OrgMember = entity("OrgMember", {
  orgId: field.id("Org"),
  userId: field.id("User"),
  role: field.string(),    // "owner" | "admin" | "member"
  joinedAt: field.string(),

  // Custom fields:
  title: field.string().optional(),
  department: field.string().optional(),
});

// REQUIRED fields: id (auto), orgId, email, role, invitedBy,
// tokenHash, tokenPrefix, createdAt, expiresAt, acceptedAt (optional)
export const OrgInvite = entity("OrgInvite", {
  orgId: field.id("Org"),
  email: field.string(),
  role: field.string(),
  invitedBy: field.id("User"),
  tokenHash: field.string(),
  tokenPrefix: field.string(),
  createdAt: field.string(),
  expiresAt: field.string(),
  acceptedAt: field.string().optional(),
});

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:
import { app } from "@pylonsync/sdk";

export default app({
  // ...
  auth: {
    org: {
      entity: "Organization",
      member_entity: "Membership",
      invite_entity: "Invite",
    },
  },
});

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/*:
auth: {
  org: { disabled: true },
}
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 + flow.

Roles

RoleManage membersDelete orgTransfer ownership
OwnerYesYesYes
AdminYesNoNo
MemberNoNoNo
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.
EndpointMethodRolePurpose
/orgsPOSTany sessionCreate an org; caller becomes Owner
/orgsGETany sessionList orgs the caller belongs to
/orgs/:idGETany memberOrg details + caller’s role
/orgs/:idDELETEOwnerDelete the org
/orgs/:id/membersGETany memberList members
/orgs/:id/members/:user_idPUTOwner/AdminChange role
/orgs/:id/members/:user_idDELETEOwner/Admin (or self)Remove member
/orgs/:id/invitesPOSTOwner/AdminSend email invite
/orgs/:id/invitesGETOwner/AdminList pending invites
/orgs/:id/invites/:invite_idDELETEOwner/AdminRevoke pending invite
/invites/:token/acceptPOSTinvited userAccept an invite
/select-orgPOSTany sessionSwitch the session’s active tenant
Non-member callers get 404 ORG_NOT_FOUND on /orgs/:id/* — by design, so probing can’t enumerate org ids.

Creating an org

curl -X POST https://your-app/api/auth/orgs \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"name": "Acme Corp"}'
Response:
{
  "id": "org_a1b2c3...",
  "name": "Acme Corp",
  "created_at": 1735000000,
  "role": "owner"
}
The caller is auto-added as Owner. created_by is set to the caller’s user id and is immutable.

Listing user’s orgs

curl https://your-app/api/auth/orgs \
  -H 'Authorization: Bearer pylon_token'
Response:
[
  { "id": "org_a1b2", "name": "Acme Corp",  "role": "owner",  "created_at": 1735000000 },
  { "id": "org_c3d4", "name": "Side Hustle","role": "admin",  "created_at": 1735100000 }
]
A user can belong to as many orgs as they like; orgs are not exclusive.

Inviting a teammate

curl -X POST https://your-app/api/auth/orgs/org_a1b2/invites \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"email": "alice@acme.com", "role": "member"}'
Response:
{
  "id": "inv_xyz",
  "email": "alice@acme.com",
  "role": "member",
  "expires_at": 1735604800,
  "accept_url": "https://your-app/api/auth/invites/<token>/accept",
  "token": "<plaintext token, dev mode only>"
}
Pylon sends the email automatically using the configured 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-useaccepted_at is 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 TTLcreated_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, OAuth), then:
curl -X POST https://your-app/api/auth/invites/<token>/accept \
  -H 'Authorization: Bearer pylon_token'
Response:
{ "org_id": "org_a1b2", "role": "member" }
Error cases (all 400 except 401 for unauthenticated):
CodeMeaning
INVITE_NOT_FOUNDToken doesn’t match any invite
INVITE_EXPIREDTTL passed
ALREADY_ACCEPTEDToken was already burned
WRONG_EMAILSigned-in account’s email doesn’t match the invite
ALREADY_MEMBERCaller is already a member of the org
The original invite row is kept (not deleted) with accepted_at stamped — preserves the audit trail.

Managing roles

# Promote a member to admin
curl -X PUT https://your-app/api/auth/orgs/org_a1b2/members/usr_alice \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"role": "admin"}'
Guardrails enforced server-side:
  • Only Owners can promote to Owner. Admins can’t self-promote. 400 BAD_ROLE for unknown roles, 403 FORBIDDEN for “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_OWNER on DELETE /members/:user_id.
Self-removal is allowed for any role except a last-Owner without 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:
curl -X POST https://your-app/api/auth/select-org \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"orgId": "org_a1b2"}'
The server verifies membership before committing — if the caller isn’t a member of 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):
curl -X POST https://your-app/api/auth/select-org \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"orgId": null}'
tenantId flows through:
  • Policiesdata.orgId == auth.tenantId row-scopes reads + writes.
  • TenantScopePlugin — auto-stamps tenantId on insert; rejects non-admin cross-tenant inserts at before_insert.
  • ctx.auth.tenantId in 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.
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:
import { entity, field } from "@pylonsync/sdk";

export const Document = entity("Document", {
  title: field.string(),
  body:  field.string(),
  tenantId: field.id("Org"),  // auto-stamped from auth.tenantId on insert
});

export const documentPolicy = policy({
  entity: "Document",
  allowRead:   "auth.tenantId == data.tenantId",
  allowInsert: "auth.tenantId == data.tenantId",
  allowUpdate: "auth.tenantId == data.tenantId",
  allowDelete: "auth.tenantId == data.tenantId",
});
A non-admin caller who tries 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.
EndpointMethodRolePurpose
/orgs/:id/ssoGETany memberRead redacted OIDC SSO config
/orgs/:id/ssoPUTOwnerConfigure OIDC SSO
/orgs/:id/ssoDELETEOwnerRemove SSO config
/orgs/:id/samlGETany memberRead SAML config
/orgs/:id/samlPUTOwnerConfigure SAML
/orgs/:id/samlDELETEOwnerRemove SAML config

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. 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’s org_id is 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.

Where to go next

  • Sessionsselect-org, multi-tenant session state
  • RBAC — policies that read auth.tenantId and auth.hasRole(...)
  • SSO — per-org OIDC + SAML