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
| Role | Manage members | Delete org | Transfer ownership |
|---|
| Owner | Yes | Yes | Yes |
| Admin | Yes | No | No |
| Member | No | No | No |
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.
| 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 |
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-use —
accepted_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 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, 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):
| 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 |
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:
- Policies —
data.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.
| 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 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
- Sessions —
select-org, multi-tenant session state
- RBAC — policies that read
auth.tenantId and auth.hasRole(...)
- SSO — per-org OIDC + SAML