# Account deletion Source: https://docs.pylonsync.com/auth/account Delete an account and revoke its sessions, API keys, OAuth links, and trusted devices in one call. `DELETE /api/auth/account` deletes a user account. It clears the user's auth state in every system Pylon owns: sessions, API keys, linked OAuth accounts, trusted-device records, and the User row. Pylon does this in one transaction. App-owned tables that reference the user are not deleted (see below). The host schema decides what gets purged. ## Endpoint | Endpoint | Method | Auth | Purpose | | ------------------- | ------ | ------- | -------------------------------- | | `/api/auth/account` | DELETE | session | Hard-delete the caller's account | ## What gets wiped Pylon performs the following in order: 1. **Revoke all sessions** for the user. Pylon revokes the caller's current session first, so a slow user-row delete cannot leave a usable session. 2. **Revoke all API keys** owned by the user (`pk.*` bearer tokens). 3. **Unlink all OAuth accounts** — Google, GitHub, Apple, etc. credentials that were linked to this user. 4. **Revoke all trusted devices** — the `pylon_trusted_device` records for this user. 5. **Delete the User row** itself from the entity backing the auth user. 6. **Clear the session cookie** on the response so the browser drops it. 7. **Audit log** an `AccountDelete` event with counts of each category. ```bash theme={null} curl -X DELETE https://your-app/api/auth/account \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "deleted": true, "revoked_sessions": 3, "revoked_api_keys": 2, "unlinked_accounts": 1 } ``` Errors: | Status | Code | Reason | | ------ | ------------------------ | ------------------------------------------------------------- | | 401 | `AUTH_REQUIRED` | No session | | 403 | `API_KEY_AUTH_FORBIDDEN` | API-key auth can't delete the account — real session required | | 400 | (storage error code) | The User row delete failed (storage layer rejection) | ## App-owned tables don't cascade Pylon does not delete your app's tables. If the user has 47 Project rows that point at their `user_id`, those rows survive the delete. Your app declares its own deletion rules. The canonical pattern is a plugin hook that fires before the user row is wiped: ```typescript theme={null} // crates/plugin/src/builtin/before_delete_user.ts (sketch) import { plugin } from "@pylonsync/sdk"; export default plugin({ name: "delete-user-data", on: { delete: { entity: "User" } }, async beforeDelete(ctx, { id: userId }) { // Purge app tables that point at this user. const projects = await ctx.db.query("Project", { ownerId: userId }); for (const p of projects) await ctx.db.delete("Project", p.id); // ...etc }, }); ``` You can also purge inside a mutation wrapper, or with a scheduled cleanup job after the delete. The framework leaves the deletion rule to your app. Some apps tombstone the row, some hard-delete it, and some anonymize the user\_id and keep the history. ## Confirming first Do not expose this endpoint without a confirmation step in your UI. The API does not require a password or TOTP re-prompt. Design the frontend flow to confirm the action: ```typescript theme={null} // Dashboard flow: // 1. User clicks "Delete account" // 2. Modal asks them to type their email to confirm // 3. Frontend calls /password/login to verify the password (or /totp/verify for 2FA) // 4. Only then calls /api/auth/account DELETE ``` For sensitive apps, gate the call on a fresh TOTP code or a session re-issued within the last 5 minutes. ## Audit trail Pylon writes the `AccountDelete` audit event before it deletes the user row, so the event row survives. Read it via [`/api/auth/audit`](/auth/overview#endpoints). Operators get a record of which users deleted their account and when, with counts of what was cleared. ## Where to go next * **[Sessions](/auth/sessions)** — revoke a single session without deleting the account * **[API keys](/auth/api-keys)** — revoke a single key without deleting the account * **[GDPR export](/operations/incident)** — `/api/admin/users/:id/export` companion endpoint for data portability # API keys Source: https://docs.pylonsync.com/auth/api-keys Create, use, expire, and revoke long-lived keys for server-to-server calls. Use short-lived, per-device sessions for users. Use long-lived, independently revocable API keys for server-to-server calls such as Stripe webhooks, external cron jobs, and CI scripts. ## Availability API keys are always available. The framework owns a dedicated key store, so there is no plugin or manifest entity to declare. The store persists to the `PYLON_SESSION_DB` SQLite file, or stays in memory when that variable is unset. Management endpoints are mounted under `/api/auth/api-keys`. What you get: * `/api/auth/api-keys` endpoints to create, list, and revoke keys * Bearer-token resolution: a request with `Authorization: Bearer pk.<...>` resolves to the key's owner instead of looking up a session ## What's stored The key store keeps an internal record per key (not a manifest entity): | Field | Notes | | -------------- | ---------------------------------------------------------------------- | | `id` | `key_<24 chars>` — used to revoke a key | | `user_id` | Owner of the key | | `name` | Human label | | `prefix` | First 16 chars of the plaintext (`pk.key_<8 id chars>`) — safe to show | | `secret_hash` | HMAC-SHA256 of the secret (see [Security](#security)) | | `scopes` | Optional, opaque comma-separated string (your app defines the meaning) | | `expires_at` | Optional unix timestamp; unset = no expiry | | `last_used_at` | Refreshed on every successful auth | | `created_at` | Unix timestamp | Lists show only the `prefix`. The full key appears once at creation time. ## Create a key ```bash theme={null} curl -X POST https://your-app/api/auth/api-keys \ -H 'Authorization: Bearer pylon_' \ -H 'Content-Type: application/json' \ -d '{ "name": "Stripe webhook handler", "scopes": "webhooks,payments:write", "expires_at": null }' ``` `scopes` is an optional free-form string. `expires_at` is an optional unix timestamp (seconds). API-key auth cannot create keys; a real session is required (`403 API_KEY_AUTH_FORBIDDEN` otherwise). A leaked key cannot create more keys. Response (200): ```json theme={null} { "key": "pk.key_a1b2c3d4....secret", "id": "key_a1b2c3d4...", "prefix": "pk.key_a1b2c3d4e", "name": "Stripe webhook handler", "scopes": "webhooks,payments:write", "expires_at": null, "created_at": 1735000000 } ``` The `key` field is shown once. Copy it now, or revoke and re-create the key. The server stores only the hash. ## Use a key Use it like a session, with `Authorization: Bearer `: ```bash theme={null} curl -X POST https://your-app/api/fn/processStripeEvent \ -H 'Authorization: Bearer pk.key_a1b2c3d4....secret' \ -H 'Content-Type: application/json' \ -d '{ "event": {...} }' ``` The runtime resolves the key with a constant-time hash comparison. It produces an `AuthContext` with the key owner's `userId`. Pylon does not interpret or enforce the key's `scopes` (see below). ## Scopes Scopes are an opaque, application-defined label. Pylon stores the string you pass and shows it back when you list keys. Pylon does not parse or enforce it. There is no built-in scope vocabulary and no automatic read/write gating. Pylon does not pass the scope string to your function or policy code. Treat it as metadata for a person who audits the key list, not as an access-control mechanism. Two things constrain what a key can do: * **It resolves to the key's owner `userId`.** A key can do whatever that user can do, subject to your entity policies. * **It does not load the owner's roles.** `auth.hasRole(...)` is `false` for key-authenticated requests, so a key can't exercise role-gated policies its owner otherwise could. For finer per-key restriction, create the key under a service user with few privileges, and check `expires_at`. Do not rely on the `scopes` string to enforce anything. ## List keys ```bash theme={null} curl https://your-app/api/auth/api-keys \ -H 'Authorization: Bearer pylon_' ``` Response: ```json theme={null} [ { "id": "key_a1b2c3d4...", "name": "Stripe webhook handler", "prefix": "pk.key_a1b2c3d4e", "scopes": "webhooks,payments:write", "expires_at": null, "last_used_at": 1735003920, "created_at": 1735000000 } ] ``` Pylon does not return the full key. The response has only the prefix and metadata. ## Rotate There is no dedicated rotate endpoint. To rotate, create a new key and revoke the old one: ```bash theme={null} # 1. Mint the replacement curl -X POST https://your-app/api/auth/api-keys \ -H 'Authorization: Bearer pylon_' \ -d '{ "name": "Stripe webhook handler", "scopes": "webhooks,payments:write" }' # 2. Roll the new key out to your downstream system, then revoke the old one curl -X DELETE https://your-app/api/auth/api-keys/key_old... \ -H 'Authorization: Bearer pylon_' ``` The old key stops working the instant it's revoked. ## Revoke ```bash theme={null} curl -X DELETE https://your-app/api/auth/api-keys/key_a1b2c3d4... \ -H 'Authorization: Bearer pylon_' ``` Pylon checks ownership first. You can only revoke your own keys (`404 NOT_FOUND` otherwise). The response is `{"revoked": true}`. Any later request with that key gets `401 INVALID_API_KEY`. ## Expiry Set `expires_at` (unix seconds) to make a key auto-expire: ```bash theme={null} curl -X POST https://your-app/api/auth/api-keys \ -H 'Authorization: Bearer pylon_' \ -d '{ "name": "CI deploy key", "scopes": "deploy", "expires_at": 1798761599 }' ``` After `expires_at`, Pylon rejects the key as `401 INVALID_API_KEY`. The `last_used_at` field updates on every successful request. List your keys and compare `last_used_at` to the current time to find dormant keys you can revoke. ## Security * **Keys are CSPRNG-generated** — a 256-bit random secret, wire format `pk.key_.`. * **Hashed with HMAC-SHA256** server-side. Unlike passwords (Argon2id), API-key secrets are full-entropy random, so a fast keyed hash is safe here and adds no per-request latency. A slow KDF adds no benefit. * **Constant-time comparison** — no timing leak on key resolution. * **Shown once** — the plaintext key never touches the database; only the hash is stored. * **Owner-scoped management** — creating, listing, and revoking keys all require a real session, never an API key. ## Common patterns ### Per-integration keys One key per third-party integration. Stripe, SendGrid, your CI pipeline, and your monitoring agent each get a key scoped to the functions it calls. ### Per-environment keys CI key for staging, separate key for prod. Rotate independently. If a CI build leaks the staging key, prod isn't affected. ### Time-limited keys for handoffs A consultant needs access for two weeks? Create a key with `expires_at` 14 days out. Enforce read-only in your functions if you tag it with a `read` scope. The key expires on its own, so no one must remember to revoke it. ### Webhook signature backup Even with HMAC-signed webhooks, an added API-key requirement means a leaked HMAC secret alone is not enough. This adds a second layer of defense. ## Differences from sessions | Sessions | API keys | | ------------------------------------------------- | ----------------------------------------------------- | | 30-day default lifetime | Indefinite by default; explicit `expiresAt` | | Refresh-able | Rotate-able | | Per-device tracking | Per-integration tracking | | Cookie or bearer | Bearer only | | User-bound | User-bound, but typically used for service-to-service | | Token prefix `pylon_` | Token prefix `pk.` | | Shown only via `/api/auth/sessions` (prefix only) | Shown once at creation, then prefix only | ## When to use a session instead You can do server-to-server auth with a long-lived session token. You then lose per-integration tracking, explicit expiry, and clean revocation. Prefer keys for any non-user caller. # Stripe billing Source: https://docs.pylonsync.com/auth/billing Hosted Checkout sessions and signed webhook verification for Stripe billing. For most apps, use [`@pylonsync/stripe`](/plugins/stripe): declarative `stripe({ plans, hooks })` config, a canonical Subscription entity, lifecycle hooks (onSubscriptionActivate/Cancel/etc.), and a URL allowlist derived from `PYLON_PUBLIC_URL`. This page documents the lower-level `/api/billing/*` routes. Pylon creates a hosted Checkout Session for the current user. It accepts Stripe webhooks with full signature verification. Your plugin or app code still owns entitlements, plan limits, and dunning. ## What's implemented | Surface | Behavior | | ---------------------------- | ------------------------------------------------------------------------------------------------------- | | `POST /api/billing/checkout` | Mint a Stripe Checkout Session URL for the current user. Auto-creates the Stripe Customer on first use. | | `POST /api/billing/webhook` | Signature-verified Stripe event ingress. Currently logs the event; plugin hook coming. | ## Not included * Plan and entitlement state machine. Your app code reads webhook events and writes its own state. * Hosted Billing Portal. Apps construct the URL through Stripe's API directly. * Per-org billing. Checkout is keyed on `user_id`; org-scoped subscriptions store `stripeCustomerId` on the Org entity and route through app handlers. ## Schema User entity needs `stripeCustomerId`: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string().unique(), stripeCustomerId: field.string().optional(), // cus_... — auto-created on first /checkout // ... }); ``` ## Creating a Checkout Session ```bash theme={null} curl -X POST https://your-app/api/billing/checkout \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{ "priceIds": ["price_1ABCxyz..."], "mode": "subscription", "successUrl": "https://your-app.com/billing/success", "cancelUrl": "https://your-app.com/billing/cancel" }' ``` Response: ```json theme={null} { "url": "https://checkout.stripe.com/c/pay/cs_test_...", "id": "cs_test_..." } ``` The client redirects the browser to `url`. Stripe handles card collection, 3DS, etc. On success Stripe redirects back to `successUrl`. Fields: * **`priceIds`** (required) — array of Stripe Price ids to add as line items. * **`mode`** — `"subscription"` (default) or `"payment"` for one-shot purchases. * **`successUrl`** — defaults to `/billing/success`. * **`cancelUrl`** — defaults to `/billing/cancel`. On the first `/checkout` for a user, Pylon calls Stripe's API to create a Customer record from the user's email. It then writes the new `cus_...` id to `User.stripeCustomerId`. Later calls reuse that id. Errors: | Status | Code | Reason | | ------ | ----------------------- | ------------------------------------------------------------------- | | 401 | `AUTH_REQUIRED` | Caller isn't signed in | | 400 | `MISSING_PRICES` | `priceIds` array is empty or absent | | 501 | `STRIPE_NOT_CONFIGURED` | `PYLON_STRIPE_API_KEY` is not set | | 502 | `STRIPE_FAILED` | Stripe's API returned an error (customer create OR checkout create) | ## Receiving webhooks In your Stripe dashboard, configure the webhook endpoint: ``` https://your-app.com/api/billing/webhook ``` Set the signing secret on Pylon: ```bash theme={null} PYLON_STRIPE_API_KEY=sk_live_... PYLON_STRIPE_WEBHOOK_SECRET=whsec_... ``` Stripe POSTs events to `/api/billing/webhook` with a `Stripe-Signature` header. Pylon verifies the HMAC-SHA256 signature against `PYLON_STRIPE_WEBHOOK_SECRET` and validates the timestamp is within Stripe's tolerance (defends against replay). On success Pylon currently logs the event at `tracing::info!` and returns: ```json theme={null} { "received": true } ``` Errors: | Status | Code | Reason | | ------ | -------------------------------------------------- | ----------------------------------------------------------------------------- | | 400 | `WEBHOOK_INVALID` | Signature verification failed (wrong secret, replayed timestamp, bad payload) | | 501 | `STRIPE_NOT_CONFIGURED` / `WEBHOOK_NOT_CONFIGURED` | One of the env vars is missing | **Plugin hook for app code** — Pylon's roadmap includes `plugin_hooks.on_billing_event` so apps can react to events (change a user's plan, schedule grace-period jobs) without changing the framework. Today, apps that need to react proxy the webhook through their own handler that verifies and dispatches it. ## Configuration ```bash theme={null} PYLON_STRIPE_API_KEY=sk_live_... # required — enables /checkout PYLON_STRIPE_WEBHOOK_SECRET=whsec_... # required — enables /webhook ``` Use `sk_test_...` and `whsec_test_...` in dev. Stripe's CLI (`stripe listen --forward-to localhost:4321/api/billing/webhook`) is the easiest way to test the webhook path locally. ## Security guarantees * **Webhook signature verification is mandatory.** Pylon refuses to process unverified events. There is no `WEBHOOK_INSECURE_SKIP_SIGNATURE` flag. The `Stripe-Signature` header is HMAC-SHA256-verified using `PYLON_STRIPE_WEBHOOK_SECRET` and the request body's exact bytes. * **Replay protection** comes from the timestamp and tolerance check in Stripe's signature format. * **Customer id binds to user\_id** — checkout always pulls the Stripe customer from the caller's row, never accepts a caller-supplied `customerId`. A user can't trigger checkout against another user's Stripe customer. * **Failed Stripe API calls return 502, not 500** — the caller gets a clear "upstream issue" signal instead of a generic server error. ## Where to go next * **[Sessions](/auth/sessions)** — `/checkout` is gated on authenticated session * **[Organizations](/auth/organizations)** — for per-org billing, store `stripeCustomerId` on the Org entity and key your checkout flow on `auth.tenantId` # CAPTCHA Source: https://docs.pylonsync.com/auth/captcha Gate magic-code send, password register, phone send-code, and magic-link send with hCaptcha, Cloudflare Turnstile, or reCAPTCHA. Magic-code and password sign-in attract abuse. Bot networks spam send-code to every known email to phish, run credential-stuffing dictionaries against `/password/login`, or use up SMS quotas through `/phone/send-code`. Pylon's built-in CAPTCHA gate protects those endpoints. Set one env var pair, and Pylon blocks unauthenticated bots before they reach the rate limiter. Three providers are built in: * **hCaptcha** — independent, privacy-focused, free up to \~1M/month * **Cloudflare Turnstile** — invisible challenge, free for any volume * **Google reCAPTCHA** — v2 + v3 token shapes ## Gated endpoints When `PYLON_CAPTCHA_PROVIDER` + `PYLON_CAPTCHA_SECRET` are both set, these endpoints require a `captchaToken` in the request body: | Endpoint | Method | Why gated | | ----------------------------- | ------ | ---------------------------------------------------------------- | | `/api/auth/magic/send` | POST | Bot can send-code-spam every email in a list to phish or harvest | | `/api/auth/password/register` | POST | Trivial account creation → spam content / abuse fanout | | `/api/auth/phone/send-code` | POST | Burns Twilio (or other SMS provider) quotas | | `/api/auth/magic-link/send` | POST | Same as magic/send but for password-reset links | When the env vars are unset, Pylon skips the gate. Existing apps keep working unchanged. ## Configuration ```bash theme={null} PYLON_CAPTCHA_PROVIDER=hcaptcha # hcaptcha | turnstile | cloudflare | recaptcha | google PYLON_CAPTCHA_SECRET=0xCAPTCHA_secret_from_provider_dashboard ``` Provider aliases: * `turnstile` and `cloudflare` both select Turnstile. * `recaptcha` and `google` both select reCAPTCHA. The framework calls each provider's `siteverify` endpoint with the supplied token and the request's peer IP: * hCaptcha: `https://api.hcaptcha.com/siteverify` * Turnstile: `https://challenges.cloudflare.com/turnstile/v0/siteverify` * reCAPTCHA: `https://www.google.com/recaptcha/api/siteverify` The client-side public site key lives in your frontend; the server-side secret lives in `PYLON_CAPTCHA_SECRET`. Get both from the provider's dashboard. ## Client integration ```html theme={null}
``` For Turnstile, the form input is `cf-turnstile-response`; for reCAPTCHA it's `g-recaptcha-response`. All three map to a single `captchaToken` field in the request to Pylon. ## Verify behavior Missing or invalid token returns `400 CAPTCHA_FAILED`: ```json theme={null} { "error": { "code": "CAPTCHA_FAILED", "message": "CAPTCHA verification failed" } } ``` Pylon logs the provider response (error codes like `missing-input-response`, `invalid-input-response`) server-side with `tracing::warn!`. Operators can debug from logs. The client does not learn which check failed. The gate runs before the rate limiter and before any DB or email work. Bots that fail the challenge do not consume rate-limit budget or trigger later actions. ## Where to go next * **[Magic codes](/auth/magic-codes)** — primary gated endpoint * **[Password](/auth/password)** — register flow uses the same gate * **[Phone / SMS](/auth/phone)** — `/phone/send-code` is gated identically # Email verification Source: https://docs.pylonsync.com/auth/email-verification Verify an authenticated user's email with the same six-digit code primitive used for magic-code sign-in. After a user signs up (via password, OAuth with an unverified email, etc.), you usually want to confirm they own the email address. The email-verification flow uses the same primitive as [magic-code sign-in](/auth/magic-codes), but gated on an authenticated session. It sends a 6-digit code to the user's `email` field and stamps `emailVerified` on a successful verify. The OAuth and SSO sign-in paths stamp `emailVerified` automatically, because the upstream IdP already verified the email. Use this flow when the IdP did not verify the email (manual signup, edit-email flow, etc.). ## Endpoints | Endpoint | Method | Auth | Purpose | | ----------------------------------- | ------ | ------- | ------------------------------------------------ | | `/api/auth/email/send-verification` | POST | session | Email a 6-digit code to the user's current email | | `/api/auth/email/verify` | POST | session | Submit the code; stamp `emailVerified` | ## Schema The User entity needs an `emailVerified` field. Pylon writes the current ISO 8601 timestamp on success: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string().unique(), emailVerified: field.string().optional(), // ISO 8601 — null = not verified // ... }); ``` If your schema lacks this field, `update` rejects the unknown column and `/email/verify` returns `500 PERSIST_FAILED` (with a hint to add an `emailVerified` datetime field to the User entity) instead of silently succeeding. ## Sending a verification code ```bash theme={null} curl -X POST https://your-app/api/auth/email/send-verification \ -H 'Authorization: Bearer pylon_token' ``` Pylon looks up the caller's User row, reads `email`, creates a 6-digit code, and sends an email with subject `"Verify your email address"` through the configured email transport. Body: `"Your email verification code is: \n\nThis code will expire in 10 minutes."`. Response in production: ```json theme={null} { "sent": true, "email": "alice@acme.com" } ``` Response in dev (`PYLON_DEV_MODE=true`): ```json theme={null} { "sent": true, "email": "alice@acme.com", "dev_code": "123456" } ``` Errors: | Status | Code | Reason | | ------ | ------------------- | --------------------------------------------------- | | 401 | `UNAUTHORIZED` | No session | | 404 | `USER_NOT_FOUND` | Session resolves to a user\_id with no matching row | | 400 | `MISSING_EMAIL` | User row has no `email` field | | 429 | `RATE_LIMITED` | A code was requested within the throttle window | | 500 | `EMAIL_SEND_FAILED` | Email transport returned an error | The throttle is shared with magic-code sends: 1 code per email per minute. The 10-minute TTL is shared too. ## Verifying ```bash theme={null} curl -X POST https://your-app/api/auth/email/verify \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` Response on success: ```json theme={null} { "verified": true, "emailVerified": "2026-01-15T10:30:00Z" } ``` Pylon stamps `emailVerified` to the current ISO 8601 timestamp and echoes it back in the response. Errors: | Status | Code | Reason | | ------ | -------------- | ------------------------------ | | 401 | `UNAUTHORIZED` | No session | | 400 | `MISSING_CODE` | `code` field absent | | 400 | `INVALID_JSON` | Body wasn't JSON | | 4xx | `INVALID_CODE` | Wrong code, expired, or burned | ## Gating handlers on `emailVerified` In your TS code: ```typescript theme={null} import { action } from "@pylonsync/functions"; export default action({ async handler(ctx, args) { if (!ctx.auth.userId) throw new Error("sign in"); const user = await ctx.db.get("User", ctx.auth.userId); if (!user?.emailVerified) { throw ctx.error("EMAIL_NOT_VERIFIED", "Verify your email before posting"); } // proceed }, }); ``` In policies, project `emailVerified` from the User entity and reference it directly: ```typescript theme={null} import { policy } from "@pylonsync/sdk"; export const postsPolicy = policy({ entity: "Post", // Pull the related user's emailVerified through the relation. allowInsert: "auth.userId == data.authorId", allowRead: "true", }); ``` Pylon does not add `auth.emailVerified` to the policy DSL today. The field lives on your User row. You reference it through your own handlers or queries. ## Security guarantees * **Code generation, comparison, throttling shared with [magic-codes](/auth/magic-codes)** — 6-digit numeric, 10-minute TTL, burn-after-5-wrong-attempts, constant-time comparison. * **Session-gated** — only the authenticated user can request and verify their own email. There is no admin override. To mark an email verified from another process, write the User row directly with admin auth. * **ISO 8601 timestamp format** — `pylon_kernel::util::now_iso()` produces `2026-01-15T10:30:00Z`. An email is verified only when `emailVerified` is non-null. ## Where to go next * **[Magic codes](/auth/magic-codes)** — the same 6-digit code primitive as a primary sign-in flow * **[Password](/auth/password)** — the register flow that produces an unverified email # JWT sessions Source: https://docs.pylonsync.com/auth/jwt Exchange an opaque session for an optional short-lived, stateless JWT. By default, Pylon sessions are opaque 256-bit tokens that the server resolves and can revoke immediately. Most apps should use them. You sometimes need a stateless token format. Two cases: to authenticate microservices that verify without a session-store round-trip, or to pass through systems that expect a JWT. For these, Pylon creates HS256 JWTs from an existing session. The opaque session stays the source of truth; revoke, refresh, and list still use it. The JWT is a short-lived projection that downstream services validate on their own. ## When to use JWTs (and when not to) | Want | Use | | -------------------------------------------------------------- | ------------------------------------------------------ | | Browser session, native app sign-in | Opaque session (default) — revocable, no key to rotate | | Service-to-service that can't pay the session-store round-trip | HS256 JWT | | Accept tokens minted elsewhere (e.g. Auth0, third-party SSO) | Use the `jwt` plugin instead of `/api/auth/jwt` | | Need to revoke a specific token before it expires | Opaque session, not JWT | You cannot revoke a JWT before it expires. So the default lifetime is short (1 hour), and the opaque session is still the revoke target. If a JWT leaks, you must wait for it to expire or rotate `PYLON_JWT_SECRET` (which invalidates every JWT). ## Algorithm * **HS256** (HMAC-SHA256). Symmetric — `PYLON_JWT_SECRET` is both signer and verifier. * No RS256, EdDSA, or asymmetric mode in `mint`. Symmetric is correct when one Pylon binary creates and verifies the token. If multiple services must verify, share the secret over your existing secrets channel. * Standard JWT envelope: `..`. ## Endpoint | Endpoint | Method | Auth | Purpose | | --------------- | ------ | --------------------- | -------------------------------------- | | `/api/auth/jwt` | POST | authenticated session | Exchange the current session for a JWT | ```bash theme={null} curl -X POST https://your-app/api/auth/jwt \ -H 'Authorization: Bearer pylon_session_token' ``` Response: ```json theme={null} { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": 1735003600 } ``` Errors: | Status | Code | Reason | | ------ | -------------------- | -------------------------------------- | | 401 | `AUTH_REQUIRED` | Caller isn't signed in | | 501 | `JWT_NOT_CONFIGURED` | `PYLON_JWT_SECRET` is not set or empty | The JWT carries the caller's identity at mint time: `sub` (user id), `tenant_id`, and `roles`. A `select-org` after minting does not update the JWT. The next call to `/api/auth/jwt` reflects the new active tenant. ## Claims ```typescript theme={null} type JwtClaims = { sub: string; // user_id iat: number; // issued-at (unix seconds) exp: number; // expires-at (unix seconds) iss: string; // PYLON_JWT_ISSUER (defaults to "pylon") tenant_id?: string; // active org at mint time roles: string[]; // RBAC roles at mint time } ``` ## Bearer flow A request can carry either an opaque session token or a JWT in `Authorization: Bearer`. Pylon's token resolver tries in this order: 1. Admin token (constant-time compare) 2. `pk.*` API key 3. JWT (when `PYLON_JWT_SECRET` is set AND the token's 3-segment shape looks like a JWT) 4. Session token JWT verification runs on every request that carries a JWT-shaped bearer. It checks the signature, `alg=HS256`, `exp` not in the past, and `iss` matches `PYLON_JWT_ISSUER` if set. If verification fails, Pylon returns `401` with one of: | Code | Reason | | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `INVALID_JWT` | Signature mismatch, expired, wrong issuer, malformed | | `JWT_MISCONFIGURED` | `PYLON_JWT_SECRET` is set but `PYLON_JWT_ISSUER` is missing — refuse-to-validate by design (see Security guarantees) | ## Configuration ```bash theme={null} PYLON_JWT_SECRET= # required to enable JWT mint + verify PYLON_JWT_ISSUER=https://your-app.com # REQUIRED in production (see below) PYLON_JWT_LIFETIME_SECS=3600 # 1 hour default ``` ### Why `PYLON_JWT_ISSUER` is required Without an issuer pin, any JWT signed with the same HS256 secret would verify, whatever its issuer. If you share `PYLON_JWT_SECRET` with another system (microservices, a third-party-token setup), a token issued for that system's `sub` could sign into Pylon as that user. Set `PYLON_JWT_ISSUER` and require the `iss` claim to match to prevent this. If `PYLON_JWT_SECRET` is set but `PYLON_JWT_ISSUER` is missing, Pylon refuses to verify any JWT (`JWT_MISCONFIGURED`). This is the safest default while the operator fixes the config. ## Accepting JWTs minted elsewhere `/api/auth/jwt` is for issuing tokens. To accept JWTs from another system (for example your Auth0 tenant or a homegrown SSO), use the `jwt` plugin instead: ```typescript theme={null} import { app } from "@pylonsync/sdk"; import { jwtPlugin } from "@pylonsync/sdk/plugins"; export default app({ // ... plugins: [ jwtPlugin({ issuer: "https://acme.auth0.com/", jwksUri: "https://acme.auth0.com/.well-known/jwks.json", // or symmetric: secret: process.env.SHARED_HS256_SECRET }), ], }); ``` That plugin validates inbound JWTs against the upstream issuer's JWKS (RS256 / ES256). It does not issue any tokens of its own. See [plugins/integrations](/plugins/integrations). ## Security guarantees * **HS256 + 256-bit secret** — `openssl rand -hex 32` produces a 32-byte secret. Anything shorter and `PYLON_JWT_SECRET` validates but the security argument weakens. * **`alg=HS256` enforced** — the `alg=none` attack and HS256/RS256 algorithm-confusion attack don't apply; Pylon's verify rejects any header where `alg != "HS256"`. * **Issuer pin** — refuse-to-validate when `PYLON_JWT_SECRET` is set but `PYLON_JWT_ISSUER` is missing. * **No revoke path** — JWTs are valid until `exp`. Keep lifetimes short (1 hour default). The underlying opaque session is still revocable via `/api/auth/session` / `/sessions` DELETE. * **Claims captured at mint time** — `tenant_id` and `roles` snapshot the session at issuance. Updating roles or running `/select-org` doesn't invalidate existing JWTs. ## Where to go next * **[Sessions](/auth/sessions)** — the opaque-token default and why it's usually the right answer * **[API keys](/auth/api-keys)** — long-lived server-to-server bearer tokens, also stateless but with explicit revoke * **[Plugins / integrations](/plugins/integrations)** — accepting JWTs from upstream IdPs # Magic codes Source: https://docs.pylonsync.com/auth/magic-codes Email a 6-digit code, exchange it for a session. Pylon's recommended sign-in flow. Magic codes are the simplest sign-in flow Pylon ships. The user types an email, gets a 6-digit code, and types it back to sign in. There are no passwords to remember and no OAuth credentials to obtain. Most apps should start here. ## How it works ``` client POST /api/auth/magic/send { email } ← { sent: true } → user receives email with 6-digit code client POST /api/auth/magic/verify { email, code } ← { token, user_id, expires_at } ``` If the user does not exist when the code is verified, Pylon creates a `User` row with that email and stamps `emailVerified`. Typing the code proves control of the address. ## Send a code ```bash theme={null} curl -X POST https://your-app/api/auth/magic/send \ -H 'Content-Type: application/json' \ -d '{"email": "alice@example.com"}' ``` Response: ```json theme={null} { "sent": true, "email": "alice@example.com" } ``` In dev mode (`PYLON_DEV_MODE=true`), the response also includes `dev_code`. You can sign in without an email provider configured: ```json theme={null} { "sent": true, "email": "alice@example.com", "dev_code": "428193" } ``` ## Verify a code ```bash theme={null} curl -X POST https://your-app/api/auth/magic/verify \ -H 'Content-Type: application/json' \ -d '{"email": "alice@example.com", "code": "428193"}' ``` Response: ```json theme={null} { "token": "pylon_a1b2c3...", "user_id": "usr_xyz", "expires_at": 1735689600 } ``` Store the `token` and pass it as `Authorization: Bearer pylon_a1b2c3...` on subsequent requests. ## From the SDKs ```typescript TypeScript theme={null} import { configureClient, startMagicLink, verifyMagicLink } from "@pylonsync/react"; configureClient({ baseUrl: "https://your-app" }); await startMagicLink("alice@example.com"); // ... user types the code ... const session = await verifyMagicLink("alice@example.com", code); // session.token is now stored automatically ``` ```swift Swift theme={null} import PylonClient let client = PylonClient(baseURL: URL(string: "https://your-app")!) try await client.startMagicCode(email: "alice@example.com") // ... user types the code ... let session = try await client.verifyMagicCode( email: "alice@example.com", code: code ) // token is persisted to UserDefaults automatically ``` ## Built-in security * **Constant-time code comparison** — no timing leak. * **5-attempt cap per code** — burned after 5 wrong tries; even a correct subsequent attempt returns `RATE_LIMITED`. * **10-minute expiry** — codes are short-lived. * **60-second send cooldown per email** — clients can't flood a user with codes. Returns `429 RATE_LIMITED` with `retry_after_secs`. * **Single-use** — verifying a code consumes it. * **CSPRNG-generated** — codes are uniform random in `0..1_000_000`. ## Configuring email delivery Magic codes need an email provider configured, otherwise they only work in dev mode. Pylon supports: ```bash theme={null} PYLON_EMAIL_PROVIDER=stack0 # or sendgrid | resend | webhook PYLON_EMAIL_API_KEY=sk_live_... PYLON_EMAIL_FROM=noreply@yourdomain.com ``` On Pylon Cloud, magic-link email is included. You need no provider config for the built-in sender. Configure your own domain in `Settings → Email` for production to keep deliverability high. If you set `PYLON_EMAIL_PROVIDER=webhook`, also set `PYLON_EMAIL_ENDPOINT` to your custom URL. Pylon POSTs `{ to, from, subject, body }` to it. To give auth email its own key and from-address, separate from your app's `ctx.email`, set the `PYLON_AUTH_EMAIL_*` family (`_PROVIDER`, `_API_KEY`, `_FROM`, `_ENDPOINT`). It overrides `PYLON_EMAIL_*` for auth flows only. See [Auth → Configuration](/auth/overview#configuration). See [Stack0](https://www.stack0.dev), [Resend](https://resend.com), [SendGrid](https://sendgrid.com) for transactional sending services. ## Customizing the email The default subject is "Your sign-in code" and the body is plain text: ``` Your sign-in code is: 428193 This code will expire in 10 minutes. ``` To customize, write your own action that calls `magic/send` server-side and sends your own templated email. The plugin system also supports replacing the email transport. See [Plugins → Integrations](/plugins/integrations). ## Error responses | Code | Status | Meaning | | ------------------- | ------ | ------------------------------------------ | | `MISSING_EMAIL` | 400 | No `email` in body | | `MISSING_CODE` | 400 | No `code` in body (verify only) | | `RATE_LIMITED` | 429 | Cooldown on send, or too many bad verifies | | `INVALID_CODE` | 401 | Wrong code, expired, or already used | | `EMAIL_SEND_FAILED` | 500 | Provider rejected the send | ## Testing In dev mode, skip the email entirely: ```typescript theme={null} const send = await fetch("/api/auth/magic/send", { method: "POST", body: JSON.stringify({ email: "test@example.com" }), }).then(r => r.json()); const verify = await fetch("/api/auth/magic/verify", { method: "POST", body: JSON.stringify({ email: "test@example.com", code: send.dev_code }), }).then(r => r.json()); // verify.token is now valid ``` For integration tests, this is the fastest sign-in path — no SMTP mock needed. ## When to choose magic codes vs alternatives | Magic codes | Password | OAuth | | ----------------------------------- | --------------------------------------- | ----------------------------------------- | | ✅ Zero memory load on user | ❌ Users forget passwords | ✅ Familiar to users | | ✅ Email is verified by construction | ❌ Email verification is a separate step | ✅ No credentials your service stores | | ✅ No password reset flow needed | ❌ Need reset flow | ✅ Provider handles 2FA | | ❌ Email deliverability matters | ✅ Works without email | ❌ Requires app registration with provider | | ❌ Slower than typing a password | ✅ Fast for repeat users | ✅ One click after first use | Most apps should start with magic codes, then add OAuth for one-click sign-in once they have users. Password is the third option, useful when email is not available (for example a shared device with no email access). # OAuth — 25+ providers + OIDC Source: https://docs.pylonsync.com/auth/oauth Configure built-in OAuth providers or connect any OpenID Connect provider through discovery. Pylon ships native OAuth for 25 providers, plus OpenID Connect discovery for any compliant IdP (Auth0, Okta, Keycloak, Cognito, Logto, Authentik, Zitadel...). A user clicks sign in, goes to the provider, and returns with a session token. Single-use state tokens protect against CSRF and survive a server restart mid-handshake. Pylon uses PKCE for providers that require it (Twitter/X, Kick) and `response_mode=form_post` for Apple. Pylon handles the provider-specific details; you set two env vars per provider. ## Built-in providers | Provider | id | Notes | | ----------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | | Google | `google` | OIDC, `openid email profile` | | GitHub | `github` | `user:email`, falls back to `/user/emails` for private addresses | | Apple | `apple` | ES256-signed JWT client\_secret, `response_mode=form_post`, identity from `id_token` — see [Apple section](#apple) | | Microsoft | `microsoft` | Tenant-aware, defaults to `common` | | Discord | `discord` | `identify email` | | Slack | `slack` | OIDC | | Spotify | `spotify` | Basic-auth token endpoint | | Twitch | `twitch` | OIDC | | Twitter / X | `twitter` | PKCE-required, no email by default | | LinkedIn | `linkedin` | OIDC | | Facebook | `facebook` | `email public_profile` | | GitLab | `gitlab` | OIDC | | Reddit | `reddit` | No email exposed — synthesized as `@reddit.invalid` | | Notion | `notion` | Basic-auth + JSON body, `Notion-Version` header | | Linear | `linear` | GraphQL userinfo | | Vercel | `vercel` | — | | Zoom | `zoom` | — | | Salesforce | `salesforce` | OIDC | | Atlassian | `atlassian` | JSON-body token exchange, `audience=api.atlassian.com` | | Figma | `figma` | — | | Dropbox | `dropbox` | POST userinfo (RPC-style) | | TikTok | `tiktok` | Uses `client_key` + comma-separated scopes | | PayPal | `paypal` | OIDC | | Kick | `kick` | PKCE-required | | Roblox | `roblox` | OIDC | For **any other OIDC-compliant IdP**, use the generic OIDC adapter — see [Generic OIDC](#generic-oidc). ## Set up credentials Register your app with the provider, then set: ```bash theme={null} PYLON_OAUTH__CLIENT_ID=... PYLON_OAUTH__CLIENT_SECRET=... PYLON_OAUTH__REDIRECT=https://your-app.com/api/auth/callback/ # Optional per-provider: PYLON_OAUTH__SCOPES="custom scope list" # override default scopes PYLON_OAUTH_MICROSOFT_TENANT=contoso.onmicrosoft.com # Microsoft only ``` Replace `` with `GOOGLE`, `GITHUB`, `APPLE`, etc. (the uppercase form of the table id above). If a provider's `CLIENT_ID` or `CLIENT_SECRET` is unset, Pylon disables that provider. `/api/auth/providers` does not list it. On **Pylon Cloud**, set these in `Settings → Environment` per workspace. ### Apple Apple is different. Its `client_secret` is a JWT you sign with your developer ES256 key, not a static string. Pylon creates a fresh JWT on every token exchange, so you do not manage rotation. ```bash theme={null} PYLON_OAUTH_APPLE_CLIENT_ID=com.example.app # Service ID or bundle id PYLON_OAUTH_APPLE_REDIRECT=https://your-app.com/api/auth/callback/apple PYLON_OAUTH_APPLE_TEAM_ID=ABCDE12345 # 10-char team id PYLON_OAUTH_APPLE_KEY_ID=KEYID12345 # 10-char key id PYLON_OAUTH_APPLE_PRIVATE_KEY=/path/to/AuthKey_KEYID12345.p8 # OR inline the PEM: PYLON_OAUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----..." ``` Apple POSTs the callback (`response_mode=form_post`) instead of the usual GET. Pylon's callback handler accepts both. The user's identity comes from the `id_token` JWT in the token response, not a userinfo endpoint. > **Note:** Apple sends the user's name only on the first sign-in for an app. Later logins return `sub` and `email` only. Pylon stores what it can and does not overwrite an existing `displayName`. ### Microsoft Set `PYLON_OAUTH_MICROSOFT_TENANT` for single-tenant apps: ```bash theme={null} PYLON_OAUTH_MICROSOFT_TENANT=contoso.onmicrosoft.com # Or for work-only multi-tenant apps: PYLON_OAUTH_MICROSOFT_TENANT=organizations ``` Default is `common` (any account — work, school, personal). ### Twitter / X Twitter requires PKCE. Pylon generates the verifier and challenge automatically and stores them in the OAuth state record. No extra config is needed. Twitter's userinfo does not include email by default, so Pylon synthesizes `@x.invalid` to give account rows a value. Apps that require a real email should reject these accounts. ### Generic OIDC For any OpenID Connect provider — Auth0, Okta, Keycloak, Cognito, Logto, Authentik, Zitadel, your homegrown IdP — set: ```bash theme={null} PYLON_OAUTH_AUTH0_OIDC_ISSUER=https://acme.auth0.com PYLON_OAUTH_AUTH0_CLIENT_ID=... PYLON_OAUTH_AUTH0_CLIENT_SECRET=... PYLON_OAUTH_AUTH0_REDIRECT=https://your-app.com/api/auth/callback/auth0 ``` The provider id is the lowercase of the env-var prefix (`auth0` here). Pylon fetches `/.well-known/openid-configuration` on first use and caches the discovered endpoints. The discovery doc's `token_endpoint_auth_methods_supported` decides whether pylon uses Basic auth (the OIDC default) or a `client_secret`-in-body POST. ## How it works ``` ┌──────────────────────────────────────┐ client → GET /api/auth/login/google?callback=… │ ← { redirect: "https://accounts.google.com/o/oauth2/...", state: "xyz" } │ client → redirects user to Google │ ← user grants access, Google redirects to │ /api/auth/callback/google?code=...&state=xyz │ │ server → validates state (CSRF check) │ → exchanges code for access_token (+ id_token) │ → fetches userinfo from Google (or decodes id_token) │ → upserts User row │ → mints Session │ ← 302 redirect to your callback URL with cookie set │ (or JSON { token, user_id, expires_at } if POSTed) │ └──────────────────────────────────────┘ ``` ## Profile pictures Pylon captures the provider's profile picture (OIDC `picture` claim, GitHub `avatar_url`) on every OAuth login and stores it on the account link. It refreshes each sign-in, so rotated provider CDN URLs stay current. It appears as `avatar_url` on: * `GET /api/auth/me` and `GET /api/auth/session` — for the signed-in user's own account chip (`useSession()` exposes it as `session.avatarUrl`); * `GET /api/auth/orgs/:id/members` — so team rosters can render real photos. The value is `null` for password and magic-link users (no provider picture), so render an initials fallback. Pylon stores only `https://` URLs. When a user has several linked providers, the most recently used one wins. ## Two ways to invoke ### Browser flow (302 redirect) `GET /api/auth/login/?callback=&redirect=1` returns a 302 directly to the provider. The callback handler sets a cookie and 302s to the `callback` URL you supplied: ```html theme={null} Sign in with Google ``` `callback` (and the optional `error_callback`) MUST have an origin listed in `manifest.auth.trustedOrigins` (or `PYLON_TRUSTED_ORIGINS`). Loopback (`http://localhost`, `127.0.0.1`, `[::1]`, any port) is always auto-trusted, so `pylon dev` works without config. See [Security plugins → Unified trustedOrigins](/plugins/security#unified-trustedorigins). ### JSON flow (manual) For SPAs or native apps that want to control the redirect themselves: ```bash theme={null} curl 'https://your-app/api/auth/login/google?callback=https://your-app/dashboard' # → { "redirect": "https://accounts.google.com/...", "state": "xyz" } ``` You navigate the browser to `redirect`, the user comes back to your app's `?code=...&state=xyz` URL, and you POST it to the callback: ```bash theme={null} curl -X POST https://your-app/api/auth/callback/google \ -H 'Content-Type: application/json' \ -d '{"code": "4/0Ad...", "state": "xyz"}' ``` Response: ```json theme={null} { "token": "pylon_...", "user_id": "usr_xyz", "provider": "google", "expires_at": 1735689600 } ``` ## CSRF protection Every OAuth start mints a random state token (256 bits, prefixed `pylon_`) that expires after 10 minutes and is single-use. The callback rejects the request if: * The state is missing * The state has expired * The state was already used (replay) * The state was minted for a different provider (e.g. Google state on a GitHub callback) State storage defaults to in-memory; the runtime swaps in a SQLite or Postgres backend so a server restart mid-handshake doesn't break in-flight sign-ins. PKCE verifiers ride along in the same record for providers that need them. ## What gets created On first OAuth sign-in for a given email, Pylon creates a `User` row: ```jsonc theme={null} { "id": "auto-generated", "email": "alice@example.com", "displayName": "Alice (from Google)", "emailVerified": "", "createdAt": "" } ``` On later sign-ins, Pylon looks up the user by `(provider, provider_account_id)`, not by email, so a user who renamed their email keeps their account. Email verification is implicit. The OAuth provider already verified the email, so Pylon stamps `emailVerified` immediately. Your `User` entity should have: ```jsonc theme={null} { "name": "email", "type": "string", "unique": true }, { "name": "displayName", "type": "string" }, { "name": "emailVerified", "type": "datetime", "optional": true }, { "name": "createdAt", "type": "datetime" } ``` ## Discovering configured providers ```bash theme={null} curl https://your-app/api/auth/providers ``` ```json theme={null} [ { "provider": "google", "auth_url": "https://accounts.google.com/o/oauth2/..." }, { "provider": "github", "auth_url": "https://github.com/login/oauth/authorize?..." }, { "provider": "apple", "auth_url": "https://appleid.apple.com/auth/authorize?..." } ] ``` Use this in your sign-in UI to render only the buttons you've configured. ## Error responses | Code | Status | Meaning | | ----------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- | | `PROVIDER_NOT_FOUND` | 404 | `PYLON_OAUTH__*` env vars not set, or unknown provider id | | `OAUTH_PROVIDER_BROKEN` | 500 | OIDC discovery failed or provider misconfigured | | `OAUTH_INVALID_STATE` | 403 | State missing, expired, or replayed | | `OAUTH_TOKEN_EXCHANGE_FAILED` | 502 | Provider rejected the code exchange — common when client secret is wrong, redirect URI doesn't match, or PKCE verifier mismatch | | `UNTRUSTED_REDIRECT` | 403 | `?callback=` origin is not in `manifest.auth.trustedOrigins` (or `PYLON_TRUSTED_ORIGINS`). Loopback is auto-trusted. | Pylon sanitizes provider error messages. It redacts `client_secret`, `code_verifier`, `refresh_token`, `id_token`, and `access_token` values before they reach `oauth_error_message` redirect URLs or server logs. # OIDC provider Source: https://docs.pylonsync.com/auth/oidc-provider Run Pylon as an OpenID Connect provider with PKCE, RS256 ID tokens, JWKS, and userinfo. Pylon can be the identity provider that other systems sign into. Internal tools, microservices, or third-party SaaS that accept any OIDC IdP point at Pylon's discovery doc. They treat the id\_tokens Pylon issues as the identity layer. ## What ships The full auth-code and PKCE flow: | Endpoint | Method | Auth | Purpose | | ----------------------------------- | ------ | -------------------------- | -------------------------------------------- | | `/.well-known/openid-configuration` | GET | none | OIDC discovery document | | `/oidc/jwks` | GET | none | RS256 public key (auto-generated, persisted) | | `/oidc/authorize` | GET | session cookie | Initiate auth-code flow | | `/oidc/token` | POST | client\_id + secret + PKCE | Exchange code → id\_token + access\_token | | `/oidc/userinfo` | GET | Bearer access\_token | Standard OIDC user claims | ## Security stance * **PKCE S256 is REQUIRED** for every authorize request. Pylon rejects `plain` per OAuth 2.1 and rejects a request with no PKCE. * **redirect\_uri** must match the client's registered list by exact string compare. Pylon allows no path coercion, suffix matching, or scheme upgrades (a common OIDC open-redirect risk). * **client\_id and client\_secret** are constant-time compared. Public clients (no secret in registration) authenticate via PKCE alone. * **id\_token claims**: `iss`, `sub`, `aud`, `exp` (10 min), `iat`, `nonce` (when supplied at /authorize), `email`, `email_verified`, `name` (subject to requested scopes). * **access\_token** is opaque random (32-byte base64url), TTL 1 hour. * **refresh\_token is not issued.** Clients redo the auth-code flow when the access\_token expires. This keeps the surface small and removes a class of long-lived bearer leak. ## Configuration ```bash theme={null} PYLON_OIDC_ISSUER=https://auth.your-app.com PYLON_OIDC_CLIENTS='[{"client_id":"docs-portal","client_secret":"shh-1234","redirect_uris":["https://docs.example.com/oauth/callback"]}]' # Optional — defaults to a .oidc-key.pem file next to your session DB, 0600 perms PYLON_OIDC_KEY_PATH=/var/lib/pylon/oidc-signing-key.pem # Optional — defaults to /login, the dashboard's own login page PYLON_LOGIN_URL=/login ``` `PYLON_OIDC_CLIENTS` is a JSON array of `{client_id, client_secret?, redirect_uris[]}`. `client_secret` is optional — omit it for SPAs / native apps that authenticate via PKCE only. ## Signing key On first start (with `PYLON_OIDC_ISSUER` set), Pylon generates a 2048-bit RSA key, persists it as PKCS#8 PEM at `PYLON_OIDC_KEY_PATH`, and chmods the file to 0600 so it is not world-readable. Pylon reuses the same key across restarts, so issued id\_tokens stay verifiable. The JWKS endpoint publishes the matching public key with: * `kid` = first 16 hex chars of SHA-256(modulus) — stable across restarts, changes on rotation * `kty: "RSA"`, `alg: "RS256"`, `use: "sig"` * `n` + `e` = base64url-no-pad-encoded big-endian integers ```bash theme={null} curl https://auth.your-app.com/.well-known/openid-configuration curl https://auth.your-app.com/oidc/jwks ``` ## Auth-code flow A typical end-to-end exchange: ``` # 1. Client redirects user's browser to /authorize. https://auth.your-app.com/oidc/authorize? response_type=code &client_id=docs-portal &redirect_uri=https://docs.example.com/oauth/callback &scope=openid+email+profile &state= &nonce= &code_challenge= &code_challenge_method=S256 # 2. User logs in (Pylon's /login handles whichever method they use). # Pylon redirects browser back to redirect_uri with ?code=...&state=... # 3. Client exchanges code at /token. POST /oidc/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code= &redirect_uri=https://docs.example.com/oauth/callback &client_id=docs-portal &client_secret=shh-1234 &code_verifier= # 200 OK { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "id_token": "", "scope": "openid email profile" } # 4. Client calls /userinfo with the access_token. GET /oidc/userinfo Authorization: Bearer # 200 OK { "sub": "user_abc123", "email": "alice@example.com", "email_verified": true, "name": "Alice Liddell" } ``` ## Userinfo claim projection `/oidc/userinfo` projects the User row through the same `auth.user.expose` / `auth.user.hide` config that protects `/api/auth/session` from leaking secrets. `passwordHash` and underscore-prefixed fields never reach a downstream service through userinfo. Claims returned per scope: | Scope | Claims | | --------- | ------------------------------------- | | `openid` | `sub` | | `email` | `email`, `email_verified` | | `profile` | `name` (from `displayName` or `name`) | ## Where to go next * **[JWT sessions](/auth/jwt)** — minting the tokens app-internal services verify * **[SSO](/auth/sso)** — the other direction (external IdPs signing INTO Pylon) * **[Sessions](/auth/sessions)** — the cookie that gates `/oidc/authorize` # Organizations Source: https://docs.pylonsync.com/auth/organizations Model multi-tenant organizations, roles, invites, and active-tenant sessions as customizable manifest entities. 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. ```typescript theme={null} 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(), // built-in or manifest-declared custom role 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: ```typescript theme={null} import { auth, buildManifest } from "@pylonsync/sdk"; const manifest = buildManifest({ // ... auth: auth({ org: { entity: "Organization", memberEntity: "Membership", inviteEntity: "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/*`: ```typescript theme={null} auth: 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 and 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. Apps can declare additional least-privilege roles directly in `buildManifest`: ```typescript theme={null} const manifest = buildManifest({ // ... auth: { orgRoles: ["reviewer", "speaker_manager", "billing"], }, }); ``` 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. | 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/*`. This is by design, so probing cannot enumerate org ids. ## Creating an org ```bash theme={null} curl -X POST https://your-app/api/auth/orgs \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"name": "Acme Corp"}' ``` Response: ```json theme={null} { "id": "org_a1b2c3...", "name": "Acme Corp", "created_at": 1735000000, "role": "owner" } ``` Pylon adds the caller as `Owner` automatically. `created_by` is set to the caller's user id and cannot change. ## Listing user's orgs ```bash theme={null} curl https://your-app/api/auth/orgs \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} [ { "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 any number of orgs. Orgs are not exclusive. ## Inviting a teammate ```bash theme={null} 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: ```json theme={null} { "id": "inv_xyz", "email": "alice@acme.com", "role": "member", "expires_at": 1735604800, "accept_url": "https://your-app/api/auth/invites//accept", "token": "" } ``` 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: ```bash theme={null} curl -X POST https://your-app/api/auth/invites/<token>/accept \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "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 | Pylon keeps the original invite row (it does not delete it) and stamps `accepted_at`. This preserves the audit trail. ## Managing roles ```bash theme={null} # 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 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`: ```bash theme={null} 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 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): ```bash theme={null} 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 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](/auth/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: ```typescript theme={null} 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 reaches the database. ## Per-org SSO Each org can have its own SSO IdP. See [SSO](/auth/sso) for OIDC and 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. 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](/auth/sessions): `select-org`, multi-tenant session state * [RBAC](/auth/rbac): policies that read `auth.tenantId` and `auth.hasRole(...)` * [SSO](/auth/sso): per-org OIDC and SAML # Auth overview Source: https://docs.pylonsync.com/auth/overview Choose among Pylon sessions, magic codes, passwords, OAuth, RBAC, and API keys. Pylon ships a complete auth system in the binary. You do not add Auth0, Clerk, or NextAuth. Sign-in flows, sessions, RBAC, and OAuth callbacks are all native HTTP endpoints. This page maps the methods. The rest of this section covers each one. ## What's included | Feature | How | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Magic-code sign-in | 6-digit code emailed to the user; verify via `/api/auth/magic/verify` | | Email + password | Argon2-hashed; `/api/auth/password/register` + `/login` | | OAuth | Google + GitHub built in; CSRF-protected via state tokens | | Sessions | Opaque 256-bit tokens, 30-day default expiry, refresh + revoke | | Multi-tenant | Active org per session; row-scoped policies via `auth.tenantId` | | Roles (RBAC) | `auth.hasRole('admin')` in policies; multi-role aware | | API keys | `pk.…` bearer tokens, optional scopes + expiry, server-issued at `/api/auth/api-keys` | | HIBP password check | Argon2 + Pwned Passwords k-anonymity check at register/change. Disable with `PYLON_DISABLE_HIBP=1` (tests) | | Password change/reset | `/api/auth/password/change` (requires current password); forgot-password via `/api/auth/password/reset/request` + `/reset/complete` (emailed single-use link) | | Account deletion | `DELETE /api/auth/account` — revokes sessions, API keys, OAuth links + deletes user row | | TOTP / 2FA | RFC 6238 6-digit codes. `/api/auth/totp/enroll` + `/verify` + `/disable`. Compatible with every authenticator app | | WebAuthn / passkeys | ES256 + Ed25519. Counter-regression detection. Endpoints: `/api/auth/passkey/{register,login}/{begin,finish}` + key management. Compose `verify_assertion` directly for custom flows. | | JWT sessions | Optional stateless `Authorization: Bearer <jwt>` mode. Set `PYLON_JWT_SECRET` to enable; `/api/auth/jwt` mints from a session | | CAPTCHA | hCaptcha + Turnstile + reCAPTCHA. Set `PYLON_CAPTCHA_PROVIDER` + `PYLON_CAPTCHA_SECRET` to gate magic-code send + password register | | Organizations | Multi-tenant orgs + RBAC (owner/admin/member) + email invites with single-use tokens. Endpoints under `/api/auth/orgs/...` | | Stripe billing | Hosted Checkout sessions + signed webhook verification. Set `PYLON_STRIPE_API_KEY` + `PYLON_STRIPE_WEBHOOK_SECRET` | | Phone / SMS sign-in | E.164-normalized magic codes via Twilio (or any SmsSender). Endpoints under `/api/auth/phone/...` | | SIWE (Sign-In With Ethereum) | EIP-4361 + k256 ECDSA recovery + Keccak-256. Endpoints: `GET /api/auth/siwe/nonce`, `POST /api/auth/siwe/verify`. | | OIDC Provider | Full auth-code + PKCE flow: `/.well-known/openid-configuration`, `/oidc/jwks`, `/oidc/authorize`, `/oidc/token`, `/oidc/userinfo`. RS256 id\_tokens signed with an auto-generated, disk-persisted RSA-2048 key. Clients registered via `PYLON_OIDC_CLIENTS` JSON env. | | SCIM 2.0 | `/scim/v2/Users` POST/GET/PATCH/PUT/DELETE with `userName eq "..."` filter support + `/scim/v2/{ServiceProviderConfig, Schemas, ResourceTypes}` discovery — bearer auth via `PYLON_SCIM_TOKEN`. SCIM `active=false` → soft delete. | | TOTP / 2FA | Via the `totp` plugin | | JWT validation | Via the `jwt` plugin (for accepting tokens minted elsewhere) | | Guest sessions | Stable anonymous IDs for cart/preference state pre-login | | Email verification | `/api/auth/email/send-verification` + `/api/auth/email/verify` | ## The shape of every auth flow Every sign-in method mints a `Session` and returns a token for the client to store. Authenticated requests then carry `Authorization: Bearer <token>`. ``` client → POST /api/auth/<method> → { token, user_id, expires_at } client → request with Authorization → server resolves session → AuthContext ``` The `AuthContext` is what flows into your policies, your TypeScript functions (as `ctx.auth`), and `/api/auth/me`. ## Endpoints All under `/api/auth/`: | Endpoint | Method | Purpose | | ----------------------------------- | ----------------- | --------------------------------------------------------------------------------- | | `/me` | GET | Resolve the current session — returns `AuthContext` | | `/magic/send` | POST | Email a 6-digit code | | `/magic/verify` | POST | Trade code for session token | | `/password/register` | POST | Create user + sign in | | `/password/login` | POST | Sign in with email + password | | `/email/send-verification` | POST | Email a code to the current user | | `/email/verify` | POST | Verify the user's email address | | `/providers` | GET | List configured OAuth providers | | `/login/:provider` | GET | Begin OAuth (returns redirect URL or 302) | | `/callback/:provider` | GET, POST | Complete OAuth | | `/refresh` | POST | Rotate the session token | | `/sessions` | GET | List all sessions for the current user | | `/sessions` | DELETE | Revoke all sessions for the current user | | `/session` | DELETE | Revoke the current session (sign out) | | `/select-org` | POST | Switch active tenant | | `/guest` | POST | Mint a guest session with a stable anonymous id | | `/upgrade` | POST | Convert a guest session to a real user (admin / dev only) | | `/password/change` | POST | Change password (requires current; HIBP-checked; revokes other sessions) | | `/password/reset/request` | POST | Email a single-use password-reset link (always 200; enumeration-safe) | | `/password/reset/complete` | POST | Consume the reset token, set the new password, revoke all sessions | | `/api-keys` | POST | Mint an API key (returns plaintext exactly once) | | `/api-keys` | GET | List the current user's API keys (no plaintext) | | `/api-keys/:id` | DELETE | Revoke a single API key by id | | `/account` | DELETE | Wipe user — sessions, API keys, OAuth links, user row | | `/totp/enroll` | POST | Mint a TOTP secret + provisioning URL (re-enroll requires current code) | | `/totp/verify` | POST | Confirm a TOTP code; finalizes enrollment on first success | | `/totp/disable` | POST | Remove TOTP (requires a current code) | | `/jwt` | POST | Exchange the current session for a JWT-shaped token (requires `PYLON_JWT_SECRET`) | | `/orgs` | POST | Create an org (caller becomes owner) | | `/orgs` | GET | List orgs the caller belongs to | | `/orgs/:id` | GET, DELETE | Org details / delete (owner only) | | `/orgs/:id/members` | GET | List members | | `/orgs/:id/members/:user_id` | PUT, DELETE | Change role / remove (admin+) | | `/orgs/:id/invites` | POST, GET | Send invite by email / list pending | | `/orgs/:id/invites/:invite_id` | DELETE | Revoke pending invite | | `/invites/:token/accept` | POST | Accept an invite (must be logged in with the invited email) | | `/billing/checkout` (under `/api/`) | POST | Mint a Stripe Checkout Session for the current user | | `/billing/webhook` (under `/api/`) | POST | Stripe webhook target (verifies signature) | | `/phone/send-code` | POST | Send a 6-digit code via SMS (Twilio, configurable transport) | | `/phone/verify` | POST | Verify the code; mints session, creates user if new | | `/siwe/nonce` | GET | Mint a SIWE nonce for `?address=0x…` (EIP-4361 step 1) | | `/siwe/verify` | POST | Verify EIP-191 signature; mints session keyed on wallet address | | `/passkey/register/begin` | POST | Issue a registration challenge (auth required) | | `/passkey/register/finish` | POST | Persist a new credential after the authenticator signs | | `/passkey/login/begin` | POST | Issue an assertion challenge (no auth needed) | | `/passkey/login/finish` | POST | Verify assertion → mint session | | `/passkey/keys` | GET, DELETE | List / revoke the user's passkeys | | `/.well-known/openid-configuration` | GET | OIDC discovery doc (root path, no `/api/` prefix) | | `/oidc/jwks` | GET | JWKS for verifying pylon-issued OIDC tokens | | `/scim/v2/Users` | POST, GET, DELETE | SCIM 2.0 user provisioning (Bearer `PYLON_SCIM_TOKEN`) | ## Sessions vs cookies vs bearer tokens Pylon supports both: * **Bearer tokens** — pass via `Authorization: Bearer <token>` header. Used by every SDK, native clients, and curl. Tokens are 256-bit opaque strings prefixed `pylon_`. * **Cookies** — when configured (see [Sessions](/auth/sessions)), the same token can ride in an `HttpOnly` cookie for browser apps. Set automatically on successful sign-in. Both resolve to the same `Session` server-side. There is no JWT, no signing key to rotate, and no refresh-token exchange. The tokens are opaque and you can revoke them. ## Auth context What every authenticated request gets: ```typescript theme={null} type AuthContext = { userId: string | null; // null = anonymous isAdmin: boolean; // bypasses writes; reads bypass only with no tenantId isGuest: boolean; // stable anonymous id (cart/preferences) roles: string[]; // ["admin", "owner", ...] tenantId: string | null; // active organization } ``` In a function: ```typescript theme={null} import { mutation, v } from "@pylonsync/functions"; export default mutation({ args: { title: v.string() }, async handler(ctx, args) { if (!ctx.auth.userId) throw new Error("sign in required"); if (!ctx.auth.hasRole("editor")) throw new Error("forbidden"); // ... }, }); ``` In a policy: ```jsonc theme={null} { "match": "Todo", "read": "auth.userId != null && data.authorId == auth.userId", "write": "auth.userId == data.authorId || auth.hasRole('admin')" } ``` ## Security guarantees * **Tokens are CSPRNG-generated** with 256 bits of entropy, prefixed `pylon_`. No JWT signing key to rotate. * **Magic codes** are 6-digit numeric, expire after 10 minutes, burn after 5 wrong attempts, and use constant-time comparison. * **Magic-code create** is throttled to 1 per email per minute. * **Passwords** are hashed with Argon2id (the OWASP-recommended default). * **OAuth state** is CSRF-protected via single-use tokens (10-minute expiry). * **Failed password logins** run a dummy hash so timing doesn't leak whether the email exists. * **Sessions can be persisted** to SQLite so they survive restart (see [Sessions](/auth/sessions)). * **`/api/auth/session` POST is admin-gated** in production — clients can't forge sessions. ## Configuration Minimal env (most apps): ```bash theme={null} PYLON_ADMIN_TOKEN=<64+ random hex> # required for admin endpoints PYLON_SESSION_DB=/var/lib/pylon/sessions.db # persistent sessions ``` OAuth — 25 built-in providers + any OIDC IdP. Set two env vars per provider you want to enable: ```bash theme={null} PYLON_OAUTH_GOOGLE_CLIENT_ID=... PYLON_OAUTH_GOOGLE_CLIENT_SECRET=... PYLON_OAUTH_GOOGLE_REDIRECT=https://yourapp.com/api/auth/callback/google PYLON_OAUTH_GITHUB_CLIENT_ID=... PYLON_OAUTH_GITHUB_CLIENT_SECRET=... PYLON_OAUTH_GITHUB_REDIRECT=https://yourapp.com/api/auth/callback/github # Apple, Microsoft, Discord, Slack, Spotify, Twitch, Twitter/X, # LinkedIn, Facebook, GitLab, Reddit, Notion, Linear, Vercel, Zoom, # Salesforce, Atlassian, Figma, Dropbox, TikTok, PayPal, Kick, Roblox # — same shape: <PROVIDER>_CLIENT_ID + _CLIENT_SECRET + _REDIRECT. # Apple needs ES256 signing material: PYLON_OAUTH_APPLE_TEAM_ID=ABCDE12345 PYLON_OAUTH_APPLE_KEY_ID=KEYID12345 PYLON_OAUTH_APPLE_PRIVATE_KEY=/path/to/AuthKey.p8 # Generic OIDC (Auth0, Okta, Keycloak, Cognito, …): PYLON_OAUTH_AUTH0_OIDC_ISSUER=https://acme.auth0.com PYLON_OAUTH_AUTH0_CLIENT_ID=... PYLON_OAUTH_AUTH0_CLIENT_SECRET=... ``` See [OAuth providers](/auth/oauth) for the full list and provider-specific notes. Email (for magic codes — picks up from environment): ```bash theme={null} PYLON_EMAIL_PROVIDER=stack0 # or sendgrid | resend | webhook PYLON_EMAIL_API_KEY=sk_live_... PYLON_EMAIL_FROM=noreply@yourdomain.com ``` Auth flows (magic codes, password reset, invitations) fall back to this `PYLON_EMAIL_*` provider, so one config covers both auth and your app's own `ctx.email`. To give auth its own sending identity (separate key, separate from-address, separate sending domain), set the `PYLON_AUTH_EMAIL_*` family. It takes precedence for auth email only: ```bash theme={null} PYLON_AUTH_EMAIL_PROVIDER=stack0 PYLON_AUTH_EMAIL_API_KEY=sk_live_... PYLON_AUTH_EMAIL_FROM=noreply@auth.yourdomain.com ``` When `PYLON_AUTH_EMAIL_*` is set, app code's `ctx.email` does not see it. It reads only `PYLON_EMAIL_*`. That separation lets a host back auth email with a shared, locked-down key (for example, on a dedicated sending subdomain) without exposing it to app sends. Pylon Cloud configures auth email this way. Cookie auth (browser apps): ```bash theme={null} PYLON_COOKIE_DOMAIN=.yourdomain.com PYLON_COOKIE_SAME_SITE=lax # lax | strict | none (none forces Secure) PYLON_COOKIE_SECURE=true # forces HTTPS-only cookies ``` ## Where to go next ### Sign-in methods * **[Magic codes](/auth/magic-codes)** — the simplest sign-in flow; what most apps should use first * **[Password](/auth/password)** — email + password with Argon2 hashing * **[OAuth](/auth/oauth)** — Google + GitHub + 23 more built-in providers * **[Passkeys](/auth/passkeys)** — phishing-resistant FIDO2 / WebAuthn * **[Phone / SMS](/auth/phone)** — E.164 sign-in via Twilio (or any custom SmsSender) * **[SIWE](/auth/siwe)** — Sign-In With Ethereum, EIP-4361 * **[SSO](/auth/sso)** — per-org OIDC + SAML, members sign in through their IdP ### Sessions + tokens * **[Sessions](/auth/sessions)** — token lifetime, refresh, revoke, persistent storage, cookies * **[API keys](/auth/api-keys)** — long-lived keys for server-to-server calls * **[JWT sessions](/auth/jwt)** — stateless `Bearer <jwt>` mode for microservices ### Account features * **[RBAC](/auth/rbac)** — roles, the active tenant, multi-org apps * **[Organizations](/auth/organizations)** — multi-tenant orgs, invites, member management * **[TOTP / 2FA](/auth/totp)** — RFC 6238 + backup codes * **[Trusted devices](/auth/trusted-devices)** — remember-this-browser cookie * **[Email verification](/auth/email-verification)** — confirm a user owns their email * **[Account deletion](/auth/account)** — GDPR-style hard delete ### Hardening + integrations * **[CAPTCHA](/auth/captcha)** — gate sign-in endpoints with hCaptcha / Turnstile / reCAPTCHA * **[Stripe billing](/auth/billing)** — hosted Checkout + signed webhooks * **[SCIM 2.0](/auth/scim)** — Okta / Azure AD user provisioning * **[OIDC provider](/auth/oidc-provider)** — Pylon as IdP for other systems # Passkeys / WebAuthn Source: https://docs.pylonsync.com/auth/passkeys FIDO2 / WebAuthn passkeys with ES256 + Ed25519 signature verification, counter-regression detection, and built-in challenge persistence. Passkeys (FIDO2 / WebAuthn) are phishing-resistant credentials backed by the user's device's secure enclave or hardware key. Pylon ships a complete WebAuthn server implementation in the binary: challenge mint, signature verification (ES256 + Ed25519), counter-regression detection, and key management endpoints. You do not add `@simplewebauthn` or `webauthn-rs`. ## Supported algorithms The verify path supports the two algorithms every shipping authenticator implements: * **ES256** (`alg=-7`) — ECDSA P-256, the default for Apple / iCloud Keychain, 1Password, most YubiKeys. * **Ed25519** (`alg=-8`) — Edwards-curve, used by newer Linux + Android authenticators. RS256, EdDSA curve negotiation beyond Ed25519, and the attestation chain are deliberately not implemented. Every authenticator in practical use signs with one of the two supported algorithms. Pylon verifies the assertion path that sign-in needs. ## Endpoints All under `/api/auth/`: | Endpoint | Method | Auth | Purpose | | -------------------------- | ------ | ------- | ------------------------------------------------------ | | `/passkey/register/begin` | POST | session | Issue a registration challenge | | `/passkey/register/finish` | POST | session | Persist a new credential after the authenticator signs | | `/passkey/login/begin` | POST | none | Issue an assertion challenge | | `/passkey/login/finish` | POST | none | Verify the assertion → mint session | | `/passkey/keys` | GET | session | List the caller's passkeys | | `/passkey/keys/:id` | DELETE | session | Revoke a passkey by id | ## Registering a passkey The user must already be signed in (this is a credential add for an existing account). ```javascript theme={null} // 1. Browser asks Pylon for a challenge. const begin = await fetch("/api/auth/passkey/register/begin", { method: "POST", headers: { Authorization: `Bearer ${session}` }, }); const { challenge, rpId, userId, userName } = await begin.json(); // 2. Browser calls navigator.credentials.create() with the challenge. const credential = await navigator.credentials.create({ publicKey: { rp: { name: "Your App", id: rpId }, user: { id: new TextEncoder().encode(userId), name: userName, displayName: userName, }, challenge: Uint8Array.from(atob(challenge), c => c.charCodeAt(0)), pubKeyCredParams: [ { alg: -7, type: "public-key" }, // ES256 { alg: -8, type: "public-key" }, // Ed25519 ], authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }, }, }); // 3. Send the credential back to Pylon. Pylon parses the // attestationObject server-side (CBOR), extracts the // credentialId + COSE public key from the attested credential // data, and verifies the rpId hash + flags. Don't pre-decode // on the client — the route trusts only the raw bytes the // authenticator emitted. await fetch("/api/auth/passkey/register/finish", { method: "POST", headers: { Authorization: `Bearer ${session}`, "Content-Type": "application/json" }, body: JSON.stringify({ clientDataJSON: base64url(credential.response.clientDataJSON), attestationObject: base64url(credential.response.attestationObject), name: "MacBook Pro Touch ID", // optional, for the user's "your passkeys" list }), }); ``` The `challenge` is single-use. `verify_registration` consumes the matching record from the PasskeyStore on every call (success or failure), so a replay attempt gets `401 PASSKEY_REGISTER_FAILED`. Pylon accepts only `fmt = "none"` attestation (the passkey default that Touch ID, Face ID, Windows Hello, and 1Password use). It rejects `packed`, `tpm`, and `android-key` because verifying them needs a FIDO MDS trust store that Pylon does not ship. ## Signing in with a passkey ```javascript theme={null} // 1. Pylon mints an assertion challenge (no session required). const begin = await fetch("/api/auth/passkey/login/begin", { method: "POST" }); const { challenge, rpId } = await begin.json(); // 2. Browser asks the authenticator to sign. const assertion = await navigator.credentials.get({ publicKey: { challenge: Uint8Array.from(atob(challenge), c => c.charCodeAt(0)), rpId, userVerification: "preferred", }, }); // 3. Send the assertion to Pylon. const r = await fetch("/api/auth/passkey/login/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId: base64url(assertion.rawId), authenticatorData: base64url(assertion.response.authenticatorData), clientDataJSON: base64url(assertion.response.clientDataJSON), signature: base64url(assertion.response.signature), }), }); // Response: { token, user_id, expires_at } const { token, user_id, expires_at } = await r.json(); ``` Pylon verifies the assertion server-side: 1. Decode `clientDataJSON` and confirm `origin == PYLON_WEBAUTHN_ORIGIN` and `type == "webauthn.get"`. 2. Confirm the `rpIdHash` in `authenticatorData` matches SHA-256(`PYLON_WEBAUTHN_RP_ID`). 3. Recompute the signed payload (`authenticatorData || SHA-256(clientDataJSON)`). 4. ECDSA-P256 or Ed25519 verify against the stored public key. 5. Counter-regression check: the new sign count must be strictly greater than the stored value (with 0 → 0 as the only allowed equality, for authenticators that don't increment). 6. Update `sign_count` + `last_used_at`; mint a session. Failure modes: | Status | Code | Reason | | ------ | ----------------------- | --------------------------------------------------------------------------------------- | | 401 | `PASSKEY_VERIFY_FAILED` | bad challenge, bad signature, wrong origin/rpId, counter regression, unknown credential | ## Counter regression WebAuthn authenticators increment a 32-bit counter on every signature. A clone of an authenticator (or a leaked private key) replays an old counter value. Pylon's verify path rejects any assertion whose `sign_count` is less than or equal to the stored value. One exception: authenticators that do not implement counters keep emitting `0`, which is permitted as long as the stored value is also `0`. When a regression fires, the assertion is rejected with `PASSKEY_VERIFY_FAILED`. The right operational response is to revoke that credential and have the user re-register. ## Listing + revoking keys ```bash theme={null} curl https://your-app/api/auth/passkey/keys \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} [ { "id": "cred_a1b2...", "name": "MacBook Pro Touch ID", "created_at": 1735000000, "last_used_at": 1735100000 } ] ``` Revoke: ```bash theme={null} curl -X DELETE https://your-app/api/auth/passkey/keys/cred_a1b2 \ -H 'Authorization: Bearer pylon_token' ``` Only the credential's owner can revoke it (caller's `user_id` must match the stored passkey's `user_id`). ## Configuration ```bash theme={null} PYLON_WEBAUTHN_RP_ID=your-app.com # registrable domain — must match Origin's host PYLON_WEBAUTHN_ORIGIN=https://your-app.com # full origin clients sign against ``` Defaults are `localhost` / `https://localhost` for dev. Production must set both. A mismatch between `rpIdHash` in the assertion and the configured `RP_ID` rejects every login with `PASSKEY_VERIFY_FAILED`. For subdomain apps, set `RP_ID` to the parent. Set `example.com` so credentials work across `app.example.com` and `admin.example.com`. Browsers enforce the same-registrable-domain rule client-side. ## Composing the verify path If you need a custom passkey flow (e.g., step-up auth for a specific high-value action), import the building block directly: ```rust theme={null} use pylon_auth::webauthn::{verify_assertion, AssertionInput}; let key = verify_assertion( passkey_store, &AssertionInput { credential_id: &cred_id, authenticator_data: &auth_data, client_data_json: &client_data, signature: &sig, user_handle: None, }, "https://your-app.com", "your-app.com", None, // expected_user_id — pass Some("usr_…") to bind the assertion to one user )?; ``` `verify_assertion` does the full ES256 / Ed25519 verify, counter check, and metadata update. The `pylon-auth` crate is `Send + Sync`, so you can call it from any handler. ## Where to go next * **[TOTP / 2FA](/auth/totp)** — software-token 2FA for accounts without a passkey * **[Trusted devices](/auth/trusted-devices)** — remember-this-browser cookie that skips the second factor on re-login * **[Sessions](/auth/sessions)** — what `/login/finish` mints # Email + password Source: https://docs.pylonsync.com/auth/password Argon2id-hashed credentials with built-in registration, login, and timing-attack defenses. Some apps need a password flow: shared devices, no email access, or regulatory requirements. Pylon ships built-in email + password auth. Passwords are hashed with Argon2id (the OWASP-recommended algorithm). Failed logins run a dummy hash so timing does not leak whether the email exists. ## Register ```bash theme={null} curl -X POST https://your-app/api/auth/password/register \ -H 'Content-Type: application/json' \ -d '{ "email": "alice@example.com", "password": "correct-horse-battery-staple", "displayName": "Alice" }' ``` Response (201): ```json theme={null} { "token": "pylon_a1b2c3...", "user_id": "usr_xyz", "expires_at": 1735689600 } ``` The user is created and signed in, with no separate "verify your email" step. If you need email verification, call `/api/auth/email/send-verification` after sign-in. The user can keep using the app while their email is unverified. Gate sensitive flows on `User.emailVerified`. ### Validation rules | Rule | Enforcement | | ----------------------------- | ------------------------- | | Email contains `@` | 400 `INVALID_EMAIL` | | Email is lowercased + trimmed | Server-side normalization | | Password ≥ 8 characters | 400 `WEAK_PASSWORD` | | Email not already registered | 409 `EMAIL_TAKEN` | `displayName` defaults to the email if omitted. ## Log in ```bash theme={null} curl -X POST https://your-app/api/auth/password/login \ -H 'Content-Type: application/json' \ -d '{"email": "alice@example.com", "password": "correct-horse-battery-staple"}' ``` Response (200): ```json theme={null} { "token": "pylon_a1b2c3...", "user_id": "usr_xyz", "expires_at": 1735689600 } ``` Wrong credentials always return the same shape: ```json theme={null} { "error": { "code": "INVALID_CREDENTIALS", "message": "Email or password is incorrect" } } ``` The error message intentionally does not say which one is wrong. The server runs the password hash check even when the email does not exist, so timing analysis cannot enumerate accounts. ## From the SDKs <CodeGroup> ```typescript TypeScript theme={null} import { configureClient } from "@pylonsync/react"; configureClient({ baseUrl: "https://your-app" }); // Register const reg = await fetch("/api/auth/password/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, displayName: name }), }).then(r => r.json()); // Login const login = await fetch("/api/auth/password/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }).then(r => r.json()); ``` ```swift Swift theme={null} import PylonClient let client = PylonClient(baseURL: URL(string: "https://your-app")!) let session = try await client.signInWithPassword( email: "alice@example.com", password: "correct-horse-battery-staple" ) // token is persisted automatically ``` </CodeGroup> ## Password hashing Pylon uses [`argon2`](https://crates.io/crates/argon2) with the Argon2id variant, the winner of the Password Hashing Competition and the OWASP recommendation. It resists both side-channel attacks (Argon2i) and GPU-based brute-force (Argon2d) by design. Default parameters: | Parameter | Value | Why | | ----------- | ------------ | --------------------------------------- | | Memory cost | 19 MiB | OWASP minimum for interactive logins | | Time cost | 2 iterations | Balances login latency vs attacker cost | | Parallelism | 1 | Server-side default | | Salt | 16 bytes | CSPRNG-generated per password | Each hash is self-describing. The algorithm parameters are stored in the hash string, so future Pylon versions can rotate them without breaking existing passwords. ## Password reset Pylon ships a built-in "forgot password" flow. Two endpoints email a single-use reset link and change the password after the user clicks it: **1. Request a reset link.** The user submits their email: ```bash theme={null} curl -X POST https://your-app/api/auth/password/reset/request \ -H 'Content-Type: application/json' \ -d '{"email": "alice@example.com"}' ``` Always returns `200 { "sent": true }`, whether or not the email is registered. The endpoint is rate-limited and equalizes response timing across the registered and not-registered paths, so it cannot be used to enumerate accounts. When the email exists, Pylon emails a link of the form `<public-url>/reset-password?token=<token>`. **2. Complete the reset.** Your `/reset-password` page reads the `token` from the URL and POSTs it with the new password: ```bash theme={null} curl -X POST https://your-app/api/auth/password/reset/complete \ -H 'Content-Type: application/json' \ -d '{"token": "<token>", "newPassword": "a-new-strong-password"}' ``` The token is single-use. The new password is length- and HIBP-checked (same as register). On success Pylon updates `passwordHash`, revokes every existing session for that user, and mints a fresh one: ```json theme={null} { "reset": true, "revoked_sessions": 3, "token": "pylon_...", "user_id": "usr_xyz", "expires_at": 1735689600 } ``` | Code | Status | Meaning | | ---------------- | ------ | ---------------------------------------------- | | `MISSING_EMAIL` | 400 | No `email` on request | | `MISSING_FIELD` | 400 | `token` or `newPassword` missing on complete | | `WEAK_PASSWORD` | 400 | New password shorter than 8 chars | | `PWNED_PASSWORD` | 400 | New password found in the HIBP breach corpus | | `INVALID_TOKEN` | 401 | Reset token is wrong, expired, or already used | | `RATE_LIMITED` | 429 | Too many reset requests | If you do not want a password-reset UI, run users through the [magic-code](/auth/magic-codes) flow instead. It mints a session without a password. Then update `User.passwordHash` yourself from an authenticated action. ## Configuring the User entity Password auth expects a `User` entity with these fields (auto-created by `pylon init`): ```jsonc theme={null} { "name": "User", "fields": [ { "name": "email", "type": "string", "unique": true, "optional": false }, { "name": "displayName", "type": "string", "optional": false }, { "name": "passwordHash", "type": "string", "optional": true }, { "name": "avatarColor", "type": "string", "optional": true }, { "name": "emailVerified","type": "datetime","optional": true }, { "name": "createdAt", "type": "datetime","optional": false } ] } ``` `passwordHash` is optional because users who signed up via OAuth or magic code never had one. `emailVerified` is null until they prove control of the email. ## Security notes * **Never deserialize `AuthContext` from request body.** The Rust side intentionally doesn't derive `Deserialize` so a client can't forge `is_admin: true`. Identity comes from the session lookup, not the wire. * **`/api/auth/session` POST is gated** — only dev mode or admin token can mint a session for an arbitrary user\_id. Your registration/login endpoints are the only public ways to obtain a token. * **Sessions expire after 30 days by default** — see [Sessions](/auth/sessions) to change. * **`/login` is rate-limited by default** — the built-in per-IP/per-email limiter throttles the credential bucket (login, register, reset, TOTP verify) and returns `429 RATE_LIMITED` with a `retry_after` hint on abuse. Pylon Cloud layers additional per-IP limiting at the edge. ## When to use password vs alternatives Use password when: * Email isn't reliable (offline-first apps, regions with poor SMTP delivery) * Compliance requires it * Users explicitly prefer it Otherwise prefer magic codes (no memory load, email is verified by construction) or OAuth (zero credentials your service stores). # Phone / SMS sign-in Source: https://docs.pylonsync.com/auth/phone Sign users in with E.164-normalized phone numbers and six-digit SMS codes through Twilio or a custom sender. Phone sign-in follows the magic-code flow with SMS as the delivery channel. The user enters a phone number, Pylon sends a six-digit code, and a successful verification mints a session. Phone numbers are E.164-normalized (`+15551234567`) before storage or transport. ## Endpoints | Endpoint | Method | Auth | Purpose | | --------------------------- | ------ | ---- | ------------------------------------------------------------- | | `/api/auth/phone/send-code` | POST | none | Send a 6-digit code via SMS | | `/api/auth/phone/verify` | POST | none | Verify the code; mints session, creates user on first sign-in | The user entity needs a `phone` field and optionally `phoneVerified`: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string().optional(), phone: field.string().optional().unique(), // E.164 normalized phoneVerified: field.string().optional(), // ISO 8601 timestamp on first verify displayName: field.string(), createdAt: field.string(), }); ``` `phone` should be `unique` so two accounts can't claim the same number. ## Sending a code ```bash theme={null} curl -X POST https://your-app/api/auth/phone/send-code \ -H 'Content-Type: application/json' \ -d '{"phone": "+15551234567"}' ``` Response in production (Twilio configured, send succeeded): ```json theme={null} { "sent": true, "phone": "+15551234567" } ``` Response in dev mode OR if SMS send failed (so the user isn't blocked): ```json theme={null} { "sent": false, "phone": "+15551234567", "dev_code": "123456" } ``` The `dev_code` field is returned whenever `PYLON_DEV_MODE=true` or the SMS transport returned an error. A misconfigured Twilio account then does not lock anyone out during early development. In production with a working Twilio config, `dev_code` is omitted and only the SMS carries the code. Errors: | Status | Code | Reason | | ------ | ---------------- | --------------------------------------------------- | | 400 | `INVALID_PHONE` | Phone is not valid E.164 (`+` + 10-15 digits) | | 400 | `CAPTCHA_FAILED` | CAPTCHA gate (when configured) rejected the request | | 429 | `RATE_LIMITED` | Throttled — wait `retry_after_secs` | The code is 6 digits, expires in 10 minutes, and is rate-limited per-number to prevent SMS spam. ## Verifying ```bash theme={null} curl -X POST https://your-app/api/auth/phone/verify \ -H 'Content-Type: application/json' \ -d '{ "phone": "+15551234567", "code": "123456", "displayName": "Alice" }' ``` Response on success: ```json theme={null} { "token": "pylon_...", "user_id": "usr_xyz", "expires_at": 1737592000 } ``` `displayName` is optional. Pylon uses it when it creates a new User row because no existing row has this `phone`. On later sign-ins for the same number, `displayName` is ignored (the existing row's name stays). Errors: | Status | Code | Reason | | ------ | -------------- | -------------------------------------------- | | 401 | `INVALID_CODE` | Wrong code, expired, or already burned | | 400 | `INVALID_CODE` | Phone not valid E.164 | | 429 | `INVALID_CODE` | Too many wrong attempts — the code is burned | On first successful verify Pylon stamps `phoneVerified` to the current ISO 8601 timestamp. ## Twilio transport (built-in) The default SMS transport is Twilio. Set three env vars: ```bash theme={null} PYLON_TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxx PYLON_TWILIO_AUTH_TOKEN=your_twilio_auth_token PYLON_TWILIO_FROM=+15551234567 # your Twilio phone number ``` When all three are set, `/phone/send-code` sends an SMS via Twilio's REST API. When any are missing, `sent` is `false` and the `dev_code` is returned in the response. ## Custom SMS providers The `pylon-auth` crate exposes an `SmsSender` trait. It is the crate-level extension point for a non-Twilio provider (MessageBird, Vonage, Plivo, AWS SNS, or an internal SMS gateway): ```rust theme={null} use pylon_auth::phone::SmsSender; struct MyProvider; impl SmsSender for MyProvider { fn send_sms(&self, phone: &str, body: &str) -> Result<(), String> { // POST to your provider's API, return Ok(()) on success Ok(()) } } ``` The shipped `pylon` binary wires only the built-in Twilio transport. The `/phone/send-code` route handler calls it directly via `TwilioSmsTransport::from_env()`. The prebuilt binary has no env-var switch or registration hook to swap in a custom `SmsSender`. (`PhoneCodeStore` only generates and persists the 6-digit code. It returns the code to the caller, which delivers it; it does not hold an `SmsSender`.) To run a different provider today, build on the `pylon-auth` crate directly in your own Rust binary. Env-configurable third-party transports for the shipped binary are not yet available. ## Security guarantees * **E.164 normalization** on `phone` before storage. `(555) 123-4567`, `555-123-4567`, and `+15551234567` collapse to the same canonical form. No two accounts share a number. * **6-digit code, 10-minute TTL** — same as magic codes. * **Code burns after wrong attempts** — `try_verify` increments an attempt counter; too many wrong attempts and the code is invalidated server-side. Status `429 INVALID_CODE`. * **Constant-time code comparison** — no timing leak on verify. * **Per-number rate limit** on `send-code` so a phone-number enumeration attack cannot exhaust the SMS budget. * **Optional CAPTCHA gate** — set `PYLON_CAPTCHA_PROVIDER` to require a captcha token before send. See [CAPTCHA](/auth/captcha). * **Twilio credentials never logged.** On transport failure only the provider error is logged at warn level (`[phone] twilio send failed: <error>`). The SMS body and the code are never written to logs. ## Where to go next * **[Magic codes](/auth/magic-codes)** — email equivalent * **[CAPTCHA](/auth/captcha)** — gate `/phone/send-code` against bot networks * **[Sessions](/auth/sessions)** — what `/phone/verify` mints # Roles & multi-tenancy (RBAC) Source: https://docs.pylonsync.com/auth/rbac Roles, the active tenant, and writing row-scoped policies that compose with both. Pylon's RBAC layer keeps roles in `AuthContext.roles` and the active tenant in `AuthContext.tenant_id`. Policies reference them through `auth.hasRole(...)` and `auth.tenantId`. More complex authorization stays in policy expressions or server functions. This page covers the data model, the policy syntax, and the patterns for typical multi-tenant apps. ## Roles `AuthContext.roles` is a string array. Organization roles must be built-ins or declared in the app manifest: ```typescript theme={null} buildManifest({ // ... auth: { orgRoles: ["reviewer", "billing"] }, }); ``` Roles are typically stored on a per-user, per-tenant join table: ```jsonc theme={null} { "name": "OrgMember", "fields": [ { "name": "userId", "type": "id(User)", "optional": false }, { "name": "orgId", "type": "id(Org)", "optional": false }, { "name": "role", "type": "string", "optional": false } ], "indexes": [ { "name": "by_user_org", "fields": ["userId", "orgId"], "unique": true } ] } ``` When the user calls `/api/auth/select-org`, the runtime loads the matching `OrgMember.role` into the session so policy `auth.hasRole(...)` / `auth.hasAnyRole(...)` checks see them on every request. (There is no `auth.roles` array binding in the policy DSL. Roles are reachable only through those two functions.) ## Special roles | Name | What it does | | --------------------- | --------------------------------------------------------------------------------------------------------------------- | | `admin` admin context | Bypasses writes. Bypasses reads only with no active org (see the note). `auth.hasRole('x')` returns true for any `x`. | Organization owners and admins receive only their exact org role in `roles`. The framework org-management routes authorize those built-ins separately. Custom roles have no inheritance: a `reviewer` does not satisfy `member` or `admin`. An admin context with an active org is a special case. Its writes still bypass every policy. Its reads do not. Pylon scopes an admin read to the active org, and the read policy runs. So the admin sees only that org's rows, like a member. An admin context with no active org (an operator, `PYLON_ADMIN_TOKEN`, or Studio) bypasses reads too. ## Policy syntax Policies live in `pylon.manifest.json` under `policies`: ```jsonc theme={null} { "policies": [ { "match": "Todo", "read": "auth.userId != null", "write": "auth.userId == data.authorId || auth.hasRole('admin')", "delete":"auth.hasRole('admin')" } ] } ``` `match` is the entity name. The other fields are boolean expressions over `auth` and `data`: | Identifier | Type | Meaning | | -------------------------- | -------------- | -------------------------------------------- | | `auth.userId` | string \| null | Current user id, null if anonymous | | `auth.isAdmin` | bool | True for admin contexts | | `auth.tenantId` | string \| null | Active org id | | `auth.hasRole('x')` | fn → bool | Role check (admin returns true for any role) | | `auth.hasAnyRole('a','b')` | fn → bool | Match any of the roles | | `data.<field>` | varies | Row column value | Supported operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, parentheses, plus numeric literals and the `now` binding. Ordering compares numbers numerically and ISO-8601 strings chronologically (deny-safe otherwise). There is no arithmetic and no `in`/`ends_with`/`starts_with`. Role checks use the `auth.hasRole(...)` / `auth.hasAnyRole(...)` functions; cross-entity membership uses `exists(Entity where field == <expr> [and ...])`. String matching belongs in a function. ## Common patterns ### Row-scoped to author Only the author can read or write their row: ```jsonc theme={null} { "match": "Note", "read": "data.authorId == auth.userId", "write": "data.authorId == auth.userId" } ``` ### Row-scoped to tenant Multi-tenant apps where rows belong to an org and members of that org can access them: ```jsonc theme={null} { "match": "Project", "read": "data.orgId == auth.tenantId", "write": "data.orgId == auth.tenantId && auth.hasAnyRole('editor', 'admin')" } ``` The `tenant_id` flows in through the session. See [Sessions → Multi-tenant](/auth/sessions#multi-tenant-switching-organizations). ### Role-gated mutation Read is open to all members; write requires elevation: ```jsonc theme={null} { "match": "OrgSettings", "read": "data.orgId == auth.tenantId", "write": "data.orgId == auth.tenantId && auth.hasRole('owner')" } ``` ### Public read, owner write Common for blog posts / public profiles: ```jsonc theme={null} { "match": "Post", "read": "true", "write": "data.authorId == auth.userId" } ``` ### Soft "no one but admin" Most internal/system tables: ```jsonc theme={null} { "match": "AuditLog", "read": "auth.hasRole('admin')", "write": "auth.hasRole('admin')", "delete":"false" } ``` `"delete": "false"` blocks delete entirely. Admins can read and write but never remove. ## Where policies run Policies enforce on every entity-level operation: CRUD via `/api/entities/*`, sync push, and query reads. They do not run inside server functions. Once you are in a `mutation` or `action` handler, you have direct DB access via `ctx.db`. Use functions when a policy is too complex to express as an expression. ```typescript theme={null} // Function-level enforcement (when policies aren't enough) import { mutation } from "@pylonsync/functions"; export default mutation({ args: { todoId: v.string() }, async handler(ctx, args) { const todo = await ctx.db.get("Todo", args.todoId); if (!todo) throw new Error("not found"); // Custom policy: only authors can mark done after a deadline const isAuthor = todo.authorId === ctx.auth.userId; const isPastDeadline = Date.now() > new Date(todo.deadline).getTime(); if (isPastDeadline && !isAuthor) throw new Error("only the author can edit overdue todos"); await ctx.db.update("Todo", args.todoId, { done: true }); }, }); ``` ## Reading roles in functions Inside a `mutation` / `query` / `action`, `ctx.auth` exposes: ```typescript theme={null} ctx.auth.userId // string | null ctx.auth.isAdmin // boolean ctx.auth.tenantId // string | null ctx.auth.roles // string[] — exact active-org/session role slugs ctx.auth.elevate({ admin: true, reason: "..." }) // promote after verifying a webhook/HMAC ``` The handler `ctx.auth` carries no `hasRole` helper and no email. `roles` is useful for branching and rendering, but authorization should use the `ctx.requireMember` helper. It looks up the membership row and fails closed: ```typescript theme={null} // throws UNAUTHENTICATED / FORBIDDEN if the caller isn't an owner of this org const member = await ctx.requireMember(args.orgId, { role: "owner" }); ``` Functions bypass policies, so a mutation or action with a forgotten membership check is an IDOR. The manifest declares which role strings are valid. Assignments live on the membership table, which is what `requireMember` reads. ## Granting roles Role assignments are strings on the membership table. Prefer the built-in `PUT /api/auth/orgs/:orgId/members/:userId` endpoint, which validates them against built-ins plus `auth.orgRoles`. A custom mutation should apply the same allowlist: ```typescript theme={null} import { mutation, v } from "@pylonsync/functions"; const VALID_ROLES = new Set(["owner", "admin", "member", "reviewer"]); export default mutation({ args: { targetUserId: v.string(), role: v.string() }, async handler(ctx, args) { if (!ctx.auth.tenantId) throw new Error("must select an org first"); if (!VALID_ROLES.has(args.role)) { throw ctx.error("INVALID_ARGS", "role is not declared by this app"); } // Gate authorization on the current membership row. // requireMember reads OrgMember and throws FORBIDDEN if the caller isn't an owner. await ctx.requireMember(ctx.auth.tenantId, { role: "owner" }); const existing = await ctx.db.query("OrgMember", { where: { userId: args.targetUserId, orgId: ctx.auth.tenantId }, }); if (existing.length === 0) throw new Error("not a member of this org"); await ctx.db.update("OrgMember", existing[0].id, { role: args.role }); }, }); ``` ## Extending beyond roles For finer-grained access, build a permission table and check it in policies with `exists(Permission where todoId == existing.id and userId == auth.userId)`. Keep complex rules in a server function when they no longer read clearly as policy expressions. ## Admin token `PYLON_ADMIN_TOKEN` is set in the environment. A request that passes it as `Authorization: Bearer <token>` resolves to `AuthContext::admin()`: `isAdmin: true`, and every `hasRole(...)` returns true. This context has no active org, so it bypasses every policy, including reads. This is for: * Migrations / backfills (`pylon migrate apply`) * Studio (the inspector) * CLI commands (`pylon export`, `pylon backup`) * Server-to-server calls between trusted services Treat the admin token like a root password. Rotate it quarterly. See [Token rotation](/operations/token-rotation). ## Testing policies Pylon runs policies in the same evaluator your tests can use: ```typescript theme={null} import { evalPolicy } from "@pylonsync/sdk/test"; const allowed = evalPolicy( "auth.userId == data.authorId", { auth: { userId: "u1", isAdmin: false, roles: [] }, data: { authorId: "u1" } } ); // → true ``` Or assert via the HTTP layer in integration tests: ```typescript theme={null} test("non-author can't edit", async () => { const session = await signInAs("u2"); const res = await fetch(`/api/entities/Todo/${todoId}`, { method: "PATCH", headers: { Authorization: `Bearer ${session.token}` }, body: JSON.stringify({ title: "hacked" }), }); expect(res.status).toBe(403); }); ``` ## Recipes * **Personal apps** — single-user, no roles, no policies. Just `auth.userId != null`. * **SaaS** — tenant\_id everywhere, roles on `OrgMember`, policies match `data.orgId == auth.tenantId`. * **Forum / community** — `auth.hasRole('moderator')` for soft-delete and ban actions. * **Marketplaces** — separate `Buyer` and `Seller` roles, policies check both. * **Internal tools** — single `admin` role, broad gates, audit log via the `audit_log` plugin. # SCIM 2.0 Source: https://docs.pylonsync.com/auth/scim Provision Pylon users from Okta, Azure AD, and other identity providers through RFC 7644 SCIM. SCIM 2.0 is the standard protocol for an IdP to create and deactivate users in your app automatically. Okta, Azure AD, OneLogin, JumpCloud, and others support it. Pylon ships a minimal SCIM 2.0 server in the binary: a `Users` endpoint, bearer-token gated, with soft-delete on `DELETE`. ## What's implemented The implementation covers user provisioning and the `userName eq "..."` filter that identity providers use for lookup. It does not yet cover Groups, Bulk, or the full SCIM filter grammar. It supports the standard Okta and Azure AD provisioning flow for creating and deactivating users. | Endpoint | Method | Behavior | | -------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/scim/v2/ServiceProviderConfig` | GET | Capabilities (patch supported, filter supported, no bulk, no etag, etc.) | | `/scim/v2/ResourceTypes` | GET | Lists the single supported resource type (`User`) | | `/scim/v2/Schemas` | GET | The User schema | | `/scim/v2/Users` | POST | Create a User from the SCIM-shaped payload | | `/scim/v2/Users` | GET | List all users, optionally filtered by `?filter=userName eq "..."` (case-insensitive per RFC 7643) | | `/scim/v2/Users/:id` | GET | Get a User by id | | `/scim/v2/Users/:id` | PATCH | Partial update; supports paths `userName`, `displayName`, `name.formatted`, `active`, `externalId`. Atomic — any unsupported op fails the whole request (no silent partial application) | | `/scim/v2/Users/:id` | PUT | Full-replace | | `/scim/v2/Users/:id` | DELETE | Soft-delete (sets `scimActive: false`); returns 204 | Note these are mounted at the **root path** `/scim/v2/...`, NOT under `/api/auth/`. Most IdPs expect SCIM at a top-level path. Array-filter PATCH paths (`emails[primary eq true].value`) are explicitly rejected — IdPs that need them should fall back to the equivalent PUT request. ## Schema The user entity needs three SCIM-shaped fields: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string().unique(), displayName: field.string().optional(), scimId: field.string().optional(), // IdP's id for this user scimActive: field.bool().optional(), // true on create; false on SCIM DELETE scimExternalId: field.string().optional(), // PATCH `externalId` writes here (Okta/Azure send it) createdAt: field.string(), }); ``` `scimActive=false` is the soft-delete signal. Your app code should refuse sign-in or hide the user when this flag is false. Pylon does not auto-revoke sessions on SCIM deactivate. If you want that behavior, watch the User entity for updates and revoke matching sessions in a plugin or scheduled job. ## Authentication Bearer-token gated: ```bash theme={null} Authorization: Bearer <PYLON_SCIM_TOKEN> ``` Token comparison is constant-time. Without `PYLON_SCIM_TOKEN` set, the env-var pull returns `None` and `check_bearer` returns false for every token. Every SCIM request gets `401`. ```bash theme={null} PYLON_SCIM_TOKEN=<generate a high-entropy token, e.g. `openssl rand -hex 32`> ``` ## Creating a user Okta and other IdPs POST a payload conforming to RFC 7643: ```bash theme={null} curl -X POST https://your-app/scim/v2/Users \ -H 'Authorization: Bearer <PYLON_SCIM_TOKEN>' \ -H 'Content-Type: application/json' \ -d '{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "alice@acme.com", "name": { "givenName": "Alice", "familyName": "Smith" }, "emails": [{ "value": "alice@acme.com", "primary": true }], "active": true }' ``` Response (201 Created): ```json theme={null} { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "usr_xyz", "userName": "alice@acme.com", "active": true, ... } ``` Pylon maps: * `userName` → no direct mapping; primary email goes to `email` * `emails[primary=true].value` → `email` * `displayName` or `name.givenName + name.familyName` → `displayName` * `id` → `scimId` (your IdP's stable id for the user) * `active` → `scimActive` Duplicate emails return `409` with SCIM-shaped error JSON. ## Listing users ```bash theme={null} curl https://your-app/scim/v2/Users \ -H 'Authorization: Bearer <PYLON_SCIM_TOKEN>' ``` Response: ```json theme={null} { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": 42, "Resources": [ ...ScimUser... ] } ``` Only the `?filter=userName eq "..."` probe is supported (case-insensitive per RFC 7643). Pagination (`?startIndex=&count=`) is not yet implemented. An unfiltered list returns every row, bounded by your User table size. If you have 50k+ users provisioned via SCIM, plan accordingly. ## Soft delete ```bash theme={null} curl -X DELETE https://your-app/scim/v2/Users/usr_xyz \ -H 'Authorization: Bearer <PYLON_SCIM_TOKEN>' ``` Returns `204 No Content`. The row stays in the DB with `scimActive: false`. Hard delete is your app's decision, typically a periodic job that hard-deletes rows that have been `scimActive: false` for N days. ## Security guarantees * **Bearer-token gated** with constant-time compare against `PYLON_SCIM_TOKEN`. * **Missing env var = 401 for every request** — fail closed, no silent-permissive mode. * **SCIM-shaped error responses** (RFC 7644 §3.12) — `schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"]`, `status`, `detail`. ## Configuration ```bash theme={null} PYLON_SCIM_TOKEN=<openssl rand -hex 32> # required to enable SCIM ``` Set this on the Pylon side, then paste it into your IdP's SCIM provisioning config alongside `https://your-app.com/scim/v2/`. The IdP discovers the endpoint by probing. It sends a few test requests, sees SCIM-shaped responses, and confirms. ## Where to go next * **[SSO](/auth/sso)** — per-org OIDC + SAML, the sign-in side of the same IdP integration * **[OIDC Provider](/auth/oidc-provider)** — Pylon as IdP for other systems * **[API keys](/auth/api-keys)** — a different "server-to-server" token shape for app integrations # Sessions Source: https://docs.pylonsync.com/auth/sessions How Pylon manages opaque tokens, refresh, revocation, persistent storage, and cookies. A Pylon session is an opaque 256-bit token (prefixed `pylon_`) that maps to a `Session { token, user_id, expires_at, device, created_at, tenant_id }`. Bearer headers and `HttpOnly` cookies both resolve through the same `SessionStore`. Because session tokens are opaque and server-resolved, they need no signing key and can be revoked immediately. ## Session shape ```typescript theme={null} type Session = { token: string; // pylon_<64 hex chars> — 256 bits of CSPRNG entropy user_id: string; // points at the User row expires_at: number; // unix epoch seconds; 0 = never expires device?: string; // optional user-agent / device label created_at: number; // unix epoch seconds tenant_id?: string; // active organization (multi-tenant apps) } ``` ## Defaults | Setting | Default | | --------------- | ------------------------ | | Lifetime | 30 days | | Token entropy | 256 bits (CSPRNG) | | Storage | In-memory | | Cookie HttpOnly | Yes when cookies enabled | | Cookie SameSite | `lax` | | Cookie Secure | `true` in non-dev mode | Sessions live in memory by default. That is fine for development, but users get logged out on every restart. For production, point Pylon at a SQLite file: ```bash theme={null} PYLON_SESSION_DB=/var/lib/pylon/sessions.db ``` Now sessions survive restarts, deploys, and crashes. The SQLite file is a write-through cache: reads still hit memory, and every save or remove writes to disk. On **Pylon Cloud**, persistent sessions are configured automatically. ## Refresh Rotate a session's token without breaking the user's sign-in: ```bash theme={null} curl -X POST https://your-app/api/auth/refresh \ -H 'Authorization: Bearer pylon_old_token' ``` Response: ```json theme={null} { "token": "pylon_new_token", "user_id": "usr_xyz", "expires_at": 1735689600 } ``` The old token is revoked; the new token has a fresh 30-day lifetime. Use this on long-running clients to keep sessions alive (the Swift SDK has `startSessionAutoRefresh(intervalSeconds:)` that does this automatically). ## Revoke ### Sign out the current device ```bash theme={null} curl -X DELETE https://your-app/api/auth/session \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "revoked": true } ``` Also clears the auth cookie (sets `Set-Cookie` with an expired value). ### Sign out everywhere ```bash theme={null} curl -X DELETE https://your-app/api/auth/sessions \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "revoked_count": 4 } ``` Useful when a user changes their password or you suspect account compromise. ### List active sessions ```bash theme={null} curl https://your-app/api/auth/sessions \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} [ { "token_prefix": "pylon_a1", "user_id": "usr_xyz", "device": "iPhone 15 / iOS 17", "created_at": 1735000000, "expires_at": 1737592000 } ] ``` The full token is never returned. The response shows only the first 8 chars for display. Power users can audit active sign-ins and revoke individual ones. ## Cookies vs bearer tokens Pylon supports both transports for the same `Session`. Pick based on client type: | Transport | When | | ------------------- | ---------------------------------------------------------------------- | | **Bearer header** | SPAs, native apps, server-to-server, curl | | **HttpOnly cookie** | Multi-page web apps, server-rendered apps where XSS resistance matters | To enable cookie auth: ```bash theme={null} PYLON_COOKIE_DOMAIN=.yourdomain.com # subdomain-shared if leading dot PYLON_COOKIE_SAME_SITE=lax # lax | strict | none (none forces Secure) PYLON_COOKIE_SECURE=true # forces HTTPS-only (default in non-dev) ``` The cookie name is `pylon_session`. It's automatically set on: * `/api/auth/magic/verify` success * `/api/auth/password/login` success * `/api/auth/password/register` success * `/api/auth/callback/:provider` success (both GET and POST) And cleared on `/api/auth/session` DELETE. When both a cookie and an `Authorization: Bearer` header are present on the same request, the bearer header wins. ## Multi-tenant: switching organizations For apps with workspaces/orgs, attach a `tenant_id` to the session so policies like `data.orgId == auth.tenantId` can run automatically: ```bash theme={null} curl -X POST https://your-app/api/auth/select-org \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"orgId": "org_xyz"}' ``` The server verifies membership before committing. It looks up an `OrgMember { userId, orgId }` row and returns 403 `NOT_A_MEMBER` if it does not exist. Clients cannot impersonate an org they do not belong to. Pass `null` to leave the org (drop back to the lobby): ```bash theme={null} curl -X POST https://your-app/api/auth/select-org \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"orgId": null}' ``` After `select-org`, every request resolves to an `AuthContext` with `tenantId` set, and your row-scoped policies see it as `auth.tenantId`. ## Guest sessions For pre-login state (cart contents, theme preference, anonymous draft), mint a guest session: ```bash theme={null} curl -X POST https://your-app/api/auth/guest ``` Response: ```json theme={null} { "token": "pylon_...", "user_id": "guest_a1b2c3d4...", "guest": true } ``` Guests have a stable `user_id` (so their cart persists across page loads), but `is_authenticated()` returns false. `AuthMode::User` rejects them, so guests cannot access user-only routes. When the user signs in for real, upgrade the guest session in place: ```typescript theme={null} // Sign-in flow that preserves the guest's cart: import { mutation } from "@pylonsync/functions"; export default mutation({ args: { realUserId: v.string() }, async handler(ctx, args) { if (!ctx.auth.isGuest) throw new Error("not a guest session"); // Move the cart from the guest user_id to the real user_id const cart = await ctx.db.query("CartItem", { where: { userId: ctx.auth.userId }, }); for (const item of cart) { await ctx.db.update("CartItem", item.id, { userId: args.realUserId }); } }, }); ``` The session token stays the same — the client doesn't need to re-store it. `/api/auth/upgrade` is admin-gated and exists for backfill scripts; normal upgrade should flow through magic-code verify or OAuth callback, which mint a fresh user session and consume the guest token. ## Programmatic session creation (admin only) In dev mode or with admin auth, mint a session for any user: ```bash theme={null} curl -X POST https://your-app/api/auth/session \ -H 'Authorization: Bearer <PYLON_ADMIN_TOKEN>' \ -H 'Content-Type: application/json' \ -d '{"user_id": "usr_xyz"}' ``` This is the admin-only path. Never expose it without admin auth. In non-dev mode, non-admin requests get 403 `FORBIDDEN`. Use it for: * Backfill scripts that need to sign in as a user * Tests * Impersonation features for support agents (gate on `auth.hasRole('support')` in your wrapper) ## Sweep expired sessions Background cleanup runs automatically — every authenticated request checks `expires_at` and removes the session if expired. For the SQLite-backed store, you can also trigger an explicit sweep: ```typescript theme={null} // In a scheduled job sessionStore.sweepExpired(); ``` Cloud runs this hourly. Self-hosted, the on-demand check is usually enough. Leaving expired rows in the table briefly does no harm. ## Session storage backends The `SessionStore` accepts a pluggable `SessionBackend`: ```rust theme={null} trait SessionBackend: Send + Sync { fn load_all(&self) -> Vec<Session>; fn save(&self, session: &Session); fn remove(&self, token: &str); } ``` Pylon ships: * **In-memory** — default; lost on restart * **SQLite** — `PYLON_SESSION_DB=path` enables it Custom backends (Redis, DynamoDB, etc.) are a few lines of Rust. Implement the trait and pass it via `SessionStore::with_backend`. See `crates/runtime/src/session_backend.rs` for the SQLite reference impl. ## Security defaults * **Tokens are 256-bit CSPRNG** — un-guessable * **Constant-time token lookup** — no timing leak on session resolution * **`HttpOnly` cookies** — JS can't read the cookie via XSS * **`Secure` cookies in non-dev** — refused over plain HTTP * **`SameSite=lax`** — CSRF-resistant by default; switch to `strict` if you don't have cross-site sign-in flows * **Sessions can be revoked individually or en masse** — no JWT-style "until expiry, can't kill" problem * **`/me` returns the runtime-resolved context, not a fresh DB lookup** — admin-token requests show as admin even though they don't have a session row # SIWE (Sign-In With Ethereum) Source: https://docs.pylonsync.com/auth/siwe Verify EIP-4361 wallet sign-in with secp256k1 ECDSA and Keccak-256 recovery. Pylon ships a complete [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) (Sign-In With Ethereum) implementation in the binary. Users prove ownership of their Ethereum address by signing a structured message. Pylon recovers the address from the signature and mints a session. You do not need Magic.link, a WalletConnect server, or a third-party SIWE service. ## What it does 1. Frontend asks Pylon for a fresh nonce for the user's address. 2. Frontend builds an EIP-4361 message (`Sign in to acme.com\n\nAddress: 0x...\n...nonce: ...\n...`) and asks the user's wallet to sign it via `personal_sign`. 3. Pylon recovers the address from the signature using secp256k1 ECDSA and Keccak-256 (Ethereum's variant). It validates the message's domain, nonce, and expiry, then mints a session keyed on the wallet. ## Endpoints | Endpoint | Method | Auth | Purpose | | ----------------------- | ------ | ---- | -------------------------------------------- | | `/api/auth/siwe/nonce` | GET | none | Mint a single-use nonce for `?address=0x...` | | `/api/auth/siwe/verify` | POST | none | Verify the signed message and mint a session | ## Schema The user entity needs a `walletAddress` field: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string().optional(), walletAddress: field.string().optional().unique(), // 0x + 40 hex chars, lowercase displayName: field.string(), createdAt: field.string(), }); ``` The `unique` constraint stops two accounts from claiming the same address. ## Sign-in flow ### 1. Request a nonce ```bash theme={null} curl 'https://your-app/api/auth/siwe/nonce?address=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0' ``` Response: ```json theme={null} { "nonce": "abc123def456..." } ``` The nonce is bound to the address. Using a nonce minted for one address against a different address fails verification. The nonce is single-use and short-lived. Errors: | Status | Code | Reason | | ------ | ----------------- | ------------------------------- | | 400 | `INVALID_ADDRESS` | Not `0x` + exactly 40 hex chars | ### 2. Build and sign the message The frontend constructs an EIP-4361 message and asks the wallet to sign it (MetaMask, WalletConnect, Coinbase Wallet, etc.): ```javascript theme={null} import { SiweMessage } from "siwe"; // npm install siwe const { nonce } = await fetch( `/api/auth/siwe/nonce?address=${address}`, ).then((r) => r.json()); const message = new SiweMessage({ domain: window.location.host, // must match Pylon's Host header address, statement: "Sign in to Acme", uri: window.location.origin, version: "1", chainId: 1, nonce, issuedAt: new Date().toISOString(), }); const prepared = message.prepareMessage(); const signature = await ethereum.request({ method: "personal_sign", params: [prepared, address], }); ``` ### 3. Verify ```bash theme={null} curl -X POST https://your-app/api/auth/siwe/verify \ -H 'Content-Type: application/json' \ -d '{ "message": "<EIP-4361 plaintext message>", "signature": "0x...", "displayName": "vitalik.eth" }' ``` Response on success: ```json theme={null} { "token": "pylon_...", "user_id": "usr_xyz", "address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb0", "expires_at": 1737592000 } ``` `displayName` is optional. Pylon uses it only when it creates a new User row for a first-time address. The default is the short form `0x742d…beb0`. Errors: | Status | Code | Reason | | ------ | -------------------- | ----------------------------------------------------------------------------------------------------- | | 400 | `SIWE_BAD_MESSAGE` | EIP-4361 message couldn't be parsed | | 401 | `SIWE_VERIFY_FAILED` | Signature didn't recover to the message's address, nonce unknown / consumed, domain mismatch, expired | ## Verification details Pylon validates: * **Signature recovery:** secp256k1 ECDSA and a Keccak-256 hash of the EIP-191 prefix (`\x19Ethereum Signed Message:\n<len>`) followed by the message recover a 20-byte address. It must match `message.address`, case-insensitive. * **Nonce:** the nonce must exist in the nonce store, bind to `message.address`, and be single-use. Pylon consumes it on first verify. * **Domain:** `message.domain` must match the request's `Host` header. This stops replay across deployments. * **Issued and expiration:** if `message.expirationTime` is set, it must be in the future. If `message.notBefore` is set, it must be in the past. Pylon lowercases the recovered address before lookup and storage. This makes checksummed `0x742d35Cc...` and lowercase `0x742d35cc...` addresses resolve to the same User row. ## Security guarantees * **secp256k1 and Keccak-256:** Pylon uses the `k256` crate, the same audited primitives Ethereum uses. * **Nonce binding to address:** using `nonce-for-A` to sign in as B fails verification. * **Single-use nonces:** Pylon consumes each nonce on first verify, regardless of success. * **Domain pinning:** Pylon validates `message.domain` against the `Host` header and rejects replay across deployments. * **Expiration enforcement:** Pylon honors `expirationTime` and `notBefore`. * **EIP-191 prefix:** Pylon applies it correctly, which defeats signature reuse from any other Ethereum-signed payload. ## Where to go next * [Sessions](/auth/sessions): what `/siwe/verify` mints. * [OAuth](/auth/oauth): other identity providers for the same user. Link multiple sign-in methods to one account. # SSO (per-org) Source: https://docs.pylonsync.com/auth/sso Let each organization configure an OIDC or SAML 2.0 identity provider for member sign-in. Per-org SSO lets each organization plug in its own identity provider (Okta, Auth0, Azure AD, Google Workspace, Keycloak, OneLogin, Ping, JumpCloud, or any OIDC-compliant provider). Members sign in by going to their org's start URL. The framework handles discovery, PKCE, nonce, state, and auto-join. Both protocols ship in the binary: * **OIDC**: a discovery document, PKCE (S256), a nonce, and single-use state. * **SAML 2.0**: an SP-initiated `AuthnRequest` over HTTP-Redirect, a Response posted to the ACS over HTTP-POST, and an XML signature verified against the configured IdP certificate. ## Trust model Org owners choose their own IdP. The framework does not question that choice. It checks that the IdP's discovery endpoints use HTTPS, but it does not restrict which providers are acceptable. Within one Pylon deployment, the first org to claim a domain owns it. The framework blocks any org from claiming a well-known freemail domain (`gmail.com`, `outlook.com`, `icloud.com`, and others). This stops an org owner from intercepting domain-detection sign-ins for every Gmail user. Operators on multi-tenant Pylon deployments should set `PYLON_SSO_ALLOWED_DOMAINS` as an allowlist. Pylon rejects any domain not on the list on `PUT /orgs/:id/sso` or `/saml`. ## Endpoints All endpoints are under `/api/auth/`. | Endpoint | Method | Auth | Purpose | | ------------------------ | ------ | ---------- | ------------------------------------------------ | | `/orgs/:id/sso` | GET | any member | Read redacted OIDC config | | `/orgs/:id/sso` | PUT | Owner | Configure OIDC SSO | | `/orgs/:id/sso` | DELETE | Owner | Remove OIDC SSO | | `/orgs/:id/sso/start` | GET | none | Begin OIDC sign-in (302 to IdP) | | `/orgs/:id/sso/callback` | GET | none | IdP redirects here with code+state | | `/orgs/:id/saml` | GET | any member | Read SAML config | | `/orgs/:id/saml` | PUT | Owner | Configure SAML | | `/orgs/:id/saml` | DELETE | Owner | Remove SAML | | `/orgs/:id/saml/start` | GET | none | Begin SAML sign-in (302 to IdP) | | `/orgs/:id/saml/acs` | POST | none | SAML ACS, where the IdP posts the SAMLResponse | | `/sso/discover` | GET | none | Resolve `?email=user@acme.com` → org's start URL | ## Configuring OIDC SSO The org's Owner posts the IdP's issuer URL and client credentials. Pylon fetches `<issuer>/.well-known/openid-configuration` and caches the four endpoints: authorization, token, userinfo, and jwks. ```bash theme={null} curl -X PUT https://your-app/api/auth/orgs/org_acme/sso \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{ "issuer_url": "https://acme.okta.com", "client_id": "0oa1abcd2efgh3ijk4", "client_secret": "OktaSecret...", "default_role": "member", "email_domains": ["acme.com", "acme.io"] }' ``` Required fields: `issuer_url`, `client_id`, and `client_secret`. Optional fields: `default_role` and `email_domains`. `default_role` is `member` or `admin`, and defaults to `member`. Pylon refuses `owner` as a default role, so an IdP misconfiguration cannot hand over org control. `email_domains` lists the domains claimed for the `/sso/discover` flow. Pylon lowercases each domain before it stores them. Pylon encrypts the `client_secret` at rest when `PYLON_SECRET` is set. Without it, Pylon falls back to a `plain:` envelope in dev mode and prints a warning at boot in production. See [Sessions](/auth/sessions) for details on at-rest encryption. Response on success: ```json theme={null} { "configured": true } ``` Errors: | Status | Code | Reason | | ------ | ------------------------ | ------------------------------------------------------------------ | | 400 | `MISSING_FIELDS` | `issuer_url` / `client_id` / `client_secret` missing | | 400 | `BAD_DEFAULT_ROLE` | `default_role: "owner"` is refused | | 400 | `DOMAIN_BLOCKLISTED` | Claimed a freemail domain | | 400 | `DOMAIN_NOT_ALLOWED` | Not in `PYLON_SSO_ALLOWED_DOMAINS` (when set) | | 400 | `DISCOVERY_FAILED` | `<issuer>/.well-known/openid-configuration` unreachable or invalid | | 409 | `DOMAIN_ALREADY_CLAIMED` | Another org already claimed one of the email domains | | 500 | `SSO_SECRET_SEAL_FAILED` | `PYLON_SECRET` is set but the ChaCha20-Poly1305 seal failed | ## OIDC sign-in flow 1. The client redirects the user to `GET /api/auth/orgs/org_acme/sso/start?callback=<success_url>&error_callback=<error_url>`. 2. Pylon validates both URLs against `manifest.auth.trustedOrigins` (or `PYLON_TRUSTED_ORIGINS`). It mints a single-use state, a PKCE verifier, and a nonce, and stores them. Then it redirects (302) to the IdP's authorization endpoint with `scope=openid email profile`, `response_type=code`, and `code_challenge_method=S256`. 3. The IdP authenticates the user and redirects (302) back to `/api/auth/orgs/org_acme/sso/callback?code=...&state=...`. 4. Pylon consumes the state, which is single-use. It exchanges `code` for tokens at the IdP's token endpoint, using the PKCE verifier. It validates the id\_token's `nonce` claim under OIDC §3.1.2.1, then fetches `email` and `name` from userinfo. 5. Pylon matches the user's email to an existing User row, or creates a new one. It stamps `emailVerified` to the current time, since the IdP has already verified it. 6. If the user is not already a member of the org, Pylon adds them with the configured `default_role`. This step is idempotent. Signing in again through SSO does not downgrade an existing admin. 7. Pylon mints a session and writes the auth cookie. It records a `SignIn` audit event with `method=org_sso`, then redirects (302) to the caller's `callback` URL. On error, Pylon redirects (302) to the `error_callback` URL with the query parameters `?sso_error=<code>&sso_error_message=<msg>`. The client can use these to show the user a clear message. `PYLON_PUBLIC_URL` is required so Pylon can construct the redirect URI to register with the IdP. Without it, `/sso/start` returns `500 REDIRECT_URI_UNAVAILABLE`. ## Configuring SAML ```bash theme={null} curl -X PUT https://your-app/api/auth/orgs/org_acme/saml \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{ "idp_entity_id": "https://idp.okta.com/exk1abcd...", "idp_sso_url": "https://acme.okta.com/app/abc/sso/saml", "idp_x509_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "default_role": "member", "email_domains": ["acme.com"], "email_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "name_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" }' ``` Required fields: `idp_entity_id`, `idp_sso_url` (must be `https://`), and `idp_x509_cert_pem`. Optional fields: `default_role`, `email_domains`, `email_attribute`, and `name_attribute`. `email_attribute` defaults to the standard `emailaddress` claim URI. Errors: | Status | Code | Reason | | ------ | ------------------------------------------- | ----------------------------------------- | | 400 | `MISSING_FIELDS` | One of the three required fields is empty | | 400 | `INSECURE_SSO_URL` | `idp_sso_url` is not `https://` | | 400 | `BAD_DEFAULT_ROLE` | `default_role: "owner"` refused | | 400 | `DOMAIN_BLOCKLISTED` / `DOMAIN_NOT_ALLOWED` | Same as OIDC | ## SAML sign-in flow 1. The client redirects to `GET /api/auth/orgs/org_acme/saml/start?callback=<success_url>&error_callback=<error_url>`. 2. Pylon builds a SAML `AuthnRequest`, then deflates and base64-encodes it for the HTTP-Redirect binding. It redirects (302) to the IdP's SSO URL with `SAMLRequest=...&RelayState=<state>`. 3. The IdP authenticates the user and posts a `SAMLResponse` to `POST /api/auth/orgs/org_acme/saml/acs`. 4. Pylon parses the XML and verifies the digital signature against the configured `idp_x509_cert_pem`, using `xmlsec1`. It validates the assertions and extracts the email and display name from the configured attributes. 5. Pylon then runs the same user-lookup, auto-join, and session-mint flow as OIDC. `xmlsec1` and `libxml2` are required system dependencies. The Pylon Docker image includes both. For self-hosted, from-source builds, install them directly: ```bash theme={null} # macOS brew install libxmlsec1 libxml2 # Debian / Ubuntu apt-get install libxmlsec1-dev libxml2-dev ``` ## Email-domain discovery A sign-in form can ask for the user's email first and route to the right SSO automatically: ```bash theme={null} curl https://your-app/api/auth/sso/discover?email=alice@acme.com ``` OIDC match: ```json theme={null} { "org_id": "org_acme", "kind": "oidc", "start_url": "/api/auth/orgs/org_acme/sso/start" } ``` SAML match: ```json theme={null} { "org_id": "org_acme", "kind": "saml", "start_url": "/api/auth/orgs/org_acme/saml/start" } ``` If nothing matches, Pylon returns `404 NO_SSO_FOR_DOMAIN`. The response reveals nothing about the user. It only routes by email domain. Pylon checks OIDC first. If both protocols claim the same domain, OIDC wins. ## Security guarantees * **State, PKCE, and nonce.** OIDC uses all three. State is single-use and scoped to the org. A reused or wrong-org state returns `403 INVALID_SSO_STATE`. * **PKCE S256.** This binds the token exchange to the client that started the flow. A leaked authorization code cannot be redeemed without the matching `code_verifier`. * **Nonce binding.** Under OIDC §3.1.2.1, this stops an id\_token from being replayed across different sign-in attempts. * **Redirect-URL allowlist.** Pylon validates both `callback` and `error_callback` against `manifest.auth.trustedOrigins` (or `PYLON_TRUSTED_ORIGINS`) before the IdP sees them. Pylon trusts loopback addresses automatically. * **SAML signature verification.** Pylon uses `xmlsec1` to check the signature. It rejects any assertion without a valid signature from the configured certificate. * **HTTPS enforced.** Pylon requires `idp_sso_url` to use `https://`. * **Freemail domain blocklist.** Pylon blocks common consumer-mail domains from being claimed by any org: gmail, yahoo, outlook, icloud, hotmail, live, msn, aol, mail.com, protonmail and proton.me, gmx, yandex, qq, 163, 126, fastmail, mac.com, and me.com. The full list lives in `BLOCKLIST_FREEMAIL_DOMAINS` in `crates/auth/src/org_sso.rs`. * **Operator domain allowlist.** Set it with `PYLON_SSO_ALLOWED_DOMAINS=acme.com,acme.io,...`. * **Default role can never be `owner`.** This stops an IdP misconfiguration from silently handing over org control through IdP-driven role promotion. * **`client_secret` encrypted at rest** when `PYLON_SECRET` is set. * **Auto-join is idempotent.** Signing in again through SSO does not change an existing member's role. To re-apply the IdP's default role, remove the membership first and let the next sign-in re-add it. ## Configuration ```bash theme={null} PYLON_PUBLIC_URL=https://your-app.com # required for SSO callbacks PYLON_SECRET=<openssl rand -hex 32> # encrypts client_secret at rest PYLON_TRUSTED_ORIGINS=https://your-app.com,... # OAuth callback allowlist (or declare in manifest.auth.trustedOrigins) PYLON_SSO_ALLOWED_DOMAINS=acme.com,acme.io # optional: positive domain allowlist ``` ## Where to go next * [Organizations](/auth/organizations): the org model that SSO joins users into. * [OAuth](/auth/oauth): global OAuth (Google and GitHub for everyone) versus per-org SSO. # TOTP / 2FA Source: https://docs.pylonsync.com/auth/totp Enroll and verify RFC 6238 one-time passwords with backup codes and encrypted seeds. Pylon ships RFC 6238 TOTP in the binary. It works with Google Authenticator, 1Password, Authy, Apple's Passwords, Bitwarden, and every other authenticator app. Pylon also ships single-use backup codes (SHA-256 hashed at rest), per-account rate limiting on verify, and at-rest encryption of the TOTP seed. ## Algorithm * **HOTP** (RFC 4226) uses SHA-1 HMAC with 6 digits. Every authenticator app implements this mode. * **TOTP** (RFC 6238) uses a 30-second step with a ±1 step tolerance window on verify. Codes near the rollover boundary still validate. * **160-bit secret**, base32-encoded for the provisioning URL. ## Endpoints All endpoints are under `/api/auth/`. Enrollment and management require a real session. Pylon refuses API-key auth with `403 API_KEY_AUTH_FORBIDDEN`. | Endpoint | Method | Auth | Purpose | | ------------------------------- | ------ | ------- | --------------------------------------------------------- | | `/totp/enroll` | POST | session | Mint a fresh TOTP secret + provisioning URL | | `/totp/verify` | POST | session | Confirm a TOTP code; finalize enrollment on first success | | `/totp/disable` | POST | session | Remove TOTP (requires a current code) | | `/totp/backup-codes/regenerate` | POST | session | Mint a new set of backup codes (invalidates old) | The user entity in your schema needs three optional fields: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; export const User = entity("User", { email: field.string(), totpSecret: field.string().optional(), // sealed envelope: `enc:...` or plaintext base32 in dev totpVerified: field.bool().optional(), // true after the first successful verify totpBackupCodes: field.json().optional(), // string[] of SHA-256 hex hashes }); ``` ## Enrolling ```bash theme={null} curl -X POST https://your-app/api/auth/totp/enroll \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "secret": "JBSWY3DPEHPK3PXP", "url": "otpauth://totp/Acme:alice@acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme&algorithm=SHA1&digits=6&period=30", "issuer": "Acme", "account": "alice@acme.com" } ``` The client renders `url` as a QR code (or shows `secret` for manual entry). The user scans, opens their authenticator app, then calls `/totp/verify` with the current 6-digit code to finalize enrollment. Pylon persists the secret immediately, in `totpVerified=false` state. The secret stays "pending" until `/totp/verify` succeeds for the first time. Calling `/totp/enroll` again while pending rotates the secret freely. Re-enrollment when already verified requires the current TOTP code in the body. This stops an attacker with only the session cookie from silently rotating the secret to one they control: ```bash theme={null} curl -X POST https://your-app/api/auth/totp/enroll \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` A wrong code returns `401 INVALID_TOTP_CODE`. This matches `/password/change`, which also requires the current password. ## Verifying ```bash theme={null} curl -X POST https://your-app/api/auth/totp/verify \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` Response: ```json theme={null} { "verified": true, "enrolled": true, "trust_device": false } ``` * `enrolled: true` is set only on the first successful verify (when `totpVerified` flips from `false` to `true`). * `trust_device: true` is set when the request body included `trust_device: true` and Pylon minted a `pylon_trusted_device` cookie. See [Trusted devices](/auth/trusted-devices). Failure modes: | Status | Code | Reason | | ------ | ------------------- | -------------------------------------------------------------------------- | | 400 | `TOTP_NOT_ENROLLED` | Call `/totp/enroll` first | | 401 | `INVALID_TOTP_CODE` | Wrong code (or wrong backup code) | | 429 | `RATE_LIMITED` | Per-account rate limit hit; `retry_after_secs` in the response | | 500 | `TOTP_BAD_SECRET` | Stored seed corrupt or `PYLON_TOTP_ENCRYPTION_KEY` missing | | 409 | `TOTP_RACE` | Two parallel verifies tried to consume the same backup code. Only one wins | ## Backup codes If `totpBackupCodes` is populated on the user row, `/totp/verify` accepts a backup code as an alternative to the live TOTP code. Pylon hashes backup codes with SHA-256 at rest (hex-encoded) and shows the plaintext to the user once, at generation time. Generating a fresh set invalidates the previous one: ```bash theme={null} curl -X POST https://your-app/api/auth/totp/backup-codes/regenerate \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` The current TOTP code is required. The response includes the plaintext codes. Pylon does not persist the plaintext codes server-side; it shows them once. ```json theme={null} { "codes": ["abcd-1234", "efgh-5678", ...] } ``` Each backup code is single-use. Pylon uses a compare-and-swap check: it consumes the matching index, re-reads the row, and fails with `409 TOTP_RACE` if a parallel verify already consumed that code. This stops two concurrent verifies from consuming the same backup code. ## Disabling ```bash theme={null} curl -X POST https://your-app/api/auth/totp/disable \ -H 'Authorization: Bearer pylon_token' \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` Disabling requires the current code. Pylon wipes `totpSecret`, `totpVerified`, and `totpBackupCodes` from the user row. ## At-rest encryption The TOTP seed is sensitive. Anyone with the bytes can generate codes forever. When you set `PYLON_TOTP_ENCRYPTION_KEY`, Pylon seals the seed before persisting it, using an HMAC-SHA256 stream cipher in counter mode, keyed off the env var, with a 16-byte CSPRNG nonce: ```bash theme={null} PYLON_TOTP_ENCRYPTION_KEY=<any 32+ byte value, e.g. `openssl rand -hex 32`> ``` Stored shape: `enc:<nonce-hex>:<ciphertext-hex>`. This construction is not AEAD, so there is no integrity tag. A flipped bit just produces a TOTP code that fails to verify, and the user re-enrolls. This trade-off avoids adding an AEAD dependency. Without the key, Pylon stores the plaintext base32 value and logs a warning at boot: ``` [totp] PYLON_TOTP_ENCRYPTION_KEY is not set — 2FA seeds stored unencrypted. ``` Generate a key: ```bash theme={null} openssl rand -hex 32 ``` To rotate the key, set the new key and leave the old one in place. `unseal_secret` accepts both `enc:...` blobs (decrypted with the current key) and plaintext (the legacy format). Walk the user table during a deploy and re-seal each seed with the new key. ## Rate limiting Pylon rate-limits verify per account through the shared `AuthRateLimiter`, the same limiter that gates password login. When a caller hits the limit, the response is `429 RATE_LIMITED` with `retry_after_secs`. This defends against an attacker who guesses the live code (about 1 in a million per try) or tries to churn through backup codes. ## Configuration ```bash theme={null} PYLON_TOTP_ISSUER=Acme # branded label in authenticator apps; defaults to manifest.name PYLON_TOTP_ENCRYPTION_KEY=<openssl rand -hex 32> # at-rest seed encryption (HMAC-SHA256 stream cipher) ``` ## Where to go next * [Passkeys](/auth/passkeys): a phishing-resistant FIDO2 method, as an alternative to TOTP or alongside it. * [Trusted devices](/auth/trusted-devices): set `trust_device: true` on `/totp/verify` to skip the prompt for 30 days. * [Password](/auth/password): TOTP usually pairs with email and password sign-in. # Trusted devices Source: https://docs.pylonsync.com/auth/trusted-devices Remember-this-browser cookie that lets a user skip the TOTP prompt for 30 days from the same device. After a user completes a second factor (TOTP today; a passkey assertion can use the same mechanism), the client can ask Pylon to remember this browser. Pylon then skips the second factor for 30 days on this device. The trust binds to the user. A stale cookie from a previous account on the same browser becomes untrusted; it never grants trust to a different user. The trust flag flows through `auth.isTrustedDevice`. Policies can use it to gate sensitive flows, for example to require a fresh TOTP code unless the device is trusted. ## Endpoints | Endpoint | Method | Auth | Purpose | | ------------------------------- | ------ | ------- | ---------------------------------------- | | `/api/auth/trusted-devices` | GET | session | List the user's trusted browsers | | `/api/auth/trusted-devices` | DELETE | session | Revoke ALL of the user's trusted devices | | `/api/auth/trusted-devices/:id` | DELETE | session | Revoke one trusted device | All three require a real session. API-key auth is refused with `403 API_KEY_AUTH_FORBIDDEN`. ## Minting trust There is no dedicated endpoint to trust a device. Pylon mints trust as a side effect of a successful second-factor verify, when the user opts in: ```bash theme={null} curl -X POST https://your-app/api/auth/totp/verify \ -H 'Authorization: Bearer pylon_session_token' \ -H 'Content-Type: application/json' \ -d '{ "code": "123456", "trust_device": true }' ``` When `trust_device: true` is set on `/totp/verify`, Pylon: 1. Mints a 256-bit random trust token. 2. Persists a `TrustedDevice` record bound to the user. 3. Sets a `pylon_trusted_device` cookie (HttpOnly, `SameSite=Lax`, `Secure` in non-dev) with a 30-day lifetime. The response includes `trust_device: true` to confirm: ```json theme={null} { "verified": true, "enrolled": false, "trust_device": true } ``` The cookie is bound to the user through the underlying record. Stealing the cookie alone does not help an attacker: Pylon validates `record.user_id == session.user_id` on every request. ## Listing devices ```bash theme={null} curl https://your-app/api/auth/trusted-devices \ -H 'Authorization: Bearer pylon_token' ``` Response: ```json theme={null} { "devices": [ { "id": "td_xyz", "label": "Chrome on macOS", "created_at": 1735000000, "expires_at": 1737592000 } ] } ``` The response does not include the token. The cookie value stays on the server and never reaches the dashboard, so XSS that reads this endpoint cannot extract trust tokens. The `label` is parsed from the request's User-Agent header at mint time. Browsers display it in the "active devices" account settings page. ## Revoking Revoke one device by id: ```bash theme={null} curl -X DELETE https://your-app/api/auth/trusted-devices/td_xyz \ -H 'Authorization: Bearer pylon_token' ``` Pylon checks object-level authorization: it verifies that the record's `user_id` matches the caller. A cross-user revoke attempt returns `404 NOT_FOUND`, the same code as a missing device, so an admin cannot enumerate trusted-device ids from response timing. Revoke all devices at once: ```bash theme={null} curl -X DELETE https://your-app/api/auth/trusted-devices \ -H 'Authorization: Bearer pylon_token' ``` Both responses include `revoked: <count>` for the number of records removed. Pylon also clears the current request's trust cookie with `Set-Cookie`, so the browser drops it immediately. ## Gating in your policies / handlers The trust flag is available on `AuthContext.is_trusted_device`. Use it in TypeScript handlers: ```typescript theme={null} import { action } from "@pylonsync/functions"; export default action({ async handler(ctx, args) { if (!ctx.auth.userId) throw new Error("sign in"); // Require fresh TOTP for high-value actions UNLESS the user is on // a trusted device. if (args.amount > 10000 && !ctx.auth.isTrustedDevice) { throw new Error("REQUIRES_2FA"); } // ... proceed }, }); ``` In policies, the field is `auth.isTrustedDevice` (a boolean). Combine it with role checks to gate sensitive entity reads and writes. ## Lifetime + cookie attributes * **Lifetime:** 30 days from mint (`DEFAULT_TRUST_LIFETIME_SECS = 30 * 24 * 60 * 60`). * **Cookie name:** `pylon_trusted_device`. * **Attributes:** set from the framework's `CookieConfig`, the same `Secure`, `SameSite`, and `Path` values as the session cookie. Operators do not configure two separate cookie policies. ## Security guarantees * **Token stays on the server.** Listing trusted devices returns the `id` (a public handle) but never the cookie value. XSS that reaches `/trusted-devices` cannot extract trust tokens; it would need to read `document.cookie`, and HttpOnly blocks that. * **Bound to the user.** Every verify checks `record.user_id == session.user_id`. Stealing the cookie alone does not work; an attacker needs both the trust cookie and the matching session. * **Object-level auth on revoke, with timing parity.** A cross-user revoke attempt and a nonexistent device return the same `404` from the same code path. * **Cleared automatically on full revoke.** `DELETE /trusted-devices` (revoke all) clears the cookie on the response, so the browser drops it without a refresh. * **HttpOnly, Secure, and SameSite=Lax**, the same settings as the session cookie. ## Where to go next * [TOTP / 2FA](/auth/totp): the verify endpoint that mints trust via `trust_device: true`. * [Sessions](/auth/sessions): the base cookie attribute config that trusted-device cookies inherit. * [Passkeys](/auth/passkeys): a phishing-resistant alternative. A passkey user usually does not need a remember-device gate. # Trusted server-side session mint Source: https://docs.pylonsync.com/auth/trusted-mint Sign a user into Pylon from a trusted server (Stripe Checkout success, custom IdP, internal SSO bridge) using an HMAC-signed request. `POST /api/auth/sessions/trusted-mint` lets a server you control mint a Pylon session after another trusted system has verified the user's identity. It skips the password, magic-link, and OAuth flows. Stripe Checkout is the standard use case. After Stripe verifies the buyer's email and collects payment, trusted mint can sign the buyer into the dashboard without another sign-in step. <Warning> The endpoint checks an HMAC signature, not a session. Anyone who knows `PYLON_TRUSTED_SECRET` can sign in as any user. Give the secret the same protection as a database password: store it only in environment variables, rotate it after any leak, and never log it. </Warning> ## Enabling the endpoint The endpoint is off by default. Until you set `PYLON_TRUSTED_SECRET`, requests receive a plain `404`. This keeps a default Pylon install from shipping a signing surface that does nothing. ```sh theme={null} # 32+ bytes of CSPRNG. macOS / Linux: openssl rand -hex 32 # → 2f1e... (use that) # Pylon Cloud: pylon secrets set PYLON_TRUSTED_SECRET=2f1e... # Self-hosted: set the env var on your Pylon server. ``` The same secret must be available to whatever code signs the requests: your Next.js route handler, your Worker, or your CLI tool. Use the same secret-management approach you already use for `STRIPE_SECRET_KEY`. The two secrets need the same level of protection. ## Wire format ``` POST /api/auth/sessions/trusted-mint Content-Type: application/json X-Pylon-Trusted-Timestamp: 1716177600 X-Pylon-Trusted-Signature: 9f8a2c... (hex HMAC-SHA256) { "email": "buyer@example.com", "createIfMissing": true, "displayName": "Jane Doe", "intent": "stripe_checkout_success" } ``` | Field | Required | Notes | | ----------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `email` | yes | Lowercased + trimmed server-side. Must contain `@`. | | `createIfMissing` | no (default `false`) | When `true`, a missing user is provisioned with `emailVerified` set to now. When `false`, a missing user returns `400 USER_NOT_FOUND`. | | `displayName` | no | Used on user creation. Falls back to the email address. Ignored when the user already exists. | | `intent` | no | Free-form audit string. Stored as `metadata.intent` on the resulting audit event so an operator can later filter "all trusted mints from Stripe Checkout success". | Signature algorithm: ``` signed_value = "<timestamp>.<raw_request_body>" signature = hex(HMAC_SHA256(PYLON_TRUSTED_SECRET, signed_value)) ``` Hex output is lowercase. Timestamps are Unix seconds. A request outside ±5 minutes of the server clock returns `401 STALE_TIMESTAMP`. The format matches Stripe's webhook signature scheme, so it will look familiar if you have built a Stripe webhook handler before. ## Response ```json theme={null} { "userId": "usr_01H...", "created": true, "token": "pylon_...", "expiresAt": 1716264000 } ``` On a `2xx` response, the endpoint also sends the same `Set-Cookie` header that `/api/auth/magic/verify` sends. A browser-facing proxy can forward the response directly, and the user ends up signed in. Pylon creates the `Session` the same way it creates a magic-link session: revocable from `/api/auth/sessions` and listed under the user's devices. ## Failure modes | Status | Code | Meaning | | ------ | --------------------------------- | ------------------------------------------------------------------------------------- | | `200` | — | Session minted. | | `400` | `INVALID_JSON` | Body wasn't valid JSON. | | `400` | `MISSING_EMAIL` / `INVALID_EMAIL` | Email field missing or doesn't contain `@`. | | `400` | `USER_NOT_FOUND` | Email is unknown and `createIfMissing` is `false`. | | `401` | `MISSING_TIMESTAMP` | `X-Pylon-Trusted-Timestamp` header missing or not a u64. | | `401` | `MISSING_SIGNATURE` | `X-Pylon-Trusted-Signature` header missing. | | `401` | `STALE_TIMESTAMP` | Timestamp outside ±5min window (clock skew or replay). | | `401` | `BAD_SIGNATURE` | HMAC mismatch (wrong secret or tampered body). | | `403` | `ACCOUNT_LOCKED` | User row has a non-null `disabledAt`, `bannedAt`, `lockedAt`, or `_deletedAt` column. | | `404` | — | `PYLON_TRUSTED_SECRET` is unset on this server. | | `500` | `USER_INSERT_FAILED` | Backend couldn't insert the new user row (schema constraint, DB error). | ## Example: Next.js Stripe Checkout success handler ```ts filename="app/api/checkout/success/route.ts" theme={null} import { NextRequest } from "next/server"; import { redirect } from "next/navigation"; import { createHmac } from "crypto"; import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const PYLON_URL = process.env.PYLON_TARGET!; const TRUSTED_SECRET = process.env.PYLON_TRUSTED_SECRET!; function sign(secret: string, ts: number, body: string) { return createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex"); } export async function GET(req: NextRequest) { const sessionId = req.nextUrl.searchParams.get("session_id"); if (!sessionId) return new Response("missing session", { status: 400 }); const session = await stripe.checkout.sessions.retrieve(sessionId); if (session.payment_status !== "paid") return new Response("unpaid", { status: 402 }); const email = session.customer_details?.email; if (!email) return new Response("no email", { status: 400 }); const ts = Math.floor(Date.now() / 1000); const body = JSON.stringify({ email, createIfMissing: true, displayName: session.customer_details?.name ?? undefined, intent: "stripe_checkout_success", }); const signature = sign(TRUSTED_SECRET, ts, body); const mint = await fetch(`${PYLON_URL}/api/auth/sessions/trusted-mint`, { method: "POST", headers: { "Content-Type": "application/json", "X-Pylon-Trusted-Timestamp": String(ts), "X-Pylon-Trusted-Signature": signature, }, body, }); if (!mint.ok) { return new Response(`trusted-mint failed: ${await mint.text()}`, { status: 500 }); } // Pass Pylon's Set-Cookie through to the browser, then redirect. const res = new Response(null, { status: 302, headers: { Location: "/dashboard" } }); const setCookie = mint.headers.get("set-cookie"); if (setCookie) res.headers.set("set-cookie", setCookie); return res; } ``` ## Edge cases * **Multi-tenant orgs.** The minted session matches what `/api/auth/magic/verify` produces. No tenant is auto-selected. Apps that need a tenant should call `/api/auth/select-org` after the user lands (the dashboard typically does this automatically, based on a stored "last org" hint). * **Locked or banned users.** If the User row has a non-null `disabledAt`, `bannedAt`, `lockedAt`, or `_deletedAt` column, the endpoint returns `403 ACCOUNT_LOCKED` and logs a `sign_in_failed` audit event. These are conventions, not framework-enforced columns. Apps that do not model lockouts can ignore them. * **Email already in use under a guest cookie.** Trusted-mint does not merge guest data automatically. That behavior is specific to magic-link verify, where the user explicitly proves control of the email. If you need anonymous-cart merging on Stripe success, do it explicitly in your handler after the mint succeeds. * **Cookie pass-through to the browser.** The endpoint always sends `Set-Cookie`, even when called server-to-server. If you do not forward the header to a browser, ignore it. The `token` in the response body is the same session, and it works as `Authorization: Bearer pylon_…`. * **Rate limiting.** The standard `PYLON_RATE_LIMIT_MAX` per-IP anonymous bucket applies. A bot that finds the endpoint URL but does not have the secret hits the rate limit within seconds and cannot get further. The secret is the real security boundary. ## Audit log Every call writes one of three event shapes to the audit log: | Action | Metadata | When | | ---------------- | ------------------------------------------------------ | ----------------------------------------------------- | | `sign_in_failed` | `method: trusted_mint`, `reason: trusted_mint:<error>` | Signature, timestamp, or account-lock check failed. | | `sign_up` | `method: trusted_mint`, `intent: <intent>` | New user was provisioned via `createIfMissing: true`. | | `sign_in` | `method: trusted_mint`, `intent: <intent>` | Session minted (always, after success). | The `intent` field is the free-form string from the request body. Use it to tell apart "Stripe checkout success," "internal SSO bridge," and "BYOC migration backfill" without parsing audit reasons. ## Replay window The signature scheme defends against two things. First, tampering: an attacker cannot change the email in a captured request without invalidating the signature. Second, stale replay: a signature stops working after 5 minutes. It does not defend against a fresh replay, a captured request resent within that same window. Anyone who can reach the Pylon ingress and replays a captured signed request within 5 minutes mints another session for the same email. The response body includes a long-lived `token`. This creates two risks: * A TLS man-in-the-middle in the request path (a corporate proxy, or a CDN that logs full bodies) can extract a usable session token within the 5-minute window. * An attacker who steals one signed request from server logs that capture the URL and headers can replay it, once per minute, for up to 5 minutes. This is rare, since signatures live in headers and bodies are not usually logged. Mitigations: * Do not log signed requests together with their headers. * Use HTTPS end-to-end. The HMAC signature does not replace TLS. * If your threat model needs single-use requests, add a `nonce` to the request body and enforce uniqueness in your own backend before calling trusted-mint. The trusted-mint endpoint does not de-duplicate requests on its own; Pylon's audit log shows every replay. Pylon accepts this trade-off. A per-request nonce store would add complexity and a new failure mode: if the nonce store is unavailable, the endpoint fails. For Stripe Checkout style use cases, the 5-minute window works well, because the user is actively in the flow at that moment. ## Security checklist * [ ] `PYLON_TRUSTED_SECRET` is 32+ bytes of CSPRNG output (`openssl rand -hex 32`). * [ ] Set the secret only on machines that need to sign or verify requests. A sign-only frontend does not need to know it. * [ ] Restrict the endpoint to your trusted server-side environments where possible. Most production Pylon deployments are public, so the secret is the only barrier against attackers. * [ ] Keep server clocks within a few seconds of NTP. The ±5 minute window is not as wide as it looks: a 7-minute clock skew makes the endpoint stop working. * [ ] To rotate the secret: set a new `PYLON_TRUSTED_SECRET`, redeploy the signing service, wait about 10 minutes for in-flight signed requests to flush, then update the Pylon side. Pylon accepts only one active secret today; multi-key rotation is planned. * [ ] Send the audit log somewhere queryable (Tinybird, Loki, Datadog), so you can alert on a `sign_in_failed` spike for the `trusted_mint` method. Every post-signature rejection (invalid JSON, missing or invalid email, USER\_NOT\_FOUND, INVALID\_USER\_ROW, USER\_INSERT\_FAILED, ACCOUNT\_LOCKED) emits one, with the matching `meta.reason`. * [ ] Confirm the TLS terminator (Cloudflare, the Fly proxy, or similar) does not log signed-request payloads. Bodies are sensitive, even though headers usually are not. ## HMAC requests vs. service tokens A long-lived service bearer token could work for the same use case, but it has three drawbacks: * Service tokens leak. Once leaked, the attacker can sign in as anyone, indefinitely. This is the same blast radius as `PYLON_TRUSTED_SECRET`, except the secret never travels with the request, so observability tools that log headers do not capture it by accident. * Service tokens do not bind to the request body. An attacker who captures one signed request can replay it. The HMAC signature plus timestamp stops that. * Token rotation needs a coordinated cutover on both sides. Secret rotation is a single environment-variable change on each side; you do not deploy a new long-lived credential. Use `pk.*` API keys when the trusted server needs to act as one specific user. Use trusted-mint when the trusted server needs to sign in as any user. These are deliberately different threat models. # Loro CRDTs Source: https://docs.pylonsync.com/clients/loro Build collaborative text, lists, maps, and trees that converge across clients with @pylonsync/loro. Pylon integrates with [Loro](https://loro.dev) for collaborative-editor features like Google Docs, Figma, and Linear-style multiplayer. Loro is a fast, tested CRDT library with bindings for JavaScript and Swift. CRDT-backed fields don't go through normal LWW merge. They sync via a dedicated binary channel on the WebSocket. Every CRDT-mode write ships as `[type | entity_len | entity | row_id_len | row_id | payload]`. The receiving side feeds the payload to a per-row `LoroDoc` that converges automatically. ## What you get * **Collaborative text:** multiple cursors, conflict-free inserts/deletes, undo/redo * **Collaborative lists:** append, insert, move, delete with stable item ids * **Collaborative maps:** key-value with last-writer-wins per key * **Collaborative trees:** hierarchical structures (outlines, file trees) * **Counter:** multi-writer increment/decrement that always converges * **Cursor positions:** anchor a cursor to a position that survives concurrent edits These CRDTs share the same wire format and the same convergence guarantees. ## Install ```bash theme={null} bun add @pylonsync/loro ``` The Swift SDK ships Loro bridging in `PylonSync`, with no separate install. ## Declare CRDT-backed fields In your schema: ```typescript theme={null} import { entity, field } from "@pylonsync/sdk"; const Document = entity("Document", { title: field.string(), // normal LWW field body: field.string().crdt("text"), // LoroText (or field.richtext()) views: field.int().crdt("counter"), // LoroCounter authorId: field.id("User"), }); ``` Set CRDT container overrides with `.crdt(annotation)` on a field builder. `"text"` and `"counter"` are wired end-to-end today. `"list"`, `"movable-list"`, and `"tree"` are reserved (the wire format is locked in, the server-side projection is in progress). You can still edit list, map, and tree containers on the per-row `LoroDoc` (see below), because the server never interprets CRDT payloads. The Pylon server stores CRDT fields as opaque bytes and does not interpret them. Clients decode the bytes into Loro containers and edit them locally. Updates broadcast to other subscribers via the binary WebSocket channel. ## Subscribe and edit ```typescript theme={null} import { useLoroDoc } from "@pylonsync/loro"; import { LoroDoc } from "loro-crdt"; function DocumentEditor({ docId }: { docId: string }) { const doc = useLoroDoc("Document", docId); const text = doc.getText("body"); return ( <textarea value={text.toString()} onChange={(e) => { text.delete(0, text.length); text.insert(0, e.target.value); }} /> ); } ``` `useLoroDoc` subscribes the engine to binary updates for `(entity, rowId)`, decodes incoming frames, and returns the live `LoroDoc`. Edits to the doc dispatch through Loro's local state; the hook ships the resulting binary update back over the WebSocket. Multiple `useLoroDoc` callers on the same row share one subscription (refcounted). Opening the same document in two tabs doesn't double-subscribe. ## Container types ### Text — collaborative rich text ```typescript theme={null} const text = doc.getText("body"); text.insert(0, "Hello, "); text.insert(7, "world!"); text.delete(0, 5); // remove "Hello" text.toString(); // "world!" ``` For rich text with marks (bold, italic, links): ```typescript theme={null} text.mark({ start: 0, end: 5 }, "bold", true); text.unmark({ start: 0, end: 5 }, "bold"); ``` ### List — append / insert / move ```typescript theme={null} const list = doc.getList("tags"); list.push("urgent"); list.insert(0, "draft"); list.delete(1, 1); list.toArray(); // ["draft"] ``` For lists where items keep stable ids across moves: ```typescript theme={null} const list = doc.getMovableList("items"); const id = list.push({ name: "First" }); list.move(id, 5); // move to position 5 ``` ### Map — key-value ```typescript theme={null} const map = doc.getMap("metadata"); map.set("status", "draft"); map.set("priority", 3); map.get("status"); // "draft" map.delete("priority"); ``` ### Counter — multi-writer increment ```typescript theme={null} const counter = doc.getCounter("views"); counter.increment(1); counter.decrement(2); counter.value; // -1 ``` Multiple clients incrementing concurrently are summed correctly — no last-writer-wins data loss. ### Tree — hierarchical ```typescript theme={null} const tree = doc.getTree("outline"); const root = tree.createNode(); root.data.set("text", "Top-level"); const child = root.createNode(); child.data.set("text", "Nested"); child.move(root, 0); // make child the first child of root ``` Useful for outlines, file trees, organizational charts, mind maps. ## Cursors For "show where each user is editing": ```typescript theme={null} import { Cursor } from "loro-crdt"; const text = doc.getText("body"); const myCursor = text.getCursor(42); // anchor at position 42 // Other clients can read the cursor's current resolved position // even after concurrent edits shift everything around: const resolved = doc.getCursorPos(myCursor); // → { offset, side, origin, ... } ``` Combined with `engine.setPresence({ cursor: myCursor })`, you get the multiplayer-cursor effect. ## Awareness / presence For lightweight ephemeral state (who's editing, where their cursor is, what they're typing) that doesn't need to be persisted, use the `useRoom` hook from `@pylonsync/react`. It returns the live peer list plus a `setPresence` you call as the local cursor moves: ```tsx theme={null} import { useRoom } from "@pylonsync/react"; function PresenceLayer({ docId, me }: { docId: string; me: User }) { const room = useRoom(`doc:${docId}`, me.id); return ( <> {room.peers.map((peer) => ( <RemoteCursor key={peer.id} presence={peer.presence} /> ))} <textarea onSelect={(e) => room.setPresence({ color: "#ff7700", cursor: e.currentTarget.selectionStart }) } /> </> ); } ``` Each `peer.presence` is the last object that peer pushed via `setPresence`. Presence rides the same WebSocket as CRDT updates but doesn't go into the CRDT itself. It's transient. Peers who disconnect drop out of `room.peers`. ## Saving and loading The engine handles persistence for you. Every CRDT update is broadcast, and the server holds the canonical state. To get the current snapshot for a one-off use (export, share, backup): ```typescript theme={null} const snapshot = doc.export({ mode: "snapshot" }); // snapshot is a Uint8Array — store it, ship it elsewhere // Restore on another client const newDoc = new LoroDoc(); newDoc.import(snapshot); ``` For incremental updates (smaller bytes, useful for over-the-wire sync): ```typescript theme={null} const update = doc.export({ mode: "update", from: previousVersion }); otherDoc.import(update); ``` ## Undo / redo Loro has built-in version vectors that make undo/redo work correctly even with concurrent edits: ```typescript theme={null} import { UndoManager } from "loro-crdt"; const undo = new UndoManager(doc, { mergeInterval: 1000, // group edits within 1s as one undoable step }); undo.undo(); undo.redo(); undo.canUndo(); undo.canRedo(); ``` ## Performance * **Local edits are O(log n)** in the size of the document. They stay fast even for large texts. * **Binary frames are tiny.** Typical updates are a few dozen bytes. Snapshots are larger but only sent on subscribe. * **CRDT logic is Rust.** Both `loro-crdt` (JS) and `loro-swift` wrap the same Rust core, so convergence is identical and performance is consistent. * **No conflict markers ever appear.** Concurrent edits always merge cleanly. ## Wire format Frame layout (matches `crates/router/src/lib.rs::encode_crdt_frame` and `packages/loro/src/wire.ts`): ``` [type: u8] [entity_len: u16 BE] [entity utf8] [row_id_len: u16 BE] [row_id utf8] [payload bytes] ``` Type bytes: `0x10` = full snapshot, `0x11` = incremental update. The engine does not read `payload`. That is Loro's binary format. To decode it for debugging: ```typescript theme={null} import { decodeCrdtFrame } from "@pylonsync/loro/wire"; engine.onBinaryFrame((bytes) => { const frame = decodeCrdtFrame(bytes); console.log(`${frame.type === 0x10 ? "snapshot" : "update"} for ${frame.entity}/${frame.rowId}: ${frame.payload.length} bytes`); }); ``` ## Swift Same model. The Swift bridge: ```swift theme={null} import PylonSync import Loro let crdtDoc = PylonLoroDoc(entity: "Document", rowId: "doc_42") await crdtDoc.attach(to: engine) let text = crdtDoc.doc.getText(id: "body") try text.insert(pos: 0, s: "Hello, world!") ``` `PylonLoroDoc.attach(to:)` registers the binary handler with the engine and sends the `crdt-subscribe` message. Detach (or let the doc go out of scope) to unsubscribe. ## When to skip CRDTs CRDTs work well for collaborative state, where two users might edit at the same time. They are unnecessary for: * **Single-user data:** use a normal entity field * **Server-authoritative state:** orders, payments, anything where the server is the source of truth and last-write-wins is correct * **High-frequency telemetry:** CRDTs aren't designed for write-heavy event streams; use a regular entity with append semantics Mix CRDT and non-CRDT fields freely on the same entity. `Document.title` can be a normal string while `Document.body` is a `LoroText`. ## Production checklist * **Snapshot frequency:** Loro's hybrid logical clock works well. For very long-lived docs, periodically save a full snapshot to bound the size of the update history. The engine does this automatically. * **Garbage collection:** Loro tracks tombstones for deletes. `doc.compact()` reclaims memory after large deletions. * **Presence cleanup:** set a TTL on presence entries. Otherwise, users who close the tab without leaving will linger. ## Examples * [`examples/pad`](https://github.com/pylonsync/pylon/tree/main/examples/pad) — CRDT-backed collaborative markdown editor * [`examples/forge`](https://github.com/pylonsync/pylon/tree/main/examples/forge) — design-tool-style multi-cursor editing * [`examples/linear`](https://github.com/pylonsync/pylon/tree/main/examples/linear) — issue tracker with CRDT-backed comment threads # Next.js SDK Source: https://docs.pylonsync.com/clients/next Use @pylonsync/next for server helpers, an edge proxy, and cookie auth in the Next.js App Router. `@pylonsync/next` is the Next.js-specific layer on top of `@pylonsync/react`. It provides: * **`createPylonServer`:** server-side fetch helpers (`pylon.requireAuth()`, `pylon.getMe()`, `pylon.json()`) that forward the user's session cookie * **`createPylonClient`:** a same-origin client fetcher for Client Components that respects Next's request lifecycle * **`createPylonProxy`:** middleware that gates protected routes on session presence so the UI doesn't flash before redirect * **`@pylonsync/next/auth`:** cookie-aware sign-up / sign-in / OAuth flows Requires the App Router on Next.js 16+ (the package's peer-dependency floor); Pages Router users can still use [`@pylonsync/react`](/clients/react) directly. The fastest way to start a Next.js + Pylon project is `npm create @pylonsync/pylon`. Pick the `todo` (or any) template with `--platforms web`. You get a working App Router setup with all of this configured. The rest of this page documents the pieces, so you can add Pylon to an existing Next.js app. ## Install ```sh theme={null} bun add @pylonsync/next # or: npm i @pylonsync/next ``` `@pylonsync/sdk` + `@pylonsync/react` install transitively. ## 1. Configure the backend URL Set `PYLON_TARGET` to the Pylon backend's origin. In dev it defaults to `http://localhost:4321` (the `pylon dev` default), so you only need to set it explicitly when the backend lives elsewhere. ```env filename=".env.local" theme={null} PYLON_TARGET=https://api.example.com ``` On Vercel, add `PYLON_TARGET` in your project's Environment Variables for both `Production` and `Preview` environments — point it at your Pylon Cloud project's URL (`https://pylon-<slug>.fly.dev` for the default hostname, or your custom domain). See [Deploying to Vercel](/operations/vercel) for the full checklist. ## 2. Server helpers — `createPylonServer` Build a single `pylon` server-helper that every Server Component, Route Handler, or Server Action imports from. ```ts filename="src/lib/pylon.ts" theme={null} import { createPylonServer } from "@pylonsync/next"; export const pylon = createPylonServer({ // Match what your Pylon backend sets. Pylon emits `${app_name}_session` // by default — pass that exact name. There's no implicit default // because picking the wrong name silently breaks auth in production. cookieName: "myapp_session", // Optional — overrides PYLON_TARGET if both are set. target: process.env.PYLON_TARGET ?? "http://localhost:4321", // Where to redirect when requireAuth() finds no session. loginUrl: "/login", }); ``` Now use it from any server context: ```tsx filename="app/dashboard/page.tsx" theme={null} import { pylon } from "@/lib/pylon"; type User = { id: string; email: string; displayName: string }; type Post = { id: string; title: string }; export default async function DashboardPage() { // 401 → redirect to /login automatically. Returns // { auth: { userId, tenantId, isAdmin, ... }, user: User } // — uses your getMe server function for the User row. const { user } = await pylon.requireMe<User>(); // Forwards the user's session cookie to the Pylon backend. const posts = await pylon.json<Post[]>("/api/entities/Post"); return ( <div> <h1>Welcome, {user.displayName}</h1> <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> </div> ); } ``` Useful methods on the returned `pylon`: | Method | Returns | Use it for | | ---------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- | | `requireAuth()` | `PylonAuth` (`{userId, tenantId, isAdmin, cookieHeader}`) or redirects | Gate Server Components / Actions when you only need the user id | | `getAuth()` | `PylonAuth \| null` | Read auth without redirecting | | `requireMe<U>()` | `{ auth, user }` (or redirects) | Gate + load the user row via your `getMe` function | | `getMe<U>()` | `{ auth, user } \| null` | Same, without redirect | | `json<T>(path, init?)` | parsed JSON `T` | Call any Pylon API with the user's session | | `fetch(path, init?)` | raw `Response` | When you need headers, streams, status codes | All paths are relative to the configured `target` and the session cookie rides along automatically. ## 3. Client fetcher — `createPylonClient` / `api` For Client Components, the package exports a same-origin `api()` helper that the `useQuery` / `useMutation` hooks build on top of. Most apps don't construct one directly — just `import { api } from "@pylonsync/next/client"`: ```tsx theme={null} "use client"; import { api } from "@pylonsync/next/client"; async function rotateToken(secretId: string) { await api("/api/fn/rotateSecret", { method: "POST", body: JSON.stringify({ id: secretId }), }); } ``` The client requests `/api/*` paths same-origin so the session cookie rides natively. If you split your frontend onto a different origin than your Pylon backend, set up a Next.js rewrite in `next.config.js`: ```js filename="next.config.js" theme={null} /** @type {import('next').NextConfig} */ module.exports = { async rewrites() { const target = process.env.PYLON_TARGET ?? "http://localhost:4321"; return [{ source: "/api/:path*", destination: `${target}/api/:path*` }]; }, }; ``` This keeps the browser talking to its own origin, sidesteps CORS preflight, and means the session cookie doesn't need cross-site config. ## 4. Live data — `<Providers>` + `db.useQuery` Live queries from React Server Components are tricky (RSC has no client store). For live data, render a Client Component and use the [`db.useQuery`](/clients/react) hook from `@pylonsync/react`: ```tsx filename="app/providers.tsx" theme={null} "use client"; import { useEffect } from "react"; import { configureClient, useSyncStatus } from "@pylonsync/react"; export function Providers({ children }: { children: React.ReactNode }) { // Empty baseUrl → use the current origin (which the Next.js // rewrite from step 3 forwards to PYLON_TARGET). Same-origin // requests keep cookies + sidestep CORS. useEffect(() => { configureClient({ baseUrl: "" }); }, []); return <>{children}</>; } ``` ```tsx filename="app/dashboard/posts/page.tsx" theme={null} "use client"; import { db } from "@pylonsync/react"; type Post = { id: string; title: string }; export default function PostsPage() { const { data: posts, loading } = db.useQuery<Post>("Post"); if (loading) return <div>Loading…</div>; return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>; } ``` `db.useQuery` subscribes to the local sync replica. Server-pushed inserts, your own optimistic mutations, and cross-tab updates all re-render automatically. ## 5. Middleware gate — `createPylonProxy` Block protected routes from rendering before auth resolves. Saves a flash of UI before the client-side redirect: ```ts filename="src/middleware.ts" theme={null} import { createPylonProxy } from "@pylonsync/next/proxy"; const { proxy, config } = createPylonProxy({ cookieName: "myapp_session", loginUrl: "/login", matcher: ["/dashboard/:path*"], }); export { proxy as middleware, config }; ``` The proxy only checks for the cookie's presence. Forged values still fail server-side at `pylon.requireAuth()` inside the page. It is a UX optimization. It is not a security boundary. ## 6. Auth flows — `@pylonsync/next/auth` Sign-up, sign-in, and OAuth from Client Components or Server Actions: ```tsx filename="app/login/page.tsx" theme={null} "use client"; import { loginWithPassword } from "@pylonsync/next/auth"; import { useState } from "react"; import { useRouter } from "next/navigation"; export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); async function onSubmit(e: React.FormEvent) { e.preventDefault(); await loginWithPassword({ email, password }); router.push("/dashboard"); } return ( <form onSubmit={onSubmit}> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <button type="submit">Sign in</button> </form> ); } ``` The Pylon backend sets the session cookie. Your Next.js layer only makes the call. After login, `await pylon.requireAuth()` in any Server Component resolves to the freshly-authenticated user. OAuth providers follow the same pattern via `startOAuthLogin({ provider: "google", returnTo: "/dashboard" })`. ## Environment variables | Variable | Where | Purpose | | -------------- | ---------------------- | ------------------------------------------------------------------------------------------- | | `PYLON_TARGET` | Server (`process.env`) | Origin of your Pylon backend. Used by `createPylonServer` and the `next.config.js` rewrite. | There is intentionally no `NEXT_PUBLIC_PYLON_URL`. The client always talks same-origin via the Next rewrite, so the browser doesn't need to know the backend URL. If you don't want the rewrite, pass an explicit `baseUrl` to `configureClient` in your `Providers`. ## Common pitfalls * **Cookie name mismatch.** Pylon emits `${app_name}_session`. If your `app.ts` says `name: "notes"`, the cookie is `notes_session` — not `pylon_session`. Pass the right name to `createPylonServer({ cookieName })` and `createPylonProxy({ cookieName })`. * **Cross-origin without rewrite.** If your Next.js app is on `app.example.com` and Pylon is on `api.example.com`, you need either a rewrite (recommended) or matching `Domain=.example.com` cookie config on the backend. See [Sessions](/auth/sessions) for the cookie-domain checklist. * **Server fetch timeouts.** `pylon.json()` and `pylon.fetch()` apply a 5s timeout by default — a stuck Pylon backend won't hang your Next.js page indefinitely. Use `loading.tsx` and `error.tsx` boundaries to render gracefully when the backend is unreachable. * **Empty `baseUrl` only works when rewrites are in place.** `configureClient({ baseUrl: "" })` uses the current origin, which only works if `/api/*` reaches your Pylon backend. Without the Next rewrite, pass the absolute Pylon URL. # Clients overview Source: https://docs.pylonsync.com/clients/overview Choose a Pylon SDK for TypeScript, React, React Native, Next.js, Swift, sync, or Loro. Pylon's wire format is plain HTTP, WebSocket, and JSON. You can call it with `fetch` and a WebSocket from any language. Pylon also ships official SDKs that handle the bookkeeping (auth tokens, optimistic mutations, reconnection, CRDT decoding, typed entities), so you don't reinvent them. ## Available SDKs | Package | Use it for | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **[`@pylonsync/sdk`](/clients/typescript)** | Schema DSL + manifest builder. Required for codegen. | | **[`@pylonsync/react`](/clients/react)** | React hooks: `useQuery`, `useMutation`, `useShard`, `useSession`, `useSearch`, `useAggregate`, `useInfiniteQuery` | | **[`@pylonsync/react-native`](/clients/react-native)** | React Native + Expo: SQLite-backed offline replica, AsyncStorage adapter | | **[`@pylonsync/next`](/clients/next)** | Next.js App Router: Server Actions, RSC data fetching, middleware-friendly auth | | **[Swift SDK](/clients/swift)** | iOS / macOS / tvOS / watchOS / Linux native — `PylonClient`, `PylonSync`, `PylonRealtime`, `PylonSwiftUI` | | **[`@pylonsync/sync`](/clients/sync)** | The sync engine: local store, mutation queue, and WebSocket/SSE/poll transports. Every JS SDK uses it. You can also use it standalone in non-React apps. | | **[`@pylonsync/loro`](/clients/loro)** | Loro CRDT integration: collaborative text/lists/maps/trees that converge across clients | ## Picking the right combination | App type | Use | | ------------------------------ | ----------------------------------------------------------------------------- | | Web SPA (React) | `@pylonsync/react` (pulls sdk + sync transitively) | | Server-rendered web (Next.js) | `@pylonsync/next` (pulls sdk + react transitively) | | iOS / macOS native | Swift SDK | | iOS + Android (cross-platform) | `@pylonsync/react-native` (pulls sdk + react + sync transitively) | | Vite SPA, Vue, Svelte, Solid | `@pylonsync/sdk` + `@pylonsync/sync` (call directly, no React) | | CLI / server-to-server | `@pylonsync/sdk` (HTTP only) | | Real-time multiplayer | Same as above + `@pylonsync/react`'s `useShard` (or `PylonRealtime` on Swift) | | Collaborative editor | Add `@pylonsync/loro` (or `PylonSync` Loro bridge on Swift) | The SDKs share the same wire formats. You can use `@pylonsync/react` on the web, the Swift SDK on iOS, and `@pylonsync/react-native` on Android. They all agree about what a `ChangeEvent` looks like. CRDT bytes are identical across platforms, because they wrap the same Loro Rust core. ## Common quickstart Every SDK shares the same shape. The TypeScript flow: ```typescript theme={null} import { init, db, fetchList } from "@pylonsync/react"; import { sendMagicLink, verifyMagicLink } from "@pylonsync/client"; init({ baseUrl: "https://your-app.com" }); // Sign in await sendMagicLink("alice@example.com"); const session = await verifyMagicLink("alice@example.com", code); // Read entities (one-shot) const todos = await fetchList("Todo"); // Live updates + optimistic writes go through the global sync engine await db.insert("Todo", { title: "ship it", done: false }); ``` The Swift flow does the same: ```swift theme={null} import PylonClient import PylonSync let client = PylonClient(baseURL: URL(string: "https://your-app.com")!) try await client.startMagicCode(email: "alice@example.com") _ = try await client.verifyMagicCode(email: "alice@example.com", code: code) let cfg = SyncEngineConfig(baseURL: URL(string: "https://your-app.com")!) let engine = await SyncEngine(config: cfg, client: client) await engine.start() _ = await engine.insert("Todo", ["title": "ship it", "done": false]) ``` ## Codegen Every SDK supports typed access via `pylon codegen`: ```bash theme={null} # TypeScript (default) pylon codegen client pylon.manifest.json --out client.ts # Swift pylon codegen client pylon.manifest.json --target swift --out PylonGenerated.swift ``` Generated files emit: * **Entity types:** Codable structs (Swift), `interface` declarations (TS) per entity * **Function signatures:** typed args and results * **Typed client extensions:** `client.listTodos()`, `client.createTodo(NewTodo(...))` instead of stringly-typed APIs Run codegen on every manifest change. Add it to your build script. ## Wire compatibility guarantees Pylon treats wire format as a stability contract. It never breaks without a major version bump. The SDKs evolve independently of the binary; a v0.2.x client works against a v0.3.x server within the same minor. ## Where to next Pick your platform: [TypeScript](/clients/typescript), [React](/clients/react), [React Native](/clients/react-native), [Next.js](/clients/next), [Swift](/clients/swift), [sync engine](/clients/sync), or [Loro CRDTs](/clients/loro). # React SDK Source: https://docs.pylonsync.com/clients/react Use @pylonsync/react hooks for queries, mutations, sessions, search, presence, and multiplayer. `@pylonsync/react` is the React client. The `db.*` namespace exposes hooks that read from a local sync replica. Every component using a hook re-renders automatically when a row changes (your own mutation, a server push, a write from another tab). ## Install ```bash theme={null} bun add @pylonsync/react # or: npm i @pylonsync/react ``` ## Initialize once Pick any entry point that runs before your first hook (`src/main.tsx`, a Next.js root client provider, a top-level component effect): ```ts theme={null} import { init } from "@pylonsync/react"; init({ baseUrl: "http://localhost:4321" }); ``` In Next.js + Vercel, omit `baseUrl` (or pass `""`) so the client talks same-origin and the [Next rewrite](/operations/vercel) forwards `/api/*` to your Pylon backend. ## The `db.*` namespace `db` is the public API for apps using a single global sync engine. Every method below subscribes to or writes to the same shared replica. ### `db.useQuery` — live list ```tsx theme={null} import { db } from "@pylonsync/react"; type Todo = { id: string; title: string; done: boolean }; export function TodoList() { const { data: todos, loading, error } = db.useQuery<Todo>("Todo"); if (loading) return <Spinner />; if (error) return <ErrorBanner error={error} />; return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>; } ``` With filtering / ordering / limits: ```tsx theme={null} const { data } = db.useQuery<Todo>("Todo", { where: { authorId: "u_xyz", done: false }, orderBy: { createdAt: "desc" }, limit: 50, }); ``` ### `db.useQueryOne` — single row by ID ```tsx theme={null} const { data: post } = db.useQueryOne<Post>("Post", postId); ``` ### `db.useInfiniteQuery` — paginated with `loadMore` ```tsx theme={null} const { data, hasMore, loadMore, loading } = db.useInfiniteQuery<Post>("Post", { pageSize: 20, }); ``` ### `db.useReactiveQuery` — server-side query, auto re-runs For Convex-style server functions that the framework re-runs whenever their dependency set changes: ```tsx theme={null} const { data: feed, loading } = db.useReactiveQuery<FeedItem[]>("getFeed", { userId: currentUser.id, }); ``` The server records every `ctx.db.*` read inside your `query()` handler and re-runs the handler when any of those rows mutate. See [Reactive queries](/concepts/reactive-queries). ### `db.useMutation` — server function with optimistic updates ```tsx theme={null} const send = db.useMutation< { channelId: string; body: string }, { messageId: string } >("sendMessage", { optimistic: (args, ctx) => ({ entity: "Message", data: { id: ctx.id, channelId: args.channelId, body: args.body, authorId: me.id, createdAt: ctx.now, }, }), }); await send.mutate({ channelId, body: "Hi!" }); ``` The ghost row appears in `db.useQuery("Message", ...)` instantly; the server's broadcast reconciles in-place. See [Optimistic updates](/concepts/optimistic-updates). ### `db.useEntity` — optimistic CRUD bound to one entity When you'd rather call `insert/update/delete` directly than wire a server function: ```tsx theme={null} const { insert, update, remove } = db.useEntity("Todo"); await insert({ title: "Buy milk", done: false }); await update(todoId, { done: true }); await remove(todoId); ``` Same store + reconciliation as `db.useMutation`. ### `db.useSearch` — live faceted search ```tsx theme={null} const { hits, facetCounts, total } = db.useSearch<Product>("Product", { query: "red sneakers", filters: { category: "shoes" }, facets: ["brand", "color"], sort: ["price", "desc"], }); ``` Requires the `search` plugin enabled per-entity in your schema. See [Search](/concepts/search). ### `db.useAggregate` — live count / sum / avg / groupBy ```tsx theme={null} const { data: stats } = db.useAggregate("Order", { count: "*", sum: ["amount"], groupBy: ["status"], where: { customerId: "c_xyz" }, }); ``` `count` is `"*"` (or a column name for `COUNT(col)`); `sum` / `avg` / `min` / `max` take arrays of column names; `groupBy` takes an array of columns (or date-bucket specs). `data` is one row per group. ## Sessions ```tsx theme={null} import { useSession } from "@pylonsync/react"; import { db } from "@pylonsync/react"; function NavBar() { const { auth, signOut } = useSession(db.sync); if (!auth) return <a href="/login">Sign in</a>; return ( <> <span>{auth.user.displayName}</span> <button onClick={signOut}>Sign out</button> </> ); } ``` `useSession` returns a live `auth` object that re-renders on sign-in, sign-out, org switch, or remote session revoke. It also exposes `selectOrg`, `clearOrg`, and `refresh` for multi-tenant flows. ## Connection status ```tsx theme={null} import { useSyncStatus } from "@pylonsync/react"; function ConnectionIndicator() { const status = useSyncStatus(); // "connected" | "connecting" | "reconnecting" | "offline" if (status === "connected") return null; return <div className="banner">{status}…</div>; } ``` ## Presence + rooms ```tsx theme={null} import { useRoom } from "@pylonsync/react"; function Editor({ docId, userId }: { docId: string; userId: string }) { const room = useRoom(`doc:${docId}`, userId); return ( <> {room.peers.map((p) => ( <Cursor key={p.id} x={p.presence.x} y={p.presence.y} /> ))} <textarea onChange={(e) => room.setPresence({ cursor: e.target.selectionStart })} /> </> ); } ``` ## Multiplayer shards For tick-driven multiplayer (games, collaborative canvases): ```tsx theme={null} import { useShard } from "@pylonsync/react"; const { snapshot, send, connected } = useShard<GameState, Input>( `match_${matchId}`, { subscriberId: userId }, ); ``` See [Live queries → shards](/concepts/live-queries#shards). ## Imperative calls For one-shot reads/writes outside the React tree (effects, event handlers, server-rendered code): ```ts theme={null} import { db } from "@pylonsync/react"; const id = await db.insert("Todo", { title: "x", done: false }); await db.update("Todo", id, { done: true }); await db.delete("Todo", id); const result = await db.fn<{ ok: boolean }>("processOrder", { orderId }); for await (const chunk of db.streamFn("chat", { prompt: "hi" })) { console.log(chunk); } ``` These hit the local store optimistically (where applicable) and the server in the background. They use the same path as the hooks. ## Auth helpers For sign-in flows that the framework owns end-to-end (cookie + storage update + session refresh), use the [`@pylonsync/next/auth`](/clients/next#6-auth-flows---pylonsyncnextauth) helpers (works outside Next.js too; the auth module has no Next-specific deps despite the package path): ```ts theme={null} import { loginWithPassword, signupWithPassword, logout, startOAuthLogin, } from "@pylonsync/next/auth"; await loginWithPassword({ email, password }); await logout(); ``` For long-running clients, run the session auto-refresher to keep tokens fresh: ```ts theme={null} import { startSessionAutoRefresh } from "@pylonsync/react"; const cancel = startSessionAutoRefresh({ intervalSeconds: 300 }); // later: cancel() ``` ## TypeScript For end-to-end type safety from schema → hooks, use `createTypedDb` with your manifest's generated types: ```ts theme={null} import { createTypedDb } from "@pylonsync/react"; import type { Schema } from "./pylon.codegen"; export const typedDb = createTypedDb<Schema>(); // Now `typedDb.useQuery("Todo")` autocompletes the entity name + // types `data` as `Todo[]` automatically. ``` Run `pylon codegen client --target ts` to emit `Schema` from your `app.ts`. # React Native SDK Source: https://docs.pylonsync.com/clients/react-native Use @pylonsync/react-native with an Expo SQLite offline replica and AsyncStorage adapter. `@pylonsync/react-native` brings the React hooks to mobile. Same API as `@pylonsync/react` plus: * **AsyncStorage-backed durable replica:** the local sync replica survives app kills (via `AsyncStorageReplicaPersistence`, enabled by default) * **AsyncStorage bridge:** auth tokens + client id persist correctly on iOS / Android * **`useNetworkStatus`** hook for offline-aware UI * **Foreground/background lifecycle handling:** sync pauses when backgrounded, catches up on resume ## Install ```bash theme={null} bun add @pylonsync/sdk @pylonsync/react @pylonsync/react-native bun add @react-native-async-storage/async-storage @react-native-community/netinfo ``` `@react-native-async-storage/async-storage` and `@react-native-community/netinfo` are peer dependencies. For Expo projects you're done. For bare React Native, link the native modules per their docs. ## Configure Call `init()` from `@pylonsync/react-native` once before your first hook. It bootstraps the AsyncStorage bridge (tokens + client id), installs the durable `AsyncStorageReplicaPersistence`, and starts sync: ```typescript theme={null} import { init } from "@pylonsync/react-native"; // await it before rendering — otherwise the first paint renders against // an unauthenticated / empty cache. await init({ baseUrl: "https://your-app.com", appName: "myapp", }); ``` `init()` reads existing pylon keys (`pylon:<app>:client_id`, `pylon:<app>:token`, etc.) into an in-memory cache and writes through to AsyncStorage. The sync engine's storage interface is synchronous. The bridge keeps it that way without making your call sites async. Pass `persist: false` to skip the durable replica, or your own `persistence` adapter. ## Use the React hooks Same as web — use the `db.*` namespace (it wires the global engine for you) and `useSession(db.sync)`: ```tsx theme={null} import { db, useSession } from "@pylonsync/react-native"; function TodoScreen() { const { auth } = useSession(db.sync); const { data: todos } = db.useQuery("Todo"); const { insert, update, remove } = db.useEntity("Todo"); return ( <FlatList data={todos} keyExtractor={t => t.id} renderItem={({ item }) => ( <TodoRow todo={item} onToggle={() => update(item.id, { done: !item.done })} onDelete={() => remove(item.id)} /> )} /> ); } ``` ## Offline persistence The sync engine ships with `IndexedDBPersistence` for browsers; on RN you swap in the AsyncStorage-backed equivalent. `init()` already installs it by default — wire it manually only when you construct the engine yourself: ```typescript theme={null} import { createSyncEngine } from "@pylonsync/sync"; import { AsyncStorageReplicaPersistence, createAsyncStorageBridge, } from "@pylonsync/react-native"; const storage = await createAsyncStorageBridge(); const persistence = new AsyncStorageReplicaPersistence("myapp"); const engine = createSyncEngine("https://your-app.com", { storage, persistence, }); ``` Now: * The replica snapshot + cursor are persisted to AsyncStorage (durable across cold launches) * The mutation queue is persisted (offline writes survive app kill) * App startup hydrates from the persisted replica first, then catches up via pull The RN replica persistence mirrors the crash-safety model of the web client's IndexedDB path. The engine writes the replica before the cursor advances. A crash mid-pull cannot leave the cursor ahead of durable state. ## Network status ```tsx theme={null} import { useNetworkStatus } from "@pylonsync/react-native"; function Header() { const { isOnline, connectionType } = useNetworkStatus(); if (!isOnline) return <OfflineBanner />; if (connectionType === "cellular") return <SlowConnectionBanner />; return null; } ``` The sync engine continues to work offline — mutations queue locally and ship when the network returns. `useNetworkStatus` is for UI affordances. ## Foreground / background The engine listens for `AppState` changes: * **Background** → pause WebSocket; mutations still queue locally * **Foreground** → reconnect, pull missed changes, drain the queue Subscriptions to multiplayer shards close on background and re-subscribe on foreground (with the `crdt-subscribe` re-send the engine does on every reconnect, so binary CRDT frames keep arriving). For long-lived background sync (e.g. push-notification-triggered fetch), use the standalone `engine.pull()` from your background task handler. ## Higher-level offline store For cases where you want a manual cache outside the sync engine (e.g. cache derived data, store user preferences): ```typescript theme={null} import { OfflineStore } from "@pylonsync/react-native"; const store = new OfflineStore(); await store.saveEntities("FavoriteRecipes", recipes); const cached = await store.loadEntities("FavoriteRecipes"); ``` Backed by AsyncStorage with `pylon:` prefix. ## Performance * **Use `FlatList`/`SectionList`** with `useQuery` results. They re-render fully on every store change, so list virtualization matters. * **Memoize derived data** with `useMemo` to avoid re-computing on every store notify. * **Limit `useQuery` results.** For large entities, use `useInfiniteQuery` instead, so the screen doesn't render thousands of rows. * **Backgrounded sync** uses zero CPU. The engine pauses cleanly. ## Push notifications Pylon doesn't ship a push provider — use Expo Push, Firebase, OneSignal, or APNs/FCM directly. Pattern: 1. On sign-in, register the device token with your Pylon backend (`POST /api/fn/registerPushToken`). 2. In your function/cron, push when something interesting happens. 3. On notification tap, foreground the app — the sync engine catches up automatically. ## Tested combinations * Expo SDK 50+ * React Native 0.74+ (peer-dependency floor) * New Architecture (Fabric / TurboModules) — works * Hermes JS engine — works (no Loro CRDT WASM dependency in this package) * Bare React Native — works after manual native module linking ## Differences from web React | Feature | Web | RN | | -------------- | ------------------ | -------------------------------- | | Storage | `localStorage` | AsyncStorage bridge | | Persistence | IndexedDB | AsyncStorage replica | | Transport | WebSocket | WebSocket (polyfilled by RN) | | Network status | `navigator.onLine` | `useNetworkStatus` | | File upload | `Blob` / `File` | `FormData` with file URI | | Download | `Blob` URL | `expo-file-system` to local file | The hooks and call signatures are identical. # Swift SDK Source: https://docs.pylonsync.com/clients/swift Native Swift for iOS, macOS, tvOS, watchOS, and Linux. PylonClient, PylonSync, PylonRealtime, PylonSwiftUI. The Swift SDK gives you full TypeScript-equivalent functionality on Apple platforms (and Linux for server-side Swift). Auth, entity CRUD, optimistic mutations, real-time sync with offline replay, multiplayer shards, Loro CRDTs, and SwiftUI hooks are all native, with no JavaScript bridge. ## Modules | Module | What's in it | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `PylonClient` | HTTP client. Auth, entities, fns, files, search, aggregate, streaming. `actor`-isolated, swappable transport. | | `PylonSync` | Sync engine, `LocalStore`, `MutationQueue`, WebSocket / SSE / poll transports, SQLite persistence, `InfiniteQuery`, Loro CRDT bridge. | | `PylonRealtime` | `ShardClient<State, Input>` for tick-driven multiplayer shards. | | `PylonSwiftUI` | `PylonQuery`, `PylonMutation`, `PylonSession`, `PylonInfiniteQuery`, `PylonAggregate`, `PylonSearch` — `ObservableObject` wrappers. | ## Platforms | Target | Min version | | ------- | ---------------------------------------------------------------- | | iOS | 16 | | macOS | 13 | | tvOS | 16 | | watchOS | 9 | | Linux | Swift 5.9+ (uses `FoundationNetworking`; needs `libsqlite3-dev`) | ## Install In `Package.swift`: ```swift theme={null} .package(url: "https://github.com/pylonsync/pylon-swift.git", from: "0.3.0"), ``` Per target: ```swift theme={null} .target(name: "MyApp", dependencies: [ .product(name: "PylonClient", package: "pylon-swift"), .product(name: "PylonSync", package: "pylon-swift"), .product(name: "PylonRealtime", package: "pylon-swift"), // optional .product(name: "PylonSwiftUI", package: "pylon-swift"), // optional ]) ``` Linux: `apt-get install libsqlite3-dev` for the SQLite-backed offline replica. ## Quickstart ```swift theme={null} import PylonClient import PylonSync // 1. HTTP client — handles auth, entity CRUD, function calls let client = PylonClient(baseURL: URL(string: "https://your-app.com")!) try await client.startMagicCode(email: "alice@example.com") let session = try await client.verifyMagicCode(email: "alice@example.com", code: "123456") // session.token is persisted to UserDefaults automatically // 2. Sync engine — pulls + pushes + WebSocket/SSE reconnect with backoff let cfg = SyncEngineConfig( baseURL: URL(string: "https://your-app.com")!, transport: .websocket // or .sse / .poll ) let persistence = try SQLitePersistence( path: NSHomeDirectory() + "/Documents/pylon.db" ) let engine = await SyncEngine(config: cfg, client: client, persistence: persistence) await engine.start() // 3. Optimistic mutations — local store updates immediately, syncs in background _ = await engine.insert("Todo", ["title": "ship it", "done": false]) // 4. React to local-store changes let store = await engine.store _ = store.subscribe { let todos = store.list("Todo") print("now have \(todos.count) todos") } ``` ## SwiftUI ```swift theme={null} import SwiftUI import PylonSync import PylonSwiftUI struct Todo: Codable, Identifiable { let id: String let title: String let done: Bool } struct TodoListView: View { @StateObject var todos: PylonQuery<Todo> @StateObject var session: PylonSession @StateObject var addTodo: PylonMutation<NewTodo, Todo> init(engine: SyncEngine, client: PylonClient) { _todos = StateObject(wrappedValue: PylonQuery(engine: engine, entity: "Todo")) _session = StateObject(wrappedValue: PylonSession(engine: engine)) _addTodo = StateObject(wrappedValue: PylonMutation(client: client, name: "createTodo")) } var body: some View { NavigationStack { List(todos.rows) { todo in Text(todo.title) } .toolbar { Button("Add") { Task { try await addTodo.run(NewTodo(title: "new", done: false)) } } } .navigationTitle("Hello, \(session.session.userId ?? "guest")") } } } ``` ## Auth ```swift theme={null} // Magic code try await client.startMagicCode(email: "alice@example.com") let session = try await client.verifyMagicCode(email: "alice@example.com", code: "123456") // Password let session = try await client.signInWithPassword(email: ..., password: ...) // OAuth (use ASWebAuthenticationSession to capture the callback URL) let session = try await client.signInWithGoogle(idToken: idTokenFromGoogleSignIn) let session = try await client.signInWithGitHub(code: codeFromCallback) // Resolve current session let me = try await client.me() // me.userId, me.tenantId, me.isAdmin, me.roles // Sign out try await client.logout() // Long-lived auto-refresh let handle = await client.startSessionAutoRefresh(intervalSeconds: 300) // later: handle.cancel() ``` ## Entity CRUD ```swift theme={null} // Generic — pass any Decodable type let todos: [Todo] = try await client.list("Todo") let todo: Todo = try await client.get("Todo", id: "t1") struct NewTodo: Encodable { let title: String; let done: Bool } let created: Todo = try await client.create("Todo", NewTodo(title: "x", done: false)) let updated: Todo = try await client.update("Todo", id: "t1", ["done": true]) try await client.delete("Todo", id: "t1") // Cursor pagination let page: CursorPage<Todo> = try await client.listCursor("Todo", after: nil, limit: 50) ``` After codegen, the typed wrappers replace the stringly-typed APIs: ```swift theme={null} let todos = try await client.listTodos() let created = try await client.createTodo(NewTodo(title: "x", done: false)) try await client.deleteTodo(id: created.id) ``` ## Streaming functions ```swift theme={null} // Line-by-line — for NDJSON / SSE-flavored output for try await line in await client.streamFn("chat", args: ChatArgs(prompt: "tell me a joke")) { print(line) } // Raw bytes — for binary streams for try await chunk in await client.streamFnBytes("audio", args: AudioArgs(...)) { speaker.feed(chunk) } ``` ## Sync engine ```swift theme={null} // Pull on demand await engine.pull() // Push pending mutations await engine.push() // Optimistic mutations let id = await engine.insert("Todo", ["title": "x"]) await engine.update("Todo", id: id, ["done": true]) await engine.delete("Todo", id: id) // Subscribe to local-store changes let store = await engine.store let unsubscribe = store.subscribe { // React to changes } defer { unsubscribe() } // Identity flips (sign-in, tenant switch) reset the replica automatically await engine.notifySessionChanged() ``` ### Transports ```swift theme={null} // WebSocket (default) — primary, with bearer.<token> subprotocol .transport: .websocket // SSE fallback — for environments that block WebSocket .transport: .sse // Polling — last resort (disconnected clients, debugging) .transport: .poll ``` All three support full-jitter exponential backoff for reconnects. ### Pagination ```swift theme={null} let query: InfiniteQuery<Todo> = engine.createInfiniteQuery("Todo", pageSize: 25) let firstPage = try await query.loadMore() let secondPage = try await query.loadMore() let allRows = await query.data() let hasMore = await query.hasMorePages() ``` ## Files ```swift theme={null} // Upload — multipart/form-data with single `file` part let resp = try await client.uploadFile( data: Data("hello".utf8), filename: "greeting.txt", contentType: "text/plain" ) // resp.id, resp.url // Download let bytes = try await client.downloadFile(id: "file_xyz") ``` ## Multiplayer shards ```swift theme={null} import PylonRealtime struct GameState: Decodable, Sendable { let players: [Player] } struct Input: Encodable, Sendable { let action: String; let x: Double; let y: Double } let cfg = ShardClientConfig( baseURL: URL(string: "https://your-app.com")!, subscriberId: userId, token: try await client.currentToken() ) let shard = ShardClient<GameState, Input>(shardId: "match_42", config: cfg) await shard.connect() for await snap in await shard.snapshots() { print("tick \(snap.tick): \(snap.state.players.count) players") } try await shard.send(Input(action: "move", x: 10, y: 20)) ``` ## Loro CRDTs For collaborative text/lists/maps/trees, the SDK bridges to [`loro-swift`](https://github.com/loro-dev/loro-swift) (the official Swift binding for Loro): ```swift theme={null} import PylonSync import Loro let crdtDoc = PylonLoroDoc(entity: "Document", rowId: "doc_42") await crdtDoc.attach(to: engine) let textContainer = crdtDoc.doc.getText(id: "body") try textContainer.insert(pos: 0, s: "Hello, world!") // Local edits sync to other clients automatically via the binary CRDT channel. ``` The CRDT logic isn't reimplemented in Swift. `loro-swift` wraps the same Rust core as the JS `loro-crdt` package, so convergence is identical across platforms by construction. ## Codegen Generate typed structs from your manifest: ```bash theme={null} pylon codegen client pylon.manifest.json --target swift --out Sources/MyApp/PylonGenerated.swift ``` Produces: * `struct Todo: Codable, Identifiable, Equatable, Hashable { ... }` per entity * `struct NewTodo: Encodable { ... }` for create payloads (id-less variant) * `struct CreateTodoInput: Encodable { ... }` per action input * A `PylonClient` extension with typed `listTodos`, `createTodo`, `deleteTodo`, etc. * `enum PylonEntities { static let Todo = "Todo"; static let all: [String] = [...] }` Run on every manifest change. Add to your build script. ## Persistence `SQLitePersistence` mirrors the IndexedDB schema used by the web client: * `rows(entity, row_id, data)` — entity rows * `cursors(key, last_seq)` — sync cursor * `mutations(id, payload)` — offline write queue WAL mode for concurrent reads while writes happen. All access serialized through a `DispatchQueue` so async callers don't hold a sync lock across `await`. ## Background sessions For long-running uploads / downloads, use a background `URLSessionConfiguration`: ```swift theme={null} let cfg = URLSessionConfiguration.background(withIdentifier: "com.your-app.uploads") let session = URLSession(configuration: cfg) let transport = URLSessionTransport(session: session) let client = PylonClient( config: PylonClientConfig(baseURL: URL(string: "https://your-app.com")!), transport: transport ) ``` Use for large file uploads that should continue even when the app is backgrounded. ## Storage adapters Bearer tokens persist via `PylonStorage`. Default is `UserDefaults` on Apple platforms, in-memory on Linux. For Keychain-backed token storage: ```swift theme={null} import Security let storage = WriteThroughStorage(seed: loadFromKeychain()) { key, value in if let value { saveToKeychain(key: key, value: value) } else { deleteFromKeychain(key: key) } } let client = PylonClient( config: PylonClientConfig(baseURL: ...), storage: storage ) ``` ## Differences from the React/JS clients * **Actor isolation:** `PylonClient` and `SyncEngine` are `actor`s, so calls into them require `await` * **Codable everywhere:** entities decode into your Swift structs at the boundary; no JSON-as-`Any` values * **Background sessions:** Apple's `URLSession.background` works * **No bundler:** Swift Package Manager handles versioning, no webpack/esbuild * **Linux-compatible:** server-side Swift apps can use the same SDK ## Tests ```bash theme={null} cd packages/swift swift test ``` The package includes 43+ tests covering HTTP, sync engine, mutation queue, SQLite persistence, SSE parser, infinite query, streaming, and Loro frame decoding. ## Sample app [`examples/swift-todo`](https://github.com/pylonsync/pylon/tree/main/examples/swift-todo) — minimal SwiftUI iOS app consuming the `examples/todo-app` manifest. Sign-in, optimistic insert/delete, live updates over WebSocket. ## Where to next * **[Sync engine](/clients/sync):** wire format, transport details * **[Loro CRDTs](/clients/loro):** collaborative text/lists * **[Auth](/auth/overview):** endpoint reference # Sync engine Source: https://docs.pylonsync.com/clients/sync Use @pylonsync/sync for a local replica, optimistic mutations, realtime transports, and offline writes. `@pylonsync/sync` is the engine the React, React Native, and Next.js clients all wrap. You can use it standalone in any JavaScript host: Vue, Svelte, Solid, vanilla JS, Node, Bun, Tauri, Electron, or Cloudflare Workers. This page covers the engine's mental model, configuration, and direct API. For React-specific hooks see [React](/clients/react). ## Mental model The sync engine maintains an in-memory replica of the rows the server has shown you. That replica is: * **Server-authoritative:** every change has a `seq` number from the server's append-only change log * **Tombstone-aware:** deletes can't be resurrected by an out-of-order replay * **Optimistic:** local mutations update the replica immediately; a background queue ships them to the server with idempotency tokens * **Identity-flip-safe:** when the auth token or active tenant changes, the replica resets so you don't see stale rows from the previous identity * **Crash-safe:** the persistence layer (IndexedDB on web, an AsyncStorage-backed replica in `@pylonsync/react-native`, SQLite in the Swift SDK) writes through every change before advancing the cursor, so a crash mid-pull can never leave the cursor ahead of the durable replica The engine subscribes to the server via one of three transports: WebSocket (primary), SSE (fallback), or polling (last resort). All use full-jitter exponential reconnect. ## Install ```bash theme={null} bun add @pylonsync/sync ``` `@pylonsync/react` already includes it; install directly only when you're using the engine without React. ## Construct an engine ```typescript theme={null} import { createSyncEngine } from "@pylonsync/sync"; const engine = createSyncEngine("https://your-app.com", { transport: "websocket", // or "sse" or "poll" appName: "default", persist: true, // enables IndexedDB persistence }); await engine.start(); ``` `start()`: 1. Loads cached entities + cursor from IndexedDB 2. Hydrates the mutation queue (offline writes survive restart) 3. Resolves the current session via `/api/auth/me` 4. Pulls changes since the last cursor 5. Connects the chosen real-time transport ## Read the local store ```typescript theme={null} const todos = engine.store.list("Todo"); const todo = engine.store.get("Todo", "t_123"); // Subscribe — fires on every store change (server push, optimistic mutation, identity flip) const unsubscribe = engine.store.subscribe(() => { console.log("rows updated:", engine.store.list("Todo").length); }); // later: unsubscribe(); ``` The `store` always reflects the post-merged state. The engine applies server pushes and optimistic mutations before listeners fire. ## Optimistic mutations ```typescript theme={null} // Insert — generates a stable client-side id and returns it. Pylon ids are // client-generated, so this id is canonical: the server doesn't reassign it. const id = await engine.insert("Todo", { title: "x", done: false }); // Update — applies locally, queues the change await engine.update("Todo", id, { done: true }); // Delete — applies locally, queues the change await engine.delete("Todo", id); ``` If the server rejects a mutation, the engine marks it `failed` in the queue (`engine.mutations`, a `MutationQueue`) and keeps it there. It isn't silently dropped. Look one up by op id with `engine.mutations.get(id)` (its `.status` is `"pending" | "applied" | "failed"`, with `.error` set on failure), and drop it once resolved: ```typescript theme={null} const m = engine.mutations.get(opId); if (m?.status === "failed") { toast.error(`write failed: ${m.error}`); engine.mutations.remove(opId); // clear it after showing a retry affordance } ``` For a simpler per-call API, prefer the React [`db.useMutation`](/clients/react) hook. It exposes `loading` / `data` / `error` for the specific call, without reaching into the queue. ## Pagination ```typescript theme={null} const query = engine.createInfiniteQuery("Post", { pageSize: 20 }); await query.loadMore(); console.log(query.data); // first 20 rows await query.loadMore(); console.log(query.data); // first 40 query.subscribe(() => render()); query.reset(); // start over ``` ## Hydration ```typescript theme={null} import { getServerData } from "@pylonsync/sync"; // On the server (e.g. Next.js loader, Astro page): const hydration = await getServerData("https://your-app.com", ["Todo", "User"], { token: req.cookies.pylon_token, }); // On the client: engine.hydrate(hydration); await engine.start(); ``` `hydrate(...)` seeds the local store and the cursor before `start()` runs, so the first paint is server-rendered and the engine doesn't waste an initial pull. ## Auth integration The engine reads the bearer token from the storage adapter on every request. To set one: ```typescript theme={null} import { defaultStorage } from "@pylonsync/sync"; const storage = defaultStorage(); // localStorage on web, in-memory elsewhere storage.set("pylon_token", "pylon_a1b2c3..."); ``` Or inject a custom adapter (RN's AsyncStorage bridge, a Tauri-store wrapper): ```typescript theme={null} import { createWriteThroughStorage, createSyncEngine } from "@pylonsync/sync"; const storage = createWriteThroughStorage(seed, async (key, value) => { if (value === null) await asyncBackend.remove(key); else await asyncBackend.set(key, value); }); const engine = createSyncEngine("https://your-app.com", { storage }); ``` The server pushes a `session-changed` envelope over WS whenever the session is mutated (select-org, clear-org, session revoke, even from other tabs or admin tools), and the engine refreshes `/api/auth/me` automatically. Tab A switching tenant updates Tab B's `useSession` and Tab B's replica without Tab B's app code doing anything. If you mutate a session via a path that bypasses the framework's auth path (rare, e.g. writing directly to SessionStore from a Rust plugin), call `engine.notifySessionChanged()` to force the refresh. ## Multi-tenant: switching active org Use the engine's built-in helper — it POSTs `/api/auth/select-org`, verifies membership server-side, resets the local replica so stale rows from the previous tenant disappear, and refreshes the cached session in one call: ```typescript theme={null} await engine.selectOrg("org_xyz"); // engine.resolvedSession() now reflects the new tenantId // the local replica is reset because the visible set changed ``` React apps get the same helpers off `useSession()`: ```typescript theme={null} const { selectOrg, clearOrg, signOut } = useSession(engine); await selectOrg("org_xyz"); // switch active org await clearOrg(); // drop active org (back to no-tenant state) await signOut(); // revoke session server-side ``` All three throw on non-2xx with `.code` (e.g. `"NOT_A_MEMBER"`) and `.status` carrying the server's response, so UI code can branch on the specific error without re-parsing the body. ## Real-time transport details ### WebSocket (primary) The engine derives the URL from `baseUrl`. It defaults to `ws://host:port+1` for `pylon dev` (port + 1 is the WS port). Override it via `wsUrl`: ```typescript theme={null} createSyncEngine("https://your-app.com", { wsUrl: "wss://ws.your-app.com", // when WS is on a separate hostname }); ``` The engine sends the auth token via the `bearer.<percent-encoded-token>` Sec-WebSocket-Protocol subprotocol because browser WebSocket has no header API. Each text frame is a JSON `ChangeEvent`; binary frames go to whichever consumer registered via `engine.onBinaryFrame(handler)`. The Loro CRDT integration uses this for binary CRDT updates. Reconnect uses full-jitter exponential backoff: `random(0, base * 2^attempts)`, capped at 30s. The backoff counter resets only after a connection has been stable for 5s, so an auth-failure-then-disconnect loop can't tight-loop reconnect. ### SSE (fallback) `transport: "sse"` connects to `http://host:port+2/events` (port + 2). The server emits one JSON `ChangeEvent` per `data:` line. Use SSE when: * The deployment blocks WebSocket (rare, but some corporate proxies do) * You only need one-way server→client (no presence, no shard inputs) The reconnect backoff matches the WebSocket path. ### Polling (last resort) `transport: "poll"` calls `/api/sync/pull` every `pollInterval` ms. Use only when SSE and WebSocket are both unavailable. ## CRDT subscriptions For collaborative rows backed by Loro CRDTs: ```typescript theme={null} engine.subscribeCrdt("Document", "doc_42"); const unregister = engine.onBinaryFrame((bytes) => { // route to your Loro decoder — see @pylonsync/loro }); // later engine.unsubscribeCrdt("Document", "doc_42"); unregister(); ``` Subscriptions are **refcounted** — two `useLoroDoc` callers on the same row don't unsubscribe each other when one unmounts. The engine re-sends active subscriptions on every reconnect so binary frames keep arriving on a fresh socket. See [Loro](/clients/loro) for the higher-level integration. ## Presence + topics ```typescript theme={null} // Broadcast presence (typically cursor position, "typing" status) engine.setPresence({ x: 100, y: 200, label: "Alice" }); // Publish to a topic engine.publishTopic("chat:room_42", { from: "alice", text: "hi" }); ``` Both ride the same WebSocket. Subscribers see updates via the store notifier (presence) or by registering their own handler (topics). ## Persistence The engine writes through to IndexedDB by default in browsers. The schema: * `entities` store — keyed `entity:row_id`, value `{ entity, id, data }` * `cursors` store — keyed `cursor`, value `{ last_seq }` * `pendingMutations` store — keyed `id`, value `{ id, change, status, error? }` On startup, the engine loads every entity row and the cursor, then catches up via pull. Mutations queued offline are hydrated and pushed on the next `push()` tick. For non-browser hosts (RN, Tauri, Electron, native Swift), pass a different persistence backend — `@pylonsync/react-native` ships `AsyncStorageReplicaPersistence`; the Swift SDK has its own SQLite implementation. ## Resetting ```typescript theme={null} // Drop everything — useful for sign-out flows await engine.resetReplica(); // then re-pull or re-start ``` `resetReplica()` clears the in-memory store, sets the cursor to 0, and persists both. Doesn't trigger a pull — the caller decides when. ## Configuration reference ```typescript theme={null} type SyncEngineConfig = { baseUrl: string; transport?: "websocket" | "sse" | "poll"; // default "websocket" wsUrl?: string; // override WS URL pollInterval?: number; // default 1000ms (poll mode) reconnectDelay?: number; // default 1000ms (backoff base) token?: string; // overrides storage persist?: boolean; // default true in browser appName?: string; // default "default" storage?: Storage; // sync key-value adapter }; ``` ## Direct calls (skip the engine) For one-off operations that don't need the local store, use the low-level `pylonFetch` primitive — it builds the request (base URL + bearer token) and parses JSON, without touching the engine's replica: ```typescript theme={null} import { pylonFetch } from "@pylonsync/sync"; const config = { baseUrl: "https://your-app.com", token }; const todos = await pylonFetch<Todo[]>(config, "/api/entities/Todo"); await pylonFetch(config, "/api/entities/Todo", { method: "POST", body: JSON.stringify({ title: "x", done: false }), }); ``` These hit the same endpoints but don't update the engine's local store. Use for server-side scripts or operations the user shouldn't see in the UI. (`@pylonsync/react` also ships convenience `fetchList` / `fetchById` wrappers that read the configured client's base URL + token for you.) # TypeScript SDK Source: https://docs.pylonsync.com/clients/typescript Define schemas, build manifests, and generate types with @pylonsync/sdk. `@pylonsync/sdk` is the foundation every other JS package depends on. It is two things in one package: 1. **A schema DSL** for declaring entities, queries, actions, policies in TypeScript instead of editing `pylon.manifest.json` by hand. 2. **The codegen runtime** that compiles your TS schema into the manifest the Pylon server reads. This package has no HTTP client. For that, use `@pylonsync/react` (browser) or `@pylonsync/sync` (any JS host). ## Install <CodeGroup> ```bash bun theme={null} bun add @pylonsync/sdk ``` ```bash npm theme={null} npm install @pylonsync/sdk ``` ```bash pnpm theme={null} pnpm add @pylonsync/sdk ``` </CodeGroup> ## Defining your schema Create `app.ts`: ```typescript theme={null} import { buildManifest, entity, field, query, action, policy, } from "@pylonsync/sdk"; const User = entity("User", { email: field.string().unique(), displayName: field.string(), passwordHash: field.string().serverOnly().optional(), createdAt: field.datetime().defaultNow(), }); const Todo = entity("Todo", { title: field.string(), done: field.bool().default(false), authorId: field.id("User"), createdAt:field.datetime().defaultNow(), }, { indexes: [{ name: "by_author", fields: ["authorId"] }], }); const todosByAuthor = query("todosByAuthor", { input: [{ name: "authorId", type: "id(User)" }], }); const createTodo = action("createTodo", { input: [ { name: "title", type: "string" }, { name: "authorId", type: "id(User)" }, ], }); const todoPolicy = policy({ entity: "Todo", allowRead: "auth.userId != null && data.authorId == auth.userId", allowWrite: "auth.userId == data.authorId", }); export default buildManifest({ name: "todo-app", version: "0.1.0", entities: [User, Todo], queries: [todosByAuthor], actions: [createTodo], policies: [todoPolicy], routes: [], }); ``` Fields are built with the `field` helper (`field.string()`, `field.int()`, `field.id("User")`, …) and refined with chained modifiers (`.unique()`, `.optional()`, `.default(v)`, `.defaultNow()`, `.owner()`, `.serverOnly()`, `.crdt(...)`). `query` / `action` register a name plus a typed `input` list; the handler itself lives in a `functions/*.ts` file (see [Actions](#actions)). Then run codegen to materialize the manifest: ```bash theme={null} pylon codegen app.ts --out pylon.manifest.json ``` `pylon dev` does this automatically on file change. ## Field types | DSL | Wire type | Notes | | ---------------------------------- | --------- | ---------------------------------------- | | `field.string()` | `string` | UTF-8 text | | `field.int()` | `number` | 64-bit signed | | `field.float()` (`field.number()`) | `number` | 64-bit float | | `field.bool()` (`field.boolean()`) | `boolean` | | | `field.datetime()` | `string` | ISO 8601 | | `field.richtext()` | `string` | LoroText by default; rich text in Studio | | `field.id("User")` | `string` | Reference to another entity | | `field.enum(["a", "b"])` | `string` | Codegen emits a literal union | Modifiers are chained methods, not option objects: ```typescript theme={null} field.string().unique() // enforce uniqueness field.string().optional() // nullable field.bool().default(false) // static insert-time default field.datetime().defaultNow() // stamp now() on insert field.string().owner() // stamp the row owner from the session field.string().serverOnly() // read on the server, never serialized to clients ``` For CRDT-backed fields, use `.crdt(annotation)`: ```typescript theme={null} field.string().crdt("text") // LoroText — collaborative text (or field.richtext()) field.int().crdt("counter") // LoroCounter — multi-writer increments ``` `"text"` and `"counter"` are wired end-to-end; the `"list"`, `"movable-list"`, and `"tree"` annotations are reserved (wire format locked in, server-side projection still landing). CRDT-backed fields do not go through normal LWW merge; they sync via the binary CRDT broadcast channel. See [Loro](/clients/loro) for the full picture. ## Indexes ```typescript theme={null} entity("Todo", { authorId: field.id("User"), status: field.string(), createdAt:field.datetime().defaultNow(), }, { indexes: [ { name: "by_author", fields: ["authorId"] }, { name: "by_status_date", fields: ["status", "createdAt"] }, { name: "unique_slug", fields: ["slug"], unique: true }, ], }); ``` The first matching prefix on a multi-column index wins for query planning. Pylon translates these to native SQLite/Postgres indexes. ## Search config ```typescript theme={null} entity("Post", { title: field.string(), body: field.richtext(), tags: field.string(), authorId: field.id("User"), }, { search: { text: ["title", "body"], facets: ["authorId", "tags"], sortable: ["createdAt", "viewCount"], }, }); ``` This wires the entity into the [`search` plugin](/plugins/search-ai). Once enabled, the entity is queryable via `POST /api/search/Post`. ## Relations ```typescript theme={null} import { entity, field, relation } from "@pylonsync/sdk"; const Post = entity("Post", { title: field.string(), authorId: field.id("User"), }, { relations: [ // name = accessor on the parent, target = entity, field = the FK on the target relation({ name: "comments", target: "Comment", field: "postId", many: true }), ], }); const Comment = entity("Comment", { postId: field.id("Post"), body: field.string(), }); ``` Relations enable `include` joins on queries. On the client, request them through the query's `include` map (an object keyed by relation name): ```typescript theme={null} import { db } from "@pylonsync/react"; const { data: posts } = db.useQuery("Post", { include: { comments: {} } }); // each post.comments is a Comment[] ``` ## Queries Named, typed query inputs. Each `input` entry is `{ name, type, optional? }`, where `type` is a field-type string (`"string"`, `"int"`, `` `id(User)` ``, …). Resolve to `/api/query/<name>`: ```typescript theme={null} const recentTodos = query("recentTodos", { input: [{ name: "limit", type: "int", optional: true }], }); const todosByAuthor = query("todosByAuthor", { input: [{ name: "authorId", type: "id(User)" }], }); ``` `query` / `action` register the name and input contract; the handler lives in a `functions/<name>.ts` file. Use a `query()` handler for reads and a `mutation()` / `action()` handler for writes and computed results. ## Actions Server-side functions with typed args. Resolve to `/api/fn/<name>`: ```typescript theme={null} const completeTodo = action("completeTodo", { input: [{ name: "todoId", type: "id(Todo)" }], }); ``` The action's *handler* lives in a separate file (`functions/completeTodo.ts`) and the function runtime wires it up: ```typescript theme={null} // functions/completeTodo.ts import { mutation, v } from "@pylonsync/functions"; export default mutation({ args: { todoId: v.string() }, async handler(ctx, args) { if (!ctx.auth.userId) throw new Error("sign in required"); const todo = await ctx.db.get("Todo", args.todoId); if (!todo || todo.authorId !== ctx.auth.userId) throw new Error("forbidden"); await ctx.db.update("Todo", args.todoId, { done: true }); }, }); ``` See [Functions](/concepts/functions) for the full handler API. ## Policies ```typescript theme={null} policy({ entity: "Todo", allowRead: "auth.userId != null && data.authorId == auth.userId", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == data.authorId", allowDelete: "auth.userId == data.authorId || auth.hasRole('admin')", }) ``` Each rule is a per-operation gate: `allowRead`, `allowInsert`, `allowUpdate`, `allowDelete`. `allowWrite` is a shared fallback for the three write operations, and `allow` is the fallback for all four. See [RBAC](/auth/rbac) for the expression syntax. ## Plugins `definePlugin({ name, hooks })` returns a `PluginDefinition`, a named set of server-side entity lifecycle hooks: ```typescript theme={null} import { definePlugin } from "@pylonsync/sdk"; const audit = definePlugin({ name: "audit", hooks: { // Return modified data to rewrite the row, or null to abort the write. beforeInsert: (entity, data) => ({ ...data, createdVia: "api" }), afterUpdate: (entity, id, data) => console.log(`${entity} ${id} updated`), }, }); ``` Available hooks: `beforeInsert`, `afterInsert`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`. Many capabilities do not need a plugin at all. They are declarative: full-text search is an entity's `search:` option (above), and auth / scheduled jobs are configured with the `auth()` / `cron()` helpers passed to `buildManifest`. ## Manifest output `buildManifest({...})` returns a `Manifest` object. The codegen step writes it to JSON: ```jsonc theme={null} // pylon.manifest.json (generated) { "manifest_version": 1, "name": "todo-app", "version": "0.1.0", "entities": [...], "routes": [...], "queries": [...], "actions": [...], "policies": [...], "auth": { ... } } ``` The Pylon server reads only the JSON. Your TypeScript source is not needed at runtime. This is what lets the same backend serve clients in any language. ## Where to next * **Browser clients** → [`@pylonsync/react`](/clients/react) * **Mobile** → [`@pylonsync/react-native`](/clients/react-native) or [Swift](/clients/swift) * **Server-rendered** → [`@pylonsync/next`](/clients/next) * **Sync engine on its own** → [`@pylonsync/sync`](/clients/sync) # Pylon Cloud Source: https://docs.pylonsync.com/cloud Hosted Pylon on Smallware. Pylon is fully self-hostable, but if you would rather not run it yourself, Smallware at [www.usesmallware.com](https://www.usesmallware.com) hosts the same app you would run on a VPS. Connect a GitHub repo and push to your default branch. The project goes live at `https://your-app.smallware.run`. ## When to use it | You should use Cloud if... | You should self-host if... | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | You want to ship today, not learn operations | You have strict data residency or on-premises requirements | | You do not want to manage Postgres backups, TLS, and scaling | You already operate Kubernetes, Nomad, or your own VPS fleet | | You would rather pay a flat platform fee and per-machine compute than pay per server | Your workload spikes hard enough that reserved capacity is cheaper | | You want managed magic-link email and OAuth credentials built in | You need to run an air-gapped deployment | Both targets run the same binary. You can start on Cloud and migrate to self-hosted (or back) without touching client code. Only the `baseUrl` changes. ## Sign up 1. Visit [www.usesmallware.com](https://www.usesmallware.com) and create an account. 2. Create a project. The project gets a stable URL like `https://your-app.smallware.run`. 3. Add a payment method (free Hobby tier covers small projects; see pricing below). ## Deploy There are two ways to deploy to Cloud: ### 1. Push to GitHub (recommended) Install the Pylon Cloud GitHub App on your repo from the project settings page. Every push to your project's default branch triggers a build and deploy. Pull-request pushes get a preview environment that tears down on merge. ```sh theme={null} git push origin main # → Cloud's webhook fires # → build runs on the platform # → schema migrates, traffic cuts over # → live at https://your-app.smallware.run ``` You can also trigger a manual deploy of any branch from the project's dashboard. Use this to redeploy without a fresh push, or to ship a feature branch to the project's machine for testing. ### 2. Manual deploy from the dashboard Project page → **Deploy** → pick a branch. The control plane resolves the branch head against your installed GitHub App and runs the same build pipeline as a webhook deploy. ### Custom domains From the project's **Domains** tab, add `app.example.com`. Cloud provisions a TLS certificate automatically. Add the displayed `CNAME` record to your DNS. Wait for verification. The custom domain then goes live, and the platform `*.smallware.run` URL stays valid alongside it. ### Environment variables Project page → **Settings** → **Environment**. Set per-project secrets without committing them to git. They appear in your TypeScript functions as `process.env.STRIPE_SECRET_KEY`. Pylon encrypts them at rest and never logs them. ### Hand off to a coding agent Every org page in Pylon Cloud has a **Hand off to your coding agent** card. Click it and paste the generated prompt into Claude Code (or Codex, OpenCode, Cursor, Aider, or grok build). The agent installs the CLI, signs in, loads the Pylon skill, and asks what you want to build. Pylon exchanges the session token through a 5-minute single-use code, so your `pk.*` API key never appears in the chat history. See [Agent handoff](/operations/agent-handoff) for the full flow. ### Pair with Vercel for the frontend The standard Pylon production stack runs the frontend on Vercel and the backend on Pylon Cloud. Vercel builds and serves your Next.js, SvelteKit, or other frontend on its global edge network. Pylon Cloud runs the realtime backend, database, and auth. Set `PYLON_TARGET=https://your-app.smallware.run` (your project's Cloud URL) in Vercel's project environment variables. The Next.js template's rewrite then forwards `/api/*` to your Pylon backend. The browser talks to one origin, so session cookies work with no CORS setup needed. Full checklist: [Deploying to Vercel](/operations/vercel). ## What's included * **Same binary as `pylon dev`:** no runtime hooks, no vendor proxies. * **Postgres or SQLite:** choose at project creation. SQLite ships on a Fly volume. Pylon provisions Postgres as a managed sibling service. * **TLS:** automatic certificates on `*.smallware.run` and your custom domains. * **Magic-link email:** managed transactional sender. No SendGrid or Resend account required. * **OAuth:** bring your own Google, GitHub, or other OAuth credentials. * **WebSocket, SSE, and shard ports:** exposed automatically. Clients connect to the public URL. * **File storage:** stored in Amazon S3, sized by your plan. * **Studio:** every project has the inspector available at `https://your-app.smallware.run/studio`. * **Logs and metrics:** request logs, function traces, and latency and error charts in the dashboard. * **On-demand backups:** Fly volume snapshots from the project's **Backups** tab. Pro and Team plans store backups longer. ## Scaling Pylon Cloud runs on Fly Machines. Every dashboard setting maps to a real infrastructure call: * **Machine size:** `shared-cpu-1x · 512 MB` baseline. Resize up to 4 GB on Pro, 8 GB on Team. * **Volume size:** up to 100 GB on Pro, 500 GB on Team. * **Autostop:** locked on for the free tier (the machine sleeps when idle and wakes on the next request). You can toggle it on Pro and higher if you need always-warm WebSockets. * **HA replicas:** available for Postgres projects on paid plans. Cloud configures the built-in PylonSync relay. You do not need Redis. Autostop is off while a project has more than one application machine. * **Region:** pick from any of 13 regions: | Region | Code | | ------------------------- | ----- | | US East (Ashburn) | `iad` | | US Central (Chicago) | `ord` | | US West (San Jose) | `sjc` | | US West (Los Angeles) | `lax` | | US Northwest (Seattle) | `sea` | | EU (London) | `lhr` | | EU (Frankfurt) | `fra` | | EU (Amsterdam) | `ams` | | Australia (Sydney) | `syd` | | Asia (Singapore) | `sin` | | Asia (Tokyo) | `nrt` | | South America (São Paulo) | `gru` | | Canada (Toronto) | `yyz` | Cloud copies each release and secret update to all application machines. The managed relay sends changes, presence, session changes, and CRDT frames between them. See [Horizontal scaling](/operations/scaling) for the data and runtime model. Jobs and workflows use the project's shared Postgres database. A replica can recover work after another replica stops. Job execution is at least once, so handlers must be idempotent. ## Pricing Each tier has a flat platform fee plus per-machine compute. Hard quotas (requests, CPU-hours, egress) cap monthly usage. Projects pause at the quota until the calendar month rolls over or you upgrade. ### Hobby (\$0) The Hobby plan has fixed limits: * 1 project per organization * `shared-cpu-1x · 512 MB · 3 GB` volume, one machine, autostop forced on * 100k requests / month * 1 CPU-hour / month * 5 GB egress / month * Single region, SQLite only * Community support ### Pro (\$20 / org / month) Includes one baseline 512 MB machine, 10 GB volume, 50 GB egress, all features (custom domains, OIDC SSO, audit log, autostop toggle). * Up to 10 projects per organization, 5 organizations per user * Quotas: 5M requests, 50 CPU-hours, 250 GB egress per month * Per-machine pricing: * 1 GB always-warm: \$14 / month * 2 GB always-warm: \$25 / month * 4 GB always-warm: \$50 / month (max) * Volume above 10 GB: \$0.25 / GB-month * Egress above 250 GB: \$0.06 / GB ### Team (\$99 / org / month) Includes one baseline 1 GB machine, 50 GB volume, 250 GB egress. * Up to 50 projects per organization, 20 organizations per user * Quotas: 50M requests, 500 CPU-hours, 2 TB egress per month * Per-machine pricing: * 1 GB always-warm: \$12 / month * 2 GB always-warm: \$22 / month * 4 GB always-warm: \$42 / month * 8 GB always-warm: \$110 / month (max) * Volume above 50 GB: \$0.20 / GB-month * Egress above 2 TB: \$0.04 / GB * SAML SSO, audit log, role-based access ### Enterprise Custom quotas, single-tenant Fly org or BYOC (AWS, GCP), custom regions, SLA and on-call escalation, migration assistance. Email `cloud@pylonsync.com`. Live pricing on the [pricing page](https://www.pylonsync.com/#pricing). ## Migrating from self-hosted `pylon backup` and `pylon restore` (in the framework CLI) snapshot the SQLite database and uploads on a local Fly volume. To migrate into Cloud: ```sh theme={null} # 1. Snapshot your local data pylon backup ./snapshot/ # 2. Create the project in the dashboard at www.usesmallware.com, # with the SAME schema you've been running locally. # 3. Connect your GitHub repo and push — Cloud builds + deploys the # same binary you'd run locally. # 4. Use the dashboard's "Restore from snapshot" flow on the # project's Backups tab to upload ./snapshot/. ``` Then point your clients at the new `baseUrl` (your project's `smallware.run` URL or custom domain). The session store is part of the snapshot, so sessions carry over and users stay signed in. ## Migrating to self-hosted ```sh theme={null} # 1. Snapshot from Cloud — Backups tab → "Download snapshot" # or trigger a backup first with the "Snapshot now" button. # 2. Restore into your own deployment locally or on a VPS. pylon restore ./snapshot/ ``` There is no lock-in. The binary and the data format are the same either way. ## Troubleshooting **Push did not trigger a deploy.** Confirm the Pylon Cloud GitHub App is installed on the repo (Project Settings → GitHub) and the push is to the configured default branch. The Deployments tab shows webhook receipt. If nothing arrived there, the webhook never fired. **Custom domain stuck on "verifying".** DNS propagation can take up to 24 hours. `dig CNAME app.example.com` should show `customers.smallware.run`. **Magic-link emails not arriving.** Check the spam folder for the first send (Cloud uses a shared sender domain that warms up over time). For production, configure your own sender domain in **Settings → Email** to authenticate with SPF, DKIM, and DMARC under your domain. **WebSocket disconnects.** Cloud's load balancer holds connections for up to 4 hours. The sync engine reconnects automatically, using full-jitter backoff. If you see frequent drops, check **Connections** in the project dashboard. **Hit a monthly quota.** The project pauses at the limit until the calendar month rolls over (UTC) or you upgrade the organization's plan. The dashboard shows current usage on the project's overview page. ## Status [status.pylonsync.com](https://status.pylonsync.com) shows live region health and incident history. # Pylon vs. Colyseus Source: https://docs.pylonsync.com/compare/colyseus Colyseus is the canonical Node game server. Pylon ships game shards alongside app data. [Colyseus](https://colyseus.io) is a widely used open-source multiplayer game server framework for Node.js. It handles the "rooms + tick-based state sync" model well. Pylon's game shards (`Shard<S: SimState>`) are inspired by Colyseus's `Room` API. ## Summary * **Choose Colyseus** if your game is the whole product, you're already deep in Node, and you don't need an app database / auth / file storage backing it. * **Choose Pylon** if your game is one feature in a larger app, you want game state + app data + auth in one binary, or you don't want to operate a Node server. ## Architecture | | Pylon | Colyseus | | ---------------------------- | ----------------------------------- | ------------------------------------ | | Runtime | Rust binary | Node.js | | Game model | `Shard<S: SimState>` with tick loop | `Room` with `setSimulationInterval` | | State sync | Snapshot delta over WebSocket | Schema-encoded deltas over WebSocket | | Auth | Built-in | Bring your own | | Database | SQLite/Postgres built-in | Bring your own | | File storage | Built-in | Bring your own | | Live queries (non-game data) | ✅ | ❌ | | Single binary | ✅ | ⚠️ Node + your code | ## Game-loop primitives Both: * Run a fixed-rate tick loop server-side (Colyseus: `setSimulationInterval(ms)`, Pylon: `tick_rate_hz` in `ShardConfig`) * Maintain authoritative state on the server * Broadcast snapshot deltas to subscribed clients * Accept inputs from clients (Colyseus: `onMessage` handlers; Pylon: `apply_input_json`) * Support reconnection * Have built-in matchmakers ## Where Colyseus is better * **More mature** — longer production history, more integrations, more examples for specific game genres. * **Schema encoding** — Colyseus's `@type` schema decorators produce compact binary deltas. Pylon's shard payloads are simpler JSON today; Colyseus is usually more bandwidth-efficient at scale. * **Game engine SDKs** — Unity SDK support and codegen tooling. Pylon's realtime client today is JS + Swift; for Unity you'd use Pylon's WebSocket protocol manually. * **Game-specific primitives** — rooms, matchmaking, state-sync patterns, and per-client state views are mature. * **Larger community** — more tutorials and answers for game-dev specific problems. ## Where Pylon is better * **Backend in one binary** — Colyseus is "your game state". You need Postgres or another DB for player profiles, leaderboards, cosmetic inventory, and a separate auth system. Pylon ships all of that in the same process. * **Live queries for UI** — Pylon's sync engine + `useQuery` lets you build the lobby UI, friend list, leaderboard, etc. with server-authoritative reactive data. Colyseus rooms can broadcast state, but they're not designed for "show me all my friends online" queries. * **Auth + sessions** — magic codes, OAuth, RBAC, all in the binary. Colyseus has middleware hooks but you build the auth flow. * **Single deployment unit** — one Rust binary on a VPS. Colyseus needs Node + your DB + your auth service + your storage. * **Performance ceiling** — Rust runs faster than Node for tick loops with non-trivial physics. For pure state-broadcast games (where the bottleneck is the network), they're comparable. * **AOI built-in** — Pylon's `area_of_interest` is part of `Shard`. Colyseus supports per-client filtering through StateView patterns, but spatial AOI is not the same high-level primitive. ## Self-host shape | | Pylon | Colyseus | | ---------------- | ------------------------- | ------------------------------------------------------------------- | | Install | `cargo install pylon-cli` | `npm install colyseus` + write your server | | Run | `pylon serve` | `node index.js` | | Backing services | None (SQLite included) | Redis/presence driver for multi-process; app DB is whatever you add | | Process model | Single binary | Node app + dependencies | Both deploy to a Linux VPS. Colyseus's "standard Node.js application" is straightforward to deploy; Pylon ships as one Rust binary (it spawns a bundled Bun runtime for any TypeScript functions/SSR you add), which keeps the deploy simple. ## Use case fit | If you're building... | Recommended | | -------------------------------------------------------------- | ------------------------------------------------ | | MMO with persistent world + characters | Pylon (game shards + entities + auth in one) | | Quick web/mobile multiplayer (.io game style) | Either; Colyseus for Unity, Pylon for web | | Turn-based strategy with matchmaking | Either | | Real-time twitch shooter | Pylon (Rust tick loop) or Colyseus (mature Node) | | Game with deep social features (chat, leaderboards, inventory) | Pylon (built-in) | | Existing Node app adding multiplayer | Colyseus (drop into your stack) | | Game where the backend is the product | Colyseus | | Game that's one feature in a broader app | Pylon | ## Migrating from Colyseus The mental model translates almost directly: ```typescript theme={null} // Colyseus class GameRoom extends Room<State> { onCreate() { this.setSimulationInterval(dt => this.update(dt), 1000 / 20); } onMessage("move", (client, data) => { /* ... */ }); update(dt) { /* tick logic */ } } ``` ```rust theme={null} // Pylon struct GameState { /* ... */ } impl SimState for GameState { fn tick(&mut self, dt: f32) { /* tick logic */ } fn apply_input(&mut self, client_id: &str, input: &str) { /* ... */ } fn snapshot(&self) -> serde_json::Value { /* ... */ } } let shard = Shard::new("match_42", GameState::default(), ShardConfig { tick_rate_hz: 20, ..Default::default() }); ``` The Pylon side is Rust, not TS. That is the largest porting cost. If your team is JS-only, that matters; if you're polyglot, the migration is straightforward. ## Pylon tradeoffs Pylon's realtime shard system is newer. Colyseus has 8 years of production hardening, edge-case fixes, integration guides, and deployment patterns. For mission-critical multiplayer where every minute of downtime costs money, Colyseus's maturity is a real asset. Pylon's shard implementation is built on the same `pylon_realtime` primitives the Pylon team uses internally. It's tested, but not yet at Colyseus's deployment-count level. ## Both / and Some teams use Pylon for everything *except* the realtime tick loop, and Colyseus for the multiplayer match itself. Pylon hosts auth, leaderboards, friend graph, item shop, chat lobbies; Colyseus handles the in-match state. The clients talk to both. This works because both protocols are simple WebSocket, so there is no format conversion. The downside is two backends to deploy and operate. If you're starting fresh, Pylon's all-in-one is simpler. # Pylon vs. Convex Source: https://docs.pylonsync.com/compare/convex Compare Pylon and Convex as TypeScript-first reactive backends. [Convex](https://convex.dev) is the product most similar to Pylon. Both offer a TypeScript backend with reactive queries and managed sync. They differ in deployment, licensing, search, and game-server support. ## TL;DR * **Choose Convex** if you want a polished, pure-TypeScript experience, you will stay on their cloud (or accept their FSL-licensed self-host), and you do not need game shards or facets. * **Choose Pylon** if you want a single-binary self-host, an open-source license, native faceted search, or in-process functions that share a transaction with your writes. ## Same shape Both ship: * Reactive queries that auto-update the UI when data changes * TypeScript-first server functions (mutation / query / action) * Schema as code * Real-time WebSocket sync * Built-in auth + file storage * Self-host options * React, React Native, and Next.js SDKs ## Where they differ ### Process model | | Pylon | Convex | | ---------------------------- | --------------- | --------------------------------------------------- | | Single binary | ✅ | ⚠️ prebuilt binary available; Docker is recommended | | Multi-service docker-compose | n/a | ✅ | | Default backing store | SQLite/Postgres | SQLite, Postgres, or another configured store | Pylon ships as one Rust binary. Convex's self-hosted product can run from Docker or a prebuilt backend binary, with the dashboard and CLI included. For a hobby project on a \$5 VPS, Pylon installs in 30 seconds with no Docker. Both work for a production deployment, but the operational details differ. ### License | | License | | ------ | ------------------------------------------------------------------------------------------------------------------------------- | | Pylon | MIT OR Apache-2.0 | | Convex | [FSL (Functional Source License)](https://convex.dev/blog/announcing-self-hosted-convex) — converts to Apache 2.0 after 2 years | Convex's license bars you from offering a competing managed Convex service for the first two years after each release. This does not matter for most users. It matters only if you are building a backend-as-a-service on top of Convex. Pylon's MIT/Apache license has no such limit. ### Search Convex has [full-text search](https://docs.convex.dev/search/text-search): keyword and prefix matching. It has no native facets. You build facets yourself with regular queries on top of Convex. Pylon ships SQLite FTS5 and roaring-bitmap facets, built in. `useSearch("Post", { facets: ["tags", "authorId"] })` returns hits and live `facetCounts` in one call. For an Algolia-style faceted UI without paying for Algolia, Pylon is the simpler option. ### Game shards Convex has no concept of authoritative tick-based game state. If you are building a Slack-like app, you do not need it. If you are building a multiplayer game or a Figma-style cursor-presence app, Pylon's `Shard<S: SimState>` primitive is a real feature that Convex does not have. The shard simulation is authored in Rust (the `pylon_realtime` crate), and a `useShard` React hook connects it to the client. Pylon has no TypeScript API for the server-side shard logic today. ### Pricing | | Pylon | Convex | | -------------- | ------------------------------------------------- | -------------------------------------------- | | Self-host | Your infra | Your infra, FSL bound | | Free / starter | Hobby plan or self-hosted infra | Free or Starter pay-as-you-go plan | | Team seats | Included in self-hosted apps; Cloud plan based | Professional plan charges per developer seat | | Large teams | Team plan + machine compute, or self-hosted infra | Business/Enterprise minimums | Pylon Cloud pricing is based on a platform plan plus machine compute and included quotas. Convex's [pricing page](https://www.convex.dev/pricing) combines hosted usage, team seats, and higher-tier minimums. ### Editor / Studio | | Pylon | Convex | | ------------------ | ---------------------------- | ------------------------------------------------ | | Built-in inspector | ✅ Pylon Studio at `/studio` | ✅ Convex dashboard | | Self-hosted | Studio ships with the binary | Dashboard is included in the self-hosted product | Both have web-based inspectors. Pylon's runs against your local binary by default. Convex's hosted dashboard is the default for cloud projects, and the self-hosted product includes its own dashboard. ### CRDTs Pylon integrates [Loro](https://loro.dev) for collaborative text, lists, maps, and trees. Loro provides true CRDTs with conflict-free convergence. Convex has reactive queries that automatically reconcile updates, but it does not ship a CRDT library. For collaborative editing, you would integrate Yjs or Loro yourself. ## Where Convex is better * **Pure-TS dev loop** — Convex has invested heavily in DX polish. Type inference flows end-to-end without codegen. Pylon requires `pylon codegen` (one CLI call); Convex's TypeScript flow is a bit tighter. * **Larger team behind it** — well-funded YC company, more docs, more examples. * **Scheduled functions** — Convex has clean durable primitives for scheduled jobs. Pylon has a durable scheduler (`ctx.scheduler.runAfter`), background jobs with exponential retry back-off, and [durable multi-step workflows](/concepts/workflows) (completed results replay, sleeps span days, events resume paused runs). Convex's scheduler UI is more polished. * **Convex Vector Search at scale** — Convex's vector indexes are approximate and built for large tables. Pylon's built-in [vector search](/concepts/vector-search) (`field.vector(dims)` + `ctx.llm.embed` + `ctx.db.vectorSearch`) is exact k-NN, with perfect recall and zero index maintenance, good up to about 100k rows per entity. Past that, Convex's ANN indexes win. ## Where Pylon is better * **Small footprint** — install the pylon binary + Bun on a VPS, or `pylon deploy`. No Docker daemon. * **Faceted search** — built in, with live counts. Convex requires custom queries. * **Game shards** — `Shard<S: SimState>` for tick-based authority (authored in Rust; `useShard` hook on the client). * **Open license** — MIT/Apache, not FSL. * **Built-in features** — TOTP, audit log, webhooks, rate limiting, file storage, faceted search, and vector search ship in the binary, configured from the manifest or env. No marketplace of third-party components is required. * **Self-host on day one** — Convex's self-host is recent, and the OSS bundle has not reached the polish of their hosted offering yet. ## Migrating from Convex | Convex | Pylon equivalent | | ------------------------------- | --------------------------------------------------------------------------- | | `defineSchema(...)` | `buildManifest({ entities: [...] })` | | `query`, `mutation`, `action` | Same names; identical mental model | | `useQuery(api.tasks.list)` | `useQuery("Task")` | | `ctx.db.insert("tasks", {...})` | `ctx.db.insert("Task", {...})` | | Convex auth | Magic codes / password / OAuth | | Convex file storage | `/api/files/init` → direct PUT → `/api/files/confirm` (Stack0 / S3 / local) | | Convex scheduled functions | `ctx.scheduler.runAfter(...)` or background jobs | | Convex search index | Per-entity `search` config | Most of the development surface maps directly. Pylon adds a code-generation step; Convex provides inline TypeScript types. ## Pylon tradeoffs Convex has a larger developer community and more polish on edge cases (reactive query batching, type inference depth, IDE integration). If you want the most polished pure-TypeScript reactive backend and do not need single-binary self-host, faceted search, or Rust game shards, Convex is a great choice. ## Using both Using Convex on the web and Pylon for game shards is a legitimate combination if your team prefers Convex's TypeScript surface for app data and needs authoritative tick-based simulation for a multiplayer feature. Keep in mind that the shard simulation itself is written in Rust. The wire formats differ, so you would run both backends side by side, but it is a real option. # Pylon vs. Firebase Source: https://docs.pylonsync.com/compare/firebase Firebase is the original mobile-first BaaS. Pylon is what you would build starting from scratch in 2026. [Firebase](https://firebase.google.com) is Google's managed BaaS: Firestore, Realtime Database, Auth, Cloud Functions, Storage, and many other mobile-focused services. Pylon overlaps with Firestore, Functions, Auth, and Storage. The rest of Firebase (FCM, Crashlytics, Analytics) is out of scope. ## TL;DR * **Choose Firebase** if you are building a mobile app, you want Google's ecosystem (FCM push notifications, Crashlytics, GA4 integration), and a closed-source backend is acceptable. * **Choose Pylon** if you want self-host, no vendor lock-in, structured data, faceted search, or game shards (authored in Rust). ## Big picture differences | | Pylon | Firebase | | ------------------ | ------------------------------- | ------------------------------------------------------------------- | | License | MIT / Apache 2.0 | Closed source | | Self-hostable | ✅ | ❌ Google-only | | Schema | ✅ declarative | ❌ Firestore is schemaless | | Realtime sync | ✅ | ✅ | | Functions | ✅ Bun, in-process | ✅ Cloud Functions (Node, separate) | | Auth | ✅ built-in | ✅ Firebase Auth | | File storage | ✅ + S3/Stack0 backends | ✅ Cloud Storage | | Push notifications | ❌ (use any provider) | ✅ FCM | | Analytics | ❌ (use any provider) | ✅ GA4 | | Crash reporting | ❌ (use Sentry/Crashlytics) | ✅ Crashlytics | | Game shards | ✅ Rust `Shard` (TS client hook) | ❌ | | Faceted search | ✅ | ⚠️ Enterprise text search or third-party search; no built-in facets | If you want everything Google ships (push notifications, analytics, A/B testing, remote config, and the broader Google Cloud ecosystem), Firebase is a better fit. Pylon focuses on the backend; pair it with dedicated single-purpose services. ## Schema and data Firestore is schemaless: any document can have any shape. Rules act as a soft schema by validating fields. This helps rapid prototyping, but it causes problems at scale: typos become orphan rows, and refactoring fields is a manual sweep. Pylon's schema is declarative TypeScript: ```typescript theme={null} const Todo = entity("Todo", { title: string(), done: bool(), authorId: id("User"), }); ``` You get types in your client (after codegen), automatic indexes, and a single source of truth. ## Realtime model Both ship realtime sync. The mental model differs: * **Firestore listeners** are per-document or per-query. You attach a listener; it fires when the matching documents change. Listeners are stateful, so you manage their lifecycle. * **Pylon's sync engine** maintains a local replica of every entity you have subscribed to. `useQuery("Todo")` returns the live array; the engine handles tombstones, optimistic mutations, and reconnection. Pylon's model is closer to Convex (replica plus reactive queries). Firebase's is closer to RxJS (observable streams). Both work. Pylon's approach treats the local replica as the source of truth, which suits offline-capable apps. ## Search Firestore Standard does not provide Pylon's built-in faceted search shape. Firestore Enterprise adds text search, and many Firebase apps still use Algolia, Elastic, Typesense, or another search service when they need richer ranking and facets. Pylon ships FTS5 + roaring-bitmap facets in the binary. No second hosted search system is required for common faceted-search UI. ## Functions | | Pylon | Firebase | | ------------ | ------------------------ | ------------------------------------------------------------ | | Runtime | Bun (in-process) | Node (separate Cloud Functions) | | Cold start | None for Pylon functions | Possible unless min instances/concurrency keep capacity warm | | DB access | Direct (`ctx.db`) | Via Admin SDK over network | | Atomicity | ✅ wrapped in transaction | Manual Firestore transactions | | Type sharing | ✅ after codegen | Manual | | Pricing | Included in your binary | Per-invocation + per-GB-second | Firebase Cloud Functions can cold start when no warm instance is available. Firebase gives you controls such as min instances and concurrency to reduce that. Pylon functions run inside the already-running Pylon process, so there is no separate serverless function cold start. ## Push notifications Pylon does not ship a push provider. FCM is excellent and free up to enormous volumes, so keep using it on top of any backend, including Pylon. The pattern: register the device token through a Pylon `action`, then send through FCM from your function or a separate worker. ## Pricing Firebase pricing is itemized across separate meters: Firestore reads, writes, deletes, network egress, function invocations, function GB-seconds, storage, downloads, and related products. The Blaze plan auto-scales, which is useful but needs budget controls. Pylon Cloud is a platform plan plus machine compute and included quotas. Self-hosted, you pay your VPS bill. ## Migrating from Firebase This is the hardest migration of any of the comparisons. Firestore's schemaless documents typically denormalize aggressively (embedding user info inside every post, for example). Porting to Pylon's relational model means: 1. **Identify entities** — what is a top-level row vs. nested data? 2. **Normalize denormalized data** — extract `User`, `Org`, etc. into their own tables 3. **Port security rules to policies** — Firestore rules use a custom DSL; Pylon policies are simpler boolean expressions 4. **Re-shape clients** — `firestore.collection("todos").onSnapshot(...)` → `useQuery("Todo")` Auth migration is tractable: export Firebase Auth users, import as Pylon `User` rows with `emailVerified` set, ask users to sign in once via magic code (which auto-binds their existing email). ## Pylon tradeoffs Firebase's mobile-first integrations (FCM, Crashlytics, A/B testing via Remote Config, in-app messaging) have no Pylon equivalent. If your app's most important behaviors are around push notifications and mobile experimentation, Firebase has more polish. Pylon assumes you will bring your own analytics, crash reporting, and push provider. Firestore also provides managed horizontal scale and very high throughput when modeled correctly. Pylon's SQLite default is single-process; Postgres mode scales further but eventually hits its own limits. Neither Pylon nor SQLite fits a Twitter-scale app; that scale needs Spanner, Cassandra, or DynamoDB. ## Both / and Use Pylon for backend logic (data, sync, functions, auth) and FCM for push notifications. They do not conflict. Push notifications are a lightweight integration, not a full-stack commitment. This combination gives you Firebase-quality push without locking the rest of your stack into Google. # Pylon vs. Nakama Source: https://docs.pylonsync.com/compare/nakama Nakama is a feature-rich Go game backend with social systems. Pylon is leaner with a tighter app/game integration. [Nakama](https://heroiclabs.com/nakama/) is Heroic Labs' open-source game server: a Go binary with a wide feature set, including authoritative match handlers, a matchmaker, leaderboards, tournaments, parties, chat, in-app purchases, and a storage engine. Pylon overlaps significantly; the question is shape and scope. ## TL;DR * **Choose Nakama** if you want a feature-complete game backend built in, you are shipping a free-to-play mobile game with leaderboards, tournaments, and in-app purchases, and you have Go (or Lua or TypeScript) game-server engineers. * **Choose Pylon** if you want a single binary with both app data and game shards, your team is comfortable with Rust and TypeScript, and you do not need every Nakama feature. ## Architecture | | Pylon | Nakama | | ----------------------- | ------------------------------ | ----------------------------------------------------------- | | Runtime | Rust binary | Go binary | | Backing DB | SQLite (default), Postgres | CockroachDB or Postgres-compatible server | | Match handlers | `Shard<S: SimState>` (Rust) | Go / Lua / TypeScript runtime | | Storage | Entities (declarative) | Storage Engine (typed JSON blobs) | | Auth | Magic codes / password / OAuth | Email / device / Google / Facebook / Apple / Steam / custom | | Live queries (non-game) | ✅ | ⚠️ via Storage Engine pull | Both are single-binary game backends with built-in user systems. Pylon treats app data as declarative entities; Nakama stores typed JSON blobs in its Storage Engine. ## Same shape Both ship: * Tick-based authoritative match handlers * Matchmaker with customizable algorithm * Single self-hosted binary * Built-in user accounts + sessions * WebSocket realtime * Notifications * Friends + relationships * Open source (Apache 2.0 / MIT-Apache) ## Where Nakama is better * **Wider feature set** for game-specific concerns: * **Leaderboards** — complete with reset schedules, ranks, and authoritative submissions * **Tournaments** — scheduled leaderboard-style competitions * **Parties** — pre-match group formation * **Groups / clans** — guild-like social systems with ranks * **In-app purchases** — receipt validation for Apple and Google stores * **Notifications** — persistent in-game notifications with read state * **Streams** — pub/sub channels for game-specific topics * **Mature game-server patterns** — written by Heroic Labs, used by [Vela Games](https://www.velagames.com/), [PlayerUnknown Productions](https://playerunknownproductions.net/), and others * **Multi-language runtime** — match handlers in Go, Lua, or TypeScript * **Console support** — patterns for PlayStation, Xbox, Switch * **Distributed deployment** — clustering and Heroic Cloud patterns for larger games * **Persistent storage engine** — typed JSON with read/write permission per object * **Bigger team behind it** — commercial company with paid support ## Where Pylon is better * **Declarative app entities** — Pylon's schema is built into the framework. Nakama's Storage Engine is typed JSON, which is flexible but does not give you typed entities, relations, or live queries the same way. * **Live queries for UI** — Nakama has the Storage Engine for persistent data and Streams for pub/sub; neither gives you "useQuery returns the live array" built in. * **Faceted search** — Nakama has storage search, but not a built-in faceted full-text search API like Pylon's `useSearch`. * **TypeScript functions tied to data** — Pylon's `mutation`/`action` shares a transaction with the entity write. Nakama's TS runtime is game-backend code against the Storage Engine and realtime APIs. * **Smaller dependency shape** — Pylon can run with SQLite for small deployments. Nakama runs with an external database dependency. * **MIT/Apache and Apache 2.0** — Pylon's runtime is MIT/Apache. Nakama core is Apache 2.0, with commercial cloud and ecosystem offerings available separately. * **Single-language story** — Rust + TS for everything. Nakama is Go (server) + your choice (handlers) + SDK language; the runtime polyglot is a feature for some teams and a complexity tax for others. ## Use case fit | If you're building... | Recommended | | ---------------------------------------------------- | ----------------------------------------------- | | F2P mobile RPG with heavy social systems | Nakama (groups, parties, leaderboards, IAP) | | Indie multiplayer game with custom backend logic | Either | | Multiplayer feature inside a SaaS app | Pylon | | Esports-focused competitive game | Nakama (tournaments are a real feature) | | Web-first multiplayer game | Pylon (lighter SDK, tighter web integration) | | Game with deep persistent world + lots of UI screens | Pylon | | Console game | Nakama (patterns exist) | | Hyper-casual mobile | Either; Nakama if you want IAP receipts handled | | App + game in one product | Pylon | ## Pricing Nakama is open source. You self-host for free. Heroic Labs offers Heroic Cloud (managed Nakama) and Satori (managed analytics and experiments) as paid services. Pylon is open source. Self-host for free. Pylon Cloud is managed Pylon, priced by usage. If you are self-hosting either, the cost is your VPS. If you are going managed, both have managed cloud options at different price points. ## Migrating from Nakama The mental model translates with caveats: | Nakama | Pylon | | ------------------------- | ------------------------------------------------------------------ | | Match handler (Lua/Go/TS) | `SimState::tick` (Rust) | | Storage Engine | Entities + policies | | Leaderboards | Custom entity + sort query (no built-in leaderboard primitive yet) | | Tournaments | Custom entity + scheduler (no built-in primitive) | | Streams | Pylon's pub/sub + presence | | Friends / groups | Custom entities | | IAP receipt validation | Pylon function calling Apple/Google APIs (no built-in) | | Notifications | Custom entity + push provider integration | The migration's largest cost is rewriting the features Pylon does not have built in: leaderboards, tournaments, IAP validation, advanced groups. For each one, you build the entity plus a small action or function. It is doable, but not effortless. ## Pylon tradeoffs Nakama's feature set is genuinely deeper for game-specific concerns. If you need leaderboards with reset schedules, scheduled tournaments, IAP validation, or groups with rank hierarchies, those are real things you would otherwise build yourself. Pylon's primitives let you build them, but Nakama provides them ready-made. For pure-game backends with deep social features, Nakama is often the right pick. For app + game backends where the game is one piece of a larger product, Pylon's integration story is cleaner. ## Both / and You can run Nakama for the game backend and Pylon for the surrounding web or mobile app (account management UI, billing, support tools, content management). Some studios do this: Nakama for the game, Pylon (or any other backend) for the marketing site, store, and admin tools. The two backends do not conflict; they share the user identity through OAuth or a shared SSO. # How Pylon compares Source: https://docs.pylonsync.com/compare/overview Compare Pylon with Convex, Supabase, Firebase, Colyseus, Playroom Kit, and Nakama. These comparisons spell out where Pylon and each alternative fit. Vendor-sensitive claims use primary documentation, and the tables call out features competitors handle better. ## App backends | | Pylon | Convex | Supabase | Firebase | | ---------------------- | -------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------- | ----------------------------------------------- | | **Best for** | Real-time apps that also want Rust game shards, in one service | TypeScript-first reactive apps | Postgres apps with managed auth/storage | Mobile apps with managed everything | | One service / one port | ✅ | ❌ docker-compose | ❌ multi-service | n/a (managed only) | | Self-hostable | ✅ | ✅ | ✅ | ❌ | | Realtime sync | ✅ | ✅ | ✅ | ✅ | | TypeScript functions | ✅ in-repo | ✅ in-repo | ⚠️ Edge Functions (Deno, separate deploy) | ⚠️ Cloud Functions (separate deploy) | | Faceted search | ✅ FTS5 + roaring bitmaps | ⚠️ text/vector search, no native facets | ⚠️ Postgres `tsvector`, no native facets | ⚠️ Enterprise text search or third-party search | | Tick-based game shards | ✅ Rust `Shard` (client hook in TS) | ❌ | ❌ | ❌ | | Open source | ✅ MIT/Apache | ✅ FSL | ✅ Apache 2.0 | ❌ | | Pricing model | Self-host infra or Pylon Cloud plan + machine compute | Hosted usage + team seats; self-host infra | Hosted tiers or self-host infra | Hosted pay-as-you-go meters | Drill down: [Pylon vs. Convex](/compare/convex) · [Pylon vs. Supabase](/compare/supabase) · [Pylon vs. Firebase](/compare/firebase) ## Game servers Pylon's authoritative game shards are written in Rust with the `Shard<S: SimState>` crate; clients connect through the `useShard` React hook. Schema, policies, functions, and sync use TypeScript, but shard simulations do not have a TypeScript authoring API. The shard comparison therefore applies only to teams willing to write Rust. | | Pylon | Colyseus | Playroom Kit | Nakama | | ------------------------- | --------------------------------------------------- | --------------------------------- | ---------------------------------- | ------------------------------------------- | | **Best for** | Apps with real-time data + game shards in one stack | Standalone game server in Node | Quick web games with shared state | Production multiplayer with social features | | Tick-based authority | ✅ | ✅ | ⚠️ host-authoritative client model | ✅ | | Area-of-interest | ✅ built-in | ⚠️ StateView/per-client filtering | ❌ | ⚠️ presence-based, manual | | Matchmaker | ✅ | ✅ | ✅ | ✅ | | Declarative app data | ✅ entities + policies | ❌ game state only | ❌ shared state only | ⚠️ Storage Engine (typed JSON) | | Live queries for UI | ✅ | ❌ | ❌ | ❌ | | Self-hosted as one binary | ✅ Rust | ⚠️ Node + your code | ❌ managed only | ✅ Go binary + external DB | | Open source | ✅ MIT/Apache | ✅ MIT | ❌ proprietary | ✅ Apache 2.0 | Drill down: [Pylon vs. Colyseus](/compare/colyseus) · [Pylon vs. Playroom Kit](/compare/playroom) · [Pylon vs. Nakama](/compare/nakama) ## Comparison policy * **Primary sources for vendor-sensitive claims.** If a vendor ships a feature, we should describe it the way their current docs describe it. * **No scoreboard inflation.** Where a competitor is better, we say so. Supabase's Postgres-native data layer is more flexible for analytics, and Convex has a tighter pure-TypeScript loop for new projects. * **Review when docs or pricing change.** If you spot a stale claim, [open an issue](https://github.com/pylonsync/pylon/issues). ## How Pylon fits in your stack Pylon is a full-stack realtime framework. Use its server-rendered React surface or pair the backend with Next.js, React and Vite, Expo or React Native, or Swift and SwiftUI. Dedicated SDKs cover each of those clients: [`@pylonsync/next`](/clients/next), [`@pylonsync/react`](/clients/react), [`@pylonsync/react-native`](/clients/react-native), and the [Swift SDK](/clients/swift). ## When Pylon is a poor fit * **You are already deep in the GCP stack.** Firebase and GCP integrate cleanly with Cloud Run, BigQuery, Pub/Sub, and many other Google services. Pylon does not integrate with these directly. * **You need warehouse-scale analytics.** Move analytical workloads to ClickHouse, DuckDB, or BigQuery. Pylon is for transactional realtime app state, not OLAP. * **You are shipping pure RPC over HTTP with no realtime requirement.** A simple Express/Fastify app or tRPC is lighter if you genuinely do not need sync, policies, multiplayer, or any of the rest. ## What about raw Postgres access? Pylon's entity API is currently the only in-process data API. Raw SQL for Postgres builds, such as `ctx.pg.query(sql, params)`, is on the roadmap. Until it lands, queries that need CTEs, window functions, materialized views, or pgvector can run through a Postgres extension or sidecar against the same database. ## Switching costs If you are already on a competitor and considering a move: | From | Migration difficulty | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **Convex** | Medium — port `defineSchema` to Pylon's schema DSL, port reactive queries to `useQuery`, port functions to `mutation`/`action` | | **Supabase** | Medium-high — port SQL schema to Pylon's manifest, port RLS to Pylon policies, replace Realtime subscriptions with `useQuery` | | **Firebase** | High — denormalized Firestore data needs reshaping; auth migration is tractable via the magic-code flow | | **Colyseus** | Medium-high — port Schema classes to Pylon's `SimState`; the tick logic is rewritten in Rust, not reused from JS | | **Playroom Kit** | Low — Playroom's small surface area maps cleanly | | **Nakama** | High — Lua/Go match handlers need rewriting in Rust | ## Migrating *from* Pylon We treat lock-in as a bug. Every Pylon deployment can: * **Export to JSON or SQL** via `pylon export` / `pylon backup` * **Migrate from Cloud to self-hosted** by downloading a snapshot, restoring it with `pylon restore`, and running the same binary * **Self-host with the same binary** — no proprietary runtime, no vendor SDK that only talks to us # Pylon vs. Playroom Kit Source: https://docs.pylonsync.com/compare/playroom Compare Playroom Kit's managed web multiplayer model with Pylon's self-hosted server authority. [Playroom Kit](https://playroomkit.com) is a managed multiplayer SDK aimed at web games and collaborative apps (HTML5, Unity WebGL, browser puzzles). It is built to ship a working multiplayer game in an afternoon, at the cost of being managed-only infrastructure. ## TL;DR * **Choose Playroom** if you are shipping a casual web game this week, you do not want to operate any backend, and you accept managed-only deployment. * **Choose Pylon** if you want self-hosting, full server authority, persistent domain data, or a dedicated server runtime. ## Big picture | | Pylon | Playroom | | ------------------------------- | -------------------------- | --------------------------------------------------------------------- | | License | MIT / Apache 2.0 | Proprietary | | Self-host | ✅ | ❌ managed only | | Server authority | ✅ tick-based | ⚠️ host-authoritative client model | | Persistent player data | ✅ entities + auth | ⚠️ room/player state; bring your app database for durable domain data | | Built-in auth | ✅ | ⚠️ guest-by-default | | File storage | ✅ | ❌ | | Search | ✅ | ❌ | | Game shards / rooms | ✅ | ✅ | | Setup complexity | Medium | Very low | | Max concurrent players per room | Dedicated server dependent | Host/model dependent; benchmark | ## Architecture Playroom uses a host-authoritative room-state model: when players join a room, one participant is elected host and is responsible for authoritative state changes. Other participants send changes through that model. For unreliable state updates, Playroom can use WebRTC; reliable updates go through its WebSocket server. This model fits a four-player trivia game with no custom server code. It becomes harder to use when: * A host with a slow phone slows the whole room * The host disconnects mid-game and state has to be re-synced from peers * You need server-side validation (anti-cheat, scoring authority) * Concurrent players exceed what the elected host and room model can comfortably simulate Pylon, Colyseus, and Nakama use a server-authoritative model: the server runs the tick loop, all state lives on the server, and clients send inputs and receive state. It costs more to operate but tolerates unreliable or hostile clients better. Pylon's authoritative tick loop is written in Rust with the `Shard<S: SimState>` crate, and clients connect through the `useShard` React hook. App data, auth, and functions stay in TypeScript, but the shard simulation itself requires Rust. ## Where Playroom is better * **Time-to-first-multiplayer** — minutes, not hours. The SDK includes a discovery UI, joins via room codes, and "just works" with a few lines of code. * **Web/mobile cross-play** — handles different platforms invisibly. * **Fast prototypes** — the hosted model is excellent for hobby projects and throwaway experiments. * **No backend to deploy** — at all. Add the SDK, ship. * **Phone-as-controller** — Playroom has a polished "stream from your laptop, control from your phone" pattern. ## Where Pylon is better * **You own your backend** — no vendor outage takes down your game. * **Server authority** — players cannot hack the host. Critical for competitive games or anything with persistent rewards. * **Persistent player data** — every player has a real account with stable identity, not a per-room guest. Inventory, progress, friends, leaderboards, and cosmetics are all built in. * **Higher authority ceiling** — server tick loops scale beyond what one elected client can safely simulate. * **Backend logic** — server-side validation, anti-cheat, complex matchmaking, fraud detection. * **Self-host** — no per-player pricing as you grow. * **Open source** — fork it, ship it, never depend on a vendor. ## Use case fit | Game type | Recommended | | ----------------------------------------- | ------------------------------------------------------- | | 2–8 player party game | Playroom | | Trivia / quiz / drawing | Playroom | | Phone-as-controller experience | Playroom | | Casual co-op web game | Either | | Competitive multiplayer (rankings matter) | Pylon (or Colyseus, Nakama) | | Persistent-world game | Pylon | | Large authoritative rooms | Pylon | | Game with progression, inventory, profile | Pylon | | Internal corp game (no signup, just join) | Playroom | | Mobile game with cross-play | Playroom for prototyping; Pylon for production at scale | ## Pricing Check Playroom's current pricing and limits before launch. It is designed to make prototypes and lightweight multiplayer cheap to start. Pylon self-hosting costs the underlying infrastructure. Pylon Cloud uses plan quotas plus machine compute. Both require more operational responsibility than Playroom but provide a dedicated server runtime, durable data, and a migration path off the hosted platform. ## Migrating from Playroom If you started with Playroom and outgrew it: 1. **Move authority to the server** — port your "host" logic into a `SimState::tick` impl on the Pylon side (this is Rust; Pylon's shard authority is not a TypeScript API today, so budget for that rewrite) 2. **Convert room state from key-value to entities** — Playroom's `getState`/`setState` pattern maps to Pylon's entity CRUD 3. **Add real auth** — replace guest sessions with magic-code sign-in (or keep guest sessions for "just play" via `/api/auth/guest`) 4. **Persist player data** — move from Playroom's volatile state to Pylon entities The hardest part is the mental shift from peer-state to server-authoritative. The code itself is straightforward. ## Pylon tradeoffs Playroom has a better developer experience for shipping a party game in a weekend than any server-authoritative option, including Pylon. Its web-game SDK, room-code flow, and phone-as-controller support are more polished. If you are building a hackathon project or an MVP to validate a multiplayer game idea, start with Playroom. Migrate to Pylon (or Colyseus, or Nakama) when you outgrow the host-authoritative client model. ## Using both You can use Playroom for the lightweight lobby UX and Pylon for persistent player data, leaderboards, and matchmaking, with the clients pulling from both. This is unusual but valid. Playroom's strength is the realtime party-game shape; Pylon's strength is everything else around it. # Pylon vs. Supabase Source: https://docs.pylonsync.com/compare/supabase Compare Supabase's Postgres platform with Pylon's single-server model. [Supabase](https://supabase.com) is a well-known open-source Firebase alternative. It is a managed stack of Postgres, GoTrue, PostgREST, Realtime, and Storage, with web and mobile SDKs. Pylon solves the same problems with a different architecture. ## TL;DR * **Choose Supabase** if you want full Postgres SQL surface, you are comfortable with multi-service deployments, or you need pgvector, edge functions, and storage in one place. * **Choose Pylon** if you want one binary on a VPS, native faceted search, or a tighter integration between sync and policies. ## Architecture | | Pylon | Supabase | | ---------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------- | | Services / ports | 1 service, 1 port | 5+ services (Postgres, GoTrue, PostgREST, Realtime, Storage, Studio, optional Edge Functions) | | Default DB | SQLite | Postgres | | Backed by | Rust server + a bundled Bun runtime for your TS | Postgres + Go + Elixir + Deno | | Self-host | binary + Bun + `systemctl` | Docker Compose stack | Supabase is a curated stack of dedicated single-purpose services. Pylon is one binary that does many of those things itself. Both are valid choices with different trade-offs. ## Same shape Both ship: * Real-time subscriptions over WebSocket * Built-in auth (magic links, password, OAuth) * File storage * Row-level access control (RLS for Supabase, policies for Pylon) * Web SDK + mobile SDKs * Self-hostable, FOSS-licensed * Managed cloud option ## Where Supabase is better * **Full Postgres** — every Postgres feature works: CTEs, window functions, JSONB operators, materialized views, foreign data wrappers, GIST indexes, partitioning. Pylon's query layer is intentionally limited. * **pgvector at scale** — approximate indexes (HNSW/IVFFlat) for million-row vector tables. Pylon ships built-in [vector search](/concepts/vector-search) (`field.vector(dims)` + `ctx.db.vectorSearch`, exact k-NN with perfect recall) which is the simpler answer to \~100k rows per entity; past that, pgvector's ANN indexes win. * **PostgREST auto-API** — every table becomes a REST endpoint automatically. Pylon also auto-generates these (`/api/entities/<name>`), but PostgREST has 8 years of production use behind it. * **Edge Functions** — globally distributed Deno runtime; nice for latency-sensitive endpoints. Pylon functions run in your single binary's region. * **Larger ecosystem** — more libraries, more tutorials, more StackOverflow answers, more job postings. * **Database UI** — Supabase Studio is excellent for SQL exploration. Pylon Studio is more focused on entity inspection. ## Where Pylon is better * **One service** — one binary plus Bun, one port, one config file. Compare to a Docker Compose stack. * **Faceted search** — Supabase has Postgres `tsvector` for FTS but no native facets ([docs](https://supabase.com/docs/guides/database/full-text-search)). Pylon ships FTS5 + roaring-bitmap facet counts. * **Game shards** — `Shard<S: SimState>` for tick-based multiplayer (authored in Rust, with a `useShard` client hook; there is no TypeScript shard-authoring API yet). Supabase has nothing equivalent. * **In-process mutations** — Pylon mutations run as typed handler-level transactions in the app process. Supabase Edge Functions are separate Deno functions that call Postgres over HTTP or a database client, so you manage transaction boundaries explicitly. * **CRDTs** — Pylon integrates Loro for collaborative editing. Supabase Realtime gives you Postgres Changes, Broadcast, and Presence; CRDT-backed collaborative data is still something you bring yourself. * **Plugin system** — built-in plugins for cross-cutting concerns such as search, files, presence, audit logs, and payments. Supabase has extensions, but they are Postgres extensions, not framework-level. * **TypeScript declarative schema** — `entity("User", {...})` in TS. Supabase uses SQL migrations; you can codegen TS types from them but the source of truth is SQL. ## Functions | | Pylon | Supabase | | ------------------------------ | ------------------------ | ------------------------------------------- | | Runtime | Bun (in-process) | Deno (separate Edge Functions) | | Type sharing with client | After codegen | Manual or via `supabase gen types` | | DB access | Direct (`ctx.db`) | Supabase JS over HTTP or explicit DB client | | Atomic with caller transaction | ✅ for mutations | Manual transaction management | | Latency | In-process for app logic | Region and network dependent | Pylon's "mutation = transaction" model is a meaningful difference. Throw inside a `mutation` and everything rolls back. Supabase Edge Functions can do this with explicit transaction handling, but it is not the default shape. ## Pricing | | Pylon | Supabase | | ----------------- | ------------------------------------------------- | ------------------------------------ | | Self-host | Your infra | Your infra | | Hobby / prototype | Pylon Cloud Hobby or self-hosted infra | Supabase free tier | | Production hosted | Pylon Cloud plan + machine compute | Supabase Pro tier + add-ons | | Large usage | Team plan + machine compute, or self-hosted infra | Higher tiers, add-ons, or enterprise | Supabase's free tier is generous for prototypes and includes managed Postgres. Pylon Cloud includes the application runtime, sync, domains, deploys, and machine hosting; self-hosted Pylon puts those costs on your own infrastructure. ## Migrating from Supabase If you are moving an existing Supabase app: | Supabase | Pylon | | --------------------------------- | --------------------------------------------------------------------------- | | SQL schema + RLS | `pylon.manifest.json` entities + policies | | `supabase.from('todos').select()` | `useQuery("Todo")` | | `supabase.auth.signInWithOtp()` | `startMagicCode(email)` + `verifyMagicCode(...)` | | Storage buckets | `/api/files/init` → direct PUT → `/api/files/confirm` (Stack0 / S3 / local) | | Edge Functions | `mutation`/`action` in `functions/*.ts` | | Realtime subscriptions | `useQuery` (server-authoritative) or `subscribeCrdt` (collaborative) | | `pg_net` outbound HTTP | `fetch` inside an action (with `net_guard` plugin for SSRF defense) | | `auth.users` | Pylon's `User` entity (you control the shape) | Pylon's entity API is currently its only in-process data API. Apps that rely on raw Postgres queries such as CTEs, window functions, materialized views, or pgvector have two options: 1. **Express the logic in a Pylon `action`** in TypeScript — for joins and aggregates this is usually fine, and you keep the type-safe entity model. 2. **Run a sidecar against the same Postgres** — Pylon does not lock the database; you can connect any tool that speaks Postgres for analytics, BI, or specialized queries. Raw SQL exposure from inside `action` handlers (for example `ctx.pg.query(sql, params)` in `postgres-live` builds) is on the roadmap; there is no architectural reason to withhold it. For now, most app workloads fit the entity API. If yours needs more Postgres surface, Supabase has a head start. ## Pylon tradeoffs Supabase's Postgres-native data layer is more flexible than Pylon's for analytics, complex queries, and integrations with tools that speak SQL (Metabase, Hex, Mode, Retool). If your data model needs to work with a wider data ecosystem, Supabase's "it is just Postgres" advantage is genuinely valuable. ## Both / and Pylon supports Postgres as a backing store via the `postgres-live` feature. You can run Pylon as the application layer (entities, policies, functions, sync) on top of an existing Supabase Postgres instance. Some teams do this to keep Supabase Studio's SQL UI for exploration while getting Pylon's sync engine and facets for the app. # Agents Source: https://docs.pylonsync.com/concepts/agents agent() runs an LLM tool loop with durable, synced run state. Define tools and get streaming, persistence, and live transcripts on every device. `agent()` turns an LLM tool loop into a one-file definition. The framework runs the loop. It streams tokens over a resumable stream. It runs your tools with validated arguments. It persists every turn (user input, assistant text, tool calls, tool results) into synced entities that update live on all of the user's devices. ```ts theme={null} // functions/researcher.ts import { agent, v } from "@pylonsync/functions"; export default agent({ system: "You are a research assistant for this workspace.", tools: { searchDocs: { description: "Search the document library for relevant passages", args: { query: v.string() }, handler: async (ctx, { query }) => ctx.runQuery("findSimilar", { query }), }, sendSummary: { description: "Email a summary to the signed-in user", args: { subject: v.string(), body: v.string() }, handler: async (ctx, { subject, body }) => { await ctx.email.send(ctx.auth.userId!, String(subject), String(body)); return { sent: true }; }, }, }, }); ``` That is the whole backend. The agent is a normal action named after its file. Call it from the client with `streamFn`: ```ts theme={null} const gen = streamFn("researcher", { input: question }, { onStreamId: (id) => setLiveStreamId(id), }); for await (const chunk of gen) append(chunk); // token stream const { runId } = (await gen.next()).value as { runId: string }; ``` ## What a run is Every invocation creates (or continues) an `AgentRun` with an ordered `AgentMessage` transcript. Both are ordinary synced entities. The framework injects them into your manifest when any agent exists: * `AgentRun` — `agent`, `status` (`idle | running | completed | failed | cancelled`), `title`, `streamId` (the resumable stream of the current generation), `error`, `pendingInput` (messages queued mid-generation), `cancelRequested`, `steps` (cumulative round-trips), timestamps. Owner-scoped: readable only by the user who started it. * `AgentMessage` — `runId`, `seq`, `role`, `content` (a string for user turns; content blocks for assistant turns, including `tool_use` and `tool_result` records). They are plain synced entities, so the usual rules apply: they replicate into the owner's local replica live, `pylon codegen` types them, `pylon migrate` creates them on Postgres, and policies fence them (clients can never write them; the loop's server-side mutations are the only writer). If your app declares its own `AgentRun` or `AgentMessage` entity (to add columns), yours wins, but the loop still writes through it, so keep the framework's columns intact: `AgentRun` needs `agent`, `status`, `userId`, `title`, `streamId`, `error`, `pendingInput`, `cancelRequested`, `steps`, `createdAt`, `updatedAt`; `AgentMessage` needs `runId`, `userId`, `seq`, `role`, `content`, `createdAt`. The same goes for policies: declaring your own `AgentRun` policy replaces the injected one, so keep reads owner-scoped with the `auth.userId != null &&` guard (bare `data.userId == auth.userId` matches anonymous callers against null-owner rows). ```tsx theme={null} import { useAgentRun } from "@pylonsync/react"; function RunView({ runId }: { runId: string }) { const { run, messages } = useAgentRun(runId); // messages update in real time as the loop persists turns — // in this tab, in other tabs, on the user's phone. } ``` ## Conversations Pass `runId` to continue with full history: ```ts theme={null} for await (const chunk of streamFn("researcher", { input: followUp, runId })) { append(chunk); } ``` The loop reloads the transcript, appends the new user turn, and the model sees everything, including its own earlier tool calls. A run stuck in `running` longer than the agent's `timeout` is a dead generation; the next turn takes it over. A run belonging to another user is indistinguishable from a missing one (`RUN_NOT_FOUND`). Runs always have an owner: an unauthenticated or system caller gets `AGENT_REQUIRES_USER`. To invoke an agent from a cron job or another server-side path, run it under a service user's auth. ## Steering a run in progress Sending to a run that is already generating queues the message instead of refusing it, so a chat UI never has to disable its composer: ```ts theme={null} // The agent is mid-turn. This returns immediately with queued: true. const { queued } = await callFn("researcher", { runId, input: "check the tests too" }); ``` The loop drains the queue at its next turn boundary and folds the text into the transcript where it stays replayable: behind the `tool_result` blocks of the turn it is answering, or as a fresh user turn if the model had already stopped. A message that arrives just as the model stops still extends the same run — the drain and the terminal status write happen in one transaction, so nothing is stranded on a completed run. Queued text lives on `run.pendingInput` until it is drained. Render it as pending below the last message. The queue is capped (32 messages, 32KB); past that, `AGENT_INPUT_QUEUE_FULL`. ## Stopping a run ```tsx theme={null} import { cancelAgentRun, useAgentRun } from "@pylonsync/react"; const { run } = useAgentRun(runId); <button disabled={run?.cancelRequested === true} onClick={() => cancelAgentRun("researcher", runId)} >Stop</button> ``` Cancel is durable state on the run row, not a stream teardown. That matters twice: dropping the SSE connection is **not** a cancel (a detached run is supposed to survive the window closing), and the request reaches a loop running on a different machine than the one serving the click. The loop honours it at its next boundary and settles the run as `cancelled`. Between tool calls that is within `CANCEL_POLL_MS` (2s) — the loop polls while handlers run, and a running handler sees `ctx.signal` abort, so a handler that threads `ctx.signal` into `fetch` stops with the run. A tool that ignores the signal finishes first. **A model response already in flight is not interrupted**: the host blocks its per-call read loop for the whole of `ctx.llm.stream`, so cancel takes effect once that response completes. Every `tool_use` still receives a `tool_result` when a batch stops early, so the transcript stays replayable. Cancelling a run that is not generating settles it as `cancelled` immediately and drops anything queued. Starting a new turn on a cancelled run works like any continuation. ## Watching from another device The transcript arrives per-message via sync. For token-level liveness, the run row records the current generation's resumable stream id: ```ts theme={null} const { run } = useAgentRun(runId); if (run?.status === "running" && run.streamId) { for await (const token of resumeStream(run.streamId)) paint(token); } ``` `resumeStream` replays the buffered tokens from the start, then live-tails. It still delivers the final result if the run finished while you were connecting. ## The stream While generating, the SSE stream carries: * plain data frames — the assistant's text deltas, * `event: tool` frames — `{ name, input, isError }` as each tool executes, so a UI can render "searching docs…" without waiting for the message row, * `event: queued` frames — `{ runId, queued }` when the call steered a generation already in flight instead of starting a turn, * the terminal `event: result` — `{ runId, text, steps, usage }`, plus `cancelled: true` when the run was stopped. ## Tools Tool `args` are the same `v.*` validators as functions, converted to JSON Schema for the model. The model's arguments are validated before your handler runs; invalid input, a thrown handler error, or an unknown tool all become `is_error` tool results the model can react to. A bad tool call never ends the run. Handlers get the full action ctx: `runQuery`/`runMutation` for data, `ctx.llm` for sub-calls, `ctx.email`, `ctx.workflows`, everything. Options: `model` (subject to the server's allowlist), `maxSteps` (default 64; reaching the cap fails the run instead of looping forever), `maxTokens`, `auth` (`"user"` default; agents require a signed-in caller so runs always have an owner), `timeout` (default 600s idle). `maxSteps` bounds one invocation, including the extra turns steering adds to it. The run row's `steps` counts every invocation and is not capped — read it to budget a long-lived conversation. ## Failure semantics A provider error, a `maxSteps` overrun, or a crash marks the run `failed` with the error message on the row, visible to the UI through the same sync channel as every other change. Completed and failed runs are permanent history; start a new turn on the same `runId` to continue after a failure. If a crash interrupted a tool call in progress, the next turn repairs the transcript with an `is_error` tool result ("tool execution was interrupted") so the model's history stays replayable. The repair rides in the same user message as that turn's input, because two user messages in a row are rejected by the Messages API just as a dangling `tool_use` is. Tool results are capped at 64KB per result; anything longer persists with a `[truncated]` suffix. For work that must survive a server restart in progress, wrap the agent call in a [workflow](/concepts/workflows). Runs are durable state, but a generation in progress lives with the process, exactly like any action. # Entities Source: https://docs.pylonsync.com/concepts/entities Declare typed tables with fields, indexes, and relationships. An entity is a table. You declare one with `entity(name, fields, options)`. ```ts theme={null} import { entity, field } from "@pylonsync/sdk"; const User = entity( "User", { email: field.string().unique(), name: field.string(), createdAt: field.datetime(), }, { indexes: [{ name: "by_email", fields: ["email"], unique: true }], }, ); ``` Every entity gets an auto-generated `id`, a 40-char lowercase-hex, lexicographically-sortable string (timestamp + counter), plus the column order you declared. (The runtime rejects non-conforming ids like ULIDs/UUIDs, because cursor pagination relies on the fixed 40-char width.) ## Field types | Method | Type | Notes | | ------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `field.string()` | `TEXT` | Any UTF-8 string | | `field.int()` | `INTEGER` | 64-bit signed | | `field.float()` | `REAL` | 64-bit IEEE-754 | | `field.bool()` | `INTEGER` (0/1) | Boolean (`field.boolean()` is an alias) | | `field.datetime()` | `TEXT` (ISO-8601) | Store with `new Date().toISOString()` | | `field.richtext()` | `TEXT` | For prose; the client SDK has editors ready | | `field.json()` | `TEXT` (serialized) | Arbitrary JSON value — objects, arrays, or scalars. Parsed-on-read: the entity API, `serverData`, `ctx.db`, and sync events all hand back the real value, never a string. On CRDT entities the whole value is one last-writer-wins register. Not searchable, not encryptable. Arg validator twin: `v.json()` | | `field.id(entity)` | `TEXT` | Foreign key to another entity's `id` | ### Modifiers * `.optional()` — column is nullable * `.unique()` — adds a unique index on that one column ```ts theme={null} field.string().optional() // nullable field.string().unique() // UNIQUE constraint field.id("User") // FK to User.id field.int().optional() // nullable int ``` ## CRDT fields Pylon rows are backed by Loro docs in CRDT mode. The database row is a projection of that doc, so ordinary queries, indexes, policies, and search keep working while collaborative fields can merge through CRDT updates. Scalar fields default to LWW registers. `field.richtext()` defaults to `LoroText`, and a normal string can be upgraded when you need character-level merge: ```ts theme={null} const Note = entity("Note", { title: field.string(), body: field.richtext(), summary: field.string().crdt("text"), updatedAt: field.datetime(), }); ``` Current field behavior: | Field | Default merge behavior | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `string`, `datetime`, `id(...)` | LWW string register | | `int`, `float`, `bool` | LWW scalar register | | `richtext` | `LoroText` | | `.crdt("text")` on `string` / `richtext` | `LoroText` | | `.crdt("counter")` on `int` / `float` | `LoroCounter` — patch value is the DELTA to apply; concurrent increments add up (`+1` from two peers → `+2`, not LWW-stomp) | | `.crdt("list")` | `LoroList` — patch is the target array; first write ships snapshot, subsequent ship deltas | | `.crdt("movable-list")` | `LoroMovableList` — same wire shape as `list`; move-op API for true reordering is a future iteration | | `.crdt("tree")` | `LoroTree` — patch is `[{id, parent, ...meta}]`; reconcile maps user-supplied id → TreeID so concurrent moves merge | | `.crdt("lww")` | Explicit LWW | All five CRDT kinds (`text`, `counter`, `list`, `movable-list`, `tree`) are fully implemented as of v0.3.100. Broadcasts ship the full Loro snapshot on first write per row, then incremental deltas (v0.3.105 SQLite, v0.3.107 Postgres) keyed off the last-broadcast version vector. ## Field gates: serverOnly & readonly Two modifiers control how a field flows through the HTTP boundary. They are additive. The field type, optionality, and indexes still work the same. ```ts theme={null} const Org = entity("Org", { name: field.string(), stripeCustomerId: field.string().serverOnly(), // never returned in HTTP responses authorId: field.id("User").readonly(), // set on insert, immutable via HTTP after }); ``` **`.serverOnly()`** — the field is stripped from every public response shape: `GET /api/entities/<entity>`, `GET /api/entities/<entity>/<id>`, the session-projection `/api/auth/session`, and sync push deltas. It stays readable from inside server functions via `ctx.db.*` so your handler can do internal work with it (e.g. webhook receivers look up `stripeCustomerId` from the Stripe customer id without leaking the value to clients). **`.syncOmit()`** — the field is stripped from replication only: snapshot pulls, delta change events, WS fanout, and the reconcile's `sync=1` fetches. Direct reads keep it: `db.get`, entity lists, queries, SSR `serverData`. Use it for heavy-but-not-secret columns (multi-KB JSON blobs, render plans, generated markdown) that would otherwise ride into every browser's replica on every sync: ```ts theme={null} const Variant = entity("Variant", { title: field.string(), // Streams to nobody; the editor fetches it by id when it opens. slidesJson: field.string().syncOmit().optional(), }); ``` Replica rows simply never hold the column, so declare such fields `.optional()` and fetch by id in the detail view that needs them. Contrast `serverOnly`, which hides a field from every client surface. `syncOmit` limits weight; `serverOnly` protects secrecy. If you want the field exposed to a specific client, re-serialize it inside a function return. `ctx.db.unsafe.get` (post v0.3.160) skips the strip; the default `ctx.db.get` honors it. **`.readonly()`** — the field is settable on insert but rejected on update. Any `PATCH /api/entities/<entity>/<id>` payload that mentions the field returns `400 READONLY_FIELD` before the policy even runs. Closes the canonical IDOR-via-update-payload shape: ```ts theme={null} // Policy says: "you can update a Note if you own it." allowUpdate: "data.authorId == auth.userId" // Without readonly: attacker PATCHes { authorId: <attacker_id> } to flip ownership. // // With readonly() on authorId: framework refuses the update before // policy evaluation — attacker can never get the payload to include // their own id without hitting READONLY_FIELD. authorId: field.id("User").readonly() ``` Admin contexts bypass both gates — ops scripts and migrations still rewrite the columns. Server-side writes via `ctx.db.update` inside a mutation/action are not blocked by `.readonly()`. Server code is trusted to enforce its own invariants; readonly is an HTTP-boundary defense, not a hard write-lock. ## Owned writes: `field.owner()` <Info>Requires pylon **0.3.261+**.</Info> `.owner()` marks a field as the row's owner, stamped from the session and unspoofable. It makes optimistic, local-first writes the default for owned data, without a server function. ```ts theme={null} const Offer = entity("Offer", { buyerId: field.string().owner(), // stamped from auth.userId on insert amount: field.float(), // … }); ``` `field.owner()` makes owned creates both optimistic and safe. The client inserts with its session id for immediate local feedback, and the server stamps and verifies that id before the row lands. Without it, a plain `db.insert` must trust a spoofable client-supplied owner id or move the write into a server function with a hand-written optimistic callback. On insert, the framework: * fills the field from `auth.userId` when it's omitted; * passes a value through unchanged when it equals the caller's own id, so the optimistic ghost and canonical row match; * rejects a different non-empty value from a non-admin caller with `403 OWNER_MISMATCH`; * rejects an anonymous caller with `401 OWNER_REQUIRED`. Guests count because they have a stable guest id. On update, `.owner()` behaves like [`.readonly()`](#field-gates-serveronly--readonly). The owner can't be reassigned through the HTTP entity routes. Admin contexts may set an explicit value (migrations, tooling). Use `field.owner()` instead of writing a function whenever the only server-authoritative part of a create is who made it. Mechanically it's a dynamic default: `.owner()` serializes to a `{"$auth":"userId"}` marker that the auth-aware mutation pipeline fills. The storage layer never stamps it without a session, so there is no way to end up with a row "owned" by no one. ## Indexes Declare composite or non-unique indexes in the options block: ```ts theme={null} entity( "Message", { roomId: field.id("Room"), authorId: field.id("User"), sentAt: field.datetime(), body: field.richtext(), }, { indexes: [ { name: "by_room_time", fields: ["roomId", "sentAt"], unique: false }, { name: "by_author", fields: ["authorId"], unique: false }, ], }, ); ``` Indexes are created and maintained automatically. Live queries use them to stay fast under load. ## Relationships Pylon has no separate relation primitive. Use `field.id("Other")` and query with filters. The typed client `db.query("Message", { roomId })` narrows by indexed columns. ```ts theme={null} // In a server function: const msgs = await ctx.db.query("Message", { roomId: args.roomId }); // Or in a React client: const { data: messages } = db.useQuery("Message", { where: { roomId } }); ``` ## Schema changes Edit `app.ts` and save. `pylon dev` detects the change and runs a live migration. Pylon's storage layer plans the diff (add column, drop index, etc.) and applies it to your database, whether SQLite or Postgres. Destructive operations (dropping a column that has data) require you to bump `manifest.version`. ## Next <CardGroup> <Card title="Policies" icon="shield" href="/concepts/policies"> Control who can read and write each row. </Card> <Card title="Functions" icon="function" href="/concepts/functions"> Write server-side logic. </Card> </CardGroup> # Functions Source: https://docs.pylonsync.com/concepts/functions Write server-side RPC with queries, mutations, and actions. A function is server TypeScript, RPC-callable from the client. Pylon runs them in a Bun process managed by the runtime. Three flavors: * **Query** — read-only, can subscribe to changes * **Mutation** — writes through the transactional path * **Action** — arbitrary side effects (HTTP calls, emails, file ops) ## Writing a function Create a file in `functions/`: ```ts theme={null} // functions/createMessage.ts import { mutation, v } from "@pylonsync/functions"; export default mutation({ // Every function declares an auth mode. "user" is the default; // see the Auth section below. The framework enforces this before // the handler runs, so `ctx.auth.userId` is `string` here. args: { roomId: v.id("Room"), body: v.string(), }, async handler(ctx, args) { const id = await ctx.db.insert("Message", { roomId: args.roomId, authorId: ctx.auth.userId, body: args.body, sentAt: new Date().toISOString(), }); return { id }; }, }); ``` The filename becomes the RPC name — `createMessage` is callable at `POST /api/fn/createMessage`. ## Context object `ctx` gives you: ```ts theme={null} ctx.auth.userId // string (when auth: "user" | "admin") OR string | null (when auth: "public" | "guest") ctx.auth.isAdmin // boolean ctx.auth.tenantId // string | null (selected org) ctx.auth.roles // string[] (exact active-org/session roles) ctx.auth.elevate({ admin: true, reason: "..." }) // promote AFTER verifying a webhook/HMAC // No ctx.auth.email on the handler ctx — fetch the user row for email: // const u = await ctx.db.get("User", ctx.auth.userId) // ctx.db — on queries (read-only) and mutations (read + write). NOT on actions. ctx.db.insert(entity, data) ctx.db.get(entity, id) ctx.db.query(entity, filter?) ctx.db.list(entity) // whole entity (no filter) ctx.db.update(entity, id, patch) ctx.db.delete(entity, id) ctx.db.link(entity, id, relation, targetId) ctx.db.unlink(entity, id, relation) // Mutations + actions only (not queries): ctx.error(code, message) // throw typed error → mutation rolls back ctx.scheduler.runAfter(delayMs, ...) // schedule (deferred until COMMIT) ctx.llm.complete(request) // LLM completion, returns when the model finishes ctx.llm.stream(request, onEvent) // LLM completion, event per delta ctx.stream.write(text) // SSE chunk → the one client holding this request ctx.rooms.broadcast(room, topic, data) // push → every subscriber of a presence room // All three flavors: ctx.files.signedUrl(fileId, { ttlSecs: 300 }) // mint a short-lived signed download URL ``` ### Signed file URLs `GET /api/files/<id>` serves a file only to its owner (or an unscoped admin). When your app's own rules say someone else should see it — an organizer reviewing a member's upload — mint a signed URL from a function that enforces those rules: ```ts theme={null} export default query({ args: { eventId: v.id("Event"), fileId: v.string() }, async handler(ctx, args) { const event = await ctx.db.get("Event", args.eventId); await ctx.requireMember(event.orgId, { role: "organizer" }); return { url: await ctx.files.signedUrl(args.fileId, { ttlSecs: 300 }) }; }, }); ``` The returned path (`/api/files/<id>?sig=<hmac>&exp=<unix>`) is anonymously fetchable until it expires. It works in `<img src>` and download links. `ttlSecs` defaults to 300 and is capped at 24 hours; a signed URL is a bearer capability, so keep lifetimes short. Invalid or expired signatures fall back to the ordinary owner check, so nothing new becomes enumerable. The three flavors expose different context surfaces: * **Query** — `ctx.db` (read-only), `ctx.auth`, `ctx.env`, `ctx.requireMember`. * **Mutation** — the above plus writes on `ctx.db`, `ctx.scheduler`, `ctx.error`, `ctx.llm`, `ctx.connections`, `ctx.stream`, `ctx.rooms`. * **Action** — no `ctx.db`. Reach the database through `ctx.runQuery(name, args)` / `ctx.runMutation(name, args)` (each runs as its own transaction). Actions also get `ctx.scheduler`, `ctx.error`, `ctx.email`, `ctx.llm`, `ctx.stream`, `ctx.rooms`, `ctx.connections`, and `ctx.request` (raw HTTP request, the only ctx that has it, so webhook signature checks live in actions). > A mutation handler is the transaction: every `ctx.db.*` call inside the handler shares one BEGIN/COMMIT, and a thrown error rolls everything back atomically. There's no `ctx.db.transact([...])` because the handler already wraps your writes; if you need batch atomicity from outside a mutation, use the HTTP `/api/transact` endpoint or call `runMutation` from an action. ## Auth (secure by default) Every function declares who can call it. The framework enforces this before the handler runs. A missing `if (!ctx.auth.userId)` check no longer leaks data, because the runtime made the check first. ```ts theme={null} export default mutation({ auth: "user", // default — signed-in user required args: { ... }, async handler(ctx, args) { // ctx.auth.userId is `string`, not `string | null`. The // redundant null check is gone. await ctx.db.insert("Note", { authorId: ctx.auth.userId, ... }); }, }); ``` Four modes: | Mode | Reaches the handler when… | Use it for | | ------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------- | | `"user"` (default) | Real signed-in user. Guest sessions rejected. | Almost everything. | | `"public"` | Anyone, including unauthenticated callers. | Healthchecks, landing-form submits, webhook receivers that verify their own signature. | | `"guest"` | Guest session OR real user. | Cart-style pre-login state. | | `"admin"` | `ctx.auth.isAdmin === true`. | Ops endpoints, dangerous helpers exposed via `/api/fn/...`. | When the mode doesn't match the caller, the request is rejected before the handler runs: `401 AUTH_REQUIRED` for `"user"` / `"guest"`, `403 FORBIDDEN` for `"admin"`. Admin sessions bypass every mode (same convention as policies). ```ts theme={null} // Public endpoint — has to be explicit, never the default. export default action({ auth: "public", async handler(ctx) { // ctx.auth.userId is `string | null`. Check it if you need it. return { ok: true }; }, }); ``` **Action authentication.** Policies gate `ctx.db.*` reads and writes, but they do not gate `action` handlers. An action that charges Stripe, sends email, or calls a private API relies on its `auth` setting, so Pylon requires authentication by default. `internal: true` functions ignore `auth`. They're unreachable over HTTP and inherit the wrapping handler's context. ## `ctx.db` honors policies (strict mode) By default, `ctx.db.*` inside a function bypasses entity policies because server code is trusted. This remains the default for compatibility. Strict mode flips the default: every `ctx.db.get/query/insert/update/delete/lookup/search` runs through the policy engine using the function's caller auth, exactly as if the same operation came in through `/api/entities/*`. Enable per deploy: ```sh theme={null} PYLON_STRICT_FN_POLICIES=1 pylon dev ``` When strict mode is on, the canonical Pylon-shaped IDOR ("I wrote `getRecording.ts` that does `ctx.db.get('Recording', args.id)` and forgot to check tenant ownership") becomes impossible. The policy on Recording fires, sees that the caller isn't a member of the row's org, and the call returns `POLICY_DENIED` before the row leaves the database. For the legitimate cross-tenant cases (admin tools, webhook receivers post signature verification, scheduled cron sweeps), use the explicit escape hatch: ```ts theme={null} // functions/stripeWebhook.ts // The webhook is an action — only actions get ctx.request (the raw // body a signature check needs). Actions have no ctx.db, so the DB // work runs in an internal mutation the action hands off to. export default action({ auth: "public", // webhook — provider doesn't sign in async handler(ctx) { const sig = ctx.request!.headers["stripe-signature"]; const ok = verifyStripeSignature(secret, ctx.request!.rawBody, sig); if (!ok) throw ctx.error("INVALID_SIGNATURE", "bad sig"); // Trust boundary crossed — the webhook is the trusted caller. await ctx.runMutation("settleStripeEvent", { customerId }); }, }); ``` ```ts theme={null} // functions/settleStripeEvent.ts — internal: unreachable over HTTP export default mutation({ internal: true, args: { customerId: v.string() }, async handler(ctx, args) { // Use ctx.db.unsafe to read any org's row regardless of who's // calling. Comment justifies the bypass for code review. const org = await ctx.db.unsafe.lookup("Org", "stripeCustomerId", args.customerId); // ... }, }); ``` Guidelines: * **Plain `ctx.db.*`** — the default. Acts as the caller. Use for anything that should reflect the user's view of the data. * **`ctx.db.unsafe.*`** — explicit bypass. Use for webhooks, cron sweeps, admin tooling, and anything that genuinely needs cross-tenant reads. Every call should have a justifying comment. * **Admin contexts bypass strict mode** — `auth.isAdmin === true` skips the gate the same way it bypasses entity-route policies. Ops scripts and the `ctx.auth.elevate({ admin: true, reason: "..." })` path inside verified webhooks still work everywhere (the `reason` is mandatory and audited). Strict mode is opt-in for now (one minor cycle) so existing apps can migrate gradually. The rollout plan: 1. **Now (v0.3.161+)** — `ctx.db.unsafe.*` is callable. Mark known cross-tenant paths with it. Plain `ctx.db.*` still bypasses policies (existing behavior); strict mode is opt-in via env. 2. **v0.4** — strict mode default-on. Apps that didn't migrate get `POLICY_DENIED` on the calls that genuinely need cross-tenant access; the fix is to mark those `unsafe`. ## Validators `v.*` describes expected argument shapes: ```ts theme={null} v.string() v.int() v.float() v.boolean() v.datetime() v.id("User") v.optional(v.string()) v.array(v.string()) v.literal("open" | "closed") v.object({ k: v.string() }) ``` The same validators work in query, mutation, and action arguments. ## Queries ```ts theme={null} // functions/listIssues.ts import { query, v } from "@pylonsync/functions"; export default query({ args: { teamId: v.id("Team") }, async handler(ctx, args) { return ctx.db.query("Issue", { teamId: args.teamId }); }, }); ``` Queries are live by default. The React client subscribes and re-runs on relevant changes. See [Live queries](/concepts/live-queries). ## Actions Use for side effects outside the database: ```ts theme={null} // functions/sendEmail.ts import { action, v } from "@pylonsync/functions"; export default action({ args: { to: v.string(), subject: v.string() }, async handler(ctx, args) { await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.RESEND_KEY}` }, body: JSON.stringify({ to: args.to, subject: args.subject }), }); return { ok: true }; }, }); ``` Actions have no `ctx.db`. They reach the database through `ctx.runQuery(name, args)` / `ctx.runMutation(name, args)`. Each of those runs as its own transaction, but the action as a whole is not atomic. If a sequence of writes must commit or roll back together, do them inside a single mutation and have the action call it once. ## Streaming LLM output `ctx.llm.complete(request)` sends a completion and resolves once the model has finished generating. `ctx.llm.stream(request, onEvent)` sends the same request but calls `onEvent` for each event as the provider emits it, then resolves with the same assembled response `complete` would have returned. A handler can push text to the client as it arrives and still inspect `stop_reason` afterwards. Both live on mutation + action ctx. Neither is on query ctx: a subscribed query re-runs whenever its `ctx.db` reads change, and a paid, non-deterministic call should not re-run on every dep invalidation. ```ts theme={null} // functions/ask.ts import { action, v } from "@pylonsync/functions"; export default action({ args: { question: v.string() }, async handler(ctx, args) { const res = await ctx.llm.stream( { messages: [{ role: "user", content: args.question }] }, (e) => { // Push each token to the caller as the model produces it. if (e.type === "text_delta") ctx.stream.write(e.text); }, ); return { stopReason: res.stop_reason, usage: res.usage }; }, }); ``` The client reads the chunks with `db.streamFn`: ```tsx theme={null} for await (const chunk of db.streamFn("ask", { question: "why is the sky blue?" })) { setAnswer((prev) => prev + chunk); } ``` The `Accept: text/event-stream` header alone does not force an SSE response. A handler that returns without ever calling `ctx.stream.write` answers with plain JSON (the raw return value), exactly like a JSON-only call. `db.streamFn` handles both. Serving MCP from an action relies on this: MCP clients advertise SSE support on every POST but expect a plain JSON body when the server doesn't stream. Every fn stream is resumable: the server buffers frames under a stream id, so a dropped connection reconnects from its cursor and misses nothing, including the final result after the handler finished. See [Resumable Streams](/concepts/streaming). ### Stream events `onEvent` receives one of four shapes: | Event | Fields | What to do with it | | ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `text_delta` | `text` | Append it — this is the model's prose, one fragment at a time. | | `tool_use_start` | `id`, `name` | A tool call is opening. Start a buffer for its arguments. | | `tool_input_delta` | `partial_json` | A raw JSON fragment of the open tool's arguments. Concatenate; parse **once**, at the end. Individual fragments are not valid JSON. | | `done` | `stop_reason`, `usage` | Always fires last, including on a partial failure. | Everything else matches `complete`: same auth gating, same model allowlist (`PYLON_AI_MODELS_ALLOWED` / the manifest `llm()` helper), same thrown errors carrying `err.code` (`LLM_NOT_CONFIGURED`, `MODEL_NOT_ALLOWED`, `PROVIDER_HTTP_429`, …). Streaming can't reach a model `complete` would refuse. ### Streaming does not extend the deadline The call deadline is absolute wall clock from invocation: `PYLON_FN_CALL_TIMEOUT`, 30 seconds by default. Emitting events does not reset it. A multi-turn agent that runs tools will exceed 30s, so declare a timeout on the function: ```ts theme={null} export default action({ timeout: 300, // seconds of wall clock for the whole handler args: { ... }, async handler(ctx, args) { ... }, }); ``` ### Agent tool loop Stream the text out as it's generated, run tools when the model asks, feed the results back, repeat until the model stops asking: ```ts theme={null} // functions/agent.ts import { action, v } from "@pylonsync/functions"; // Anthropic Messages shape: content is prose, or content blocks — // tool_use coming back from the model, tool_result going the other way. type Turn = { role: "user" | "assistant"; content: string | any[] }; const tools = [ { name: "get_order", description: "Look up an order by id.", input_schema: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"], }, }, ]; export default action({ args: { question: v.string() }, timeout: 300, // streaming does NOT extend the 30s default async handler(ctx, args) { const messages: Turn[] = [{ role: "user", content: args.question }]; for (let turn = 0; turn < 10; turn++) { const res = await ctx.llm.stream({ messages, tools }, (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); }); // Keep the assistant turn verbatim — it carries the tool_use // blocks the next turn's tool_result blocks refer to by id. messages.push({ role: "assistant", content: res.content }); if (res.stop_reason !== "tool_use") return { turns: turn + 1 }; const results = []; for (const block of res.content) { if (block.type !== "tool_use") continue; const order = await ctx.runQuery("getOrder", { id: (block.input as { orderId: string }).orderId, }); results.push({ type: "tool_result" as const, tool_use_id: block.id, content: JSON.stringify(order ?? { error: "not found" }), }); } messages.push({ role: "user", content: results }); } throw ctx.error("AGENT_LOOP_LIMIT", "tool loop did not converge"); }, }); ``` The loop bound matters: a model can keep requesting tools indefinitely. Without a cap, the only thing that stops it is the call deadline ending the worker during a turn. ## Server push to a room `ctx.stream.write` writes to the HTTP response of the call in progress. It reaches exactly one client, the one that made the request. Close the tab and the output is gone; a second device never sees it. `ctx.rooms.broadcast(room, topic, data)` pushes to every subscriber of a presence room over the WebSocket, independent of who made the call. Same rooms clients join with `useRoom`, same delivery path a member's own `broadcast()` uses. ```ts theme={null} await ctx.rooms.broadcast("chat-42", "agent.delta", { text: "hi" }); // => { delivered: true } ``` It resolves `{ delivered: false }` when the room has no members. Broadcasting into an empty room is a no-op, not an error. An agent doesn't have to know whether anyone is watching. Available on mutation + action ctx, not on queries: a reactive query re-runs on every dep change, which would re-broadcast each time. Pick based on who needs the output: * **One caller, one screen** (a chat box waiting on its own response) — `ctx.stream.write`, read with `db.streamFn`. * **Everyone watching, including tabs that weren't the caller** (agent output that must survive a reload, a shared session on a second device, a job with no HTTP caller at all) — `ctx.rooms.broadcast`. Doing both in one handler is normal. Write to the caller for the low-latency path, then broadcast so every other watcher stays in sync: ```ts theme={null} await ctx.llm.stream({ messages }, (e) => { if (e.type !== "text_delta") return; ctx.stream.write(e.text); // the caller void ctx.rooms.broadcast(room, "agent.delta", { text: e.text }); // everyone else }); ``` On the client, join the room with `useRoom` and read the pushed messages off the sync engine: ```tsx theme={null} import { useRoom, getSync } from "@pylonsync/react"; useRoom("chat-42", userId); // joins the room — required to receive pushes useEffect(() => { return getSync().subscribeRoomMessages("chat-42", (m) => { if (m.topic === "agent.delta") append((m.payload as { text: string }).text); }); }, []); ``` Server broadcasts arrive with an empty `from` (no sender user id). A member's own broadcasts carry theirs, so `m.from === userId` is how you filter your own echoes. ## Calling functions from the client ```tsx theme={null} import { callFn } from "@pylonsync/react"; async function onSend() { const { id } = await callFn("createMessage", { roomId: "01H...", body: "hello", }); console.log("created", id); } ``` ## Errors Throw typed errors that propagate to the client with structured codes: ```ts theme={null} if (!ctx.auth.userId) { throw ctx.error("UNAUTHENTICATED", "log in first"); } if (msg.length > 2000) { throw ctx.error("INVALID_ARGS", "message too long"); } ``` The client receives `{ code, message }` and can render different UI for each code. ## Next <CardGroup> <Card title="Live queries" icon="radio" href="/concepts/live-queries"> How query subscriptions stay in sync. </Card> <Card title="Validators" icon="check" href="#validators"> All argument shapes `v.*` supports. </Card> </CardGroup> # Live queries Source: https://docs.pylonsync.com/concepts/live-queries Subscriptions that stay in sync with zero extra code. A live query is a server query whose result streams updates to the client. In React: ```tsx theme={null} import { db } from "@pylonsync/react"; function Inbox() { const { data: messages, loading } = db.useQuery("Message", { where: { roomId } }); if (loading) return null; return messages.map((m) => <Row key={m.id} msg={m} />); } ``` When anyone inserts, updates, or deletes a `Message` row matching `{ roomId }`, every subscribed client gets the new result, without writing a single line of WebSocket code. ## How it works 1. Client opens a WebSocket to the Pylon server. 2. `useQuery` sends a `subscribe` frame with the entity and filter. 3. Server runs the query once, returns the initial result, and adds the subscription to its index. 4. On every mutation, Pylon's change log walks all active subscriptions whose filter matches the changed row and pushes the diff. 5. Client applies the diff locally and React re-renders. ## Filters Filters map to indexed columns. Equality is always fast; inequality and range scans require an index on the queried columns. ```tsx theme={null} db.useQuery("Issue", { where: { teamId, state: "open" } }); db.useQuery("Message", { where: { roomId } }); db.useQuery("User"); // full table (careful, scales O(N)) ``` ## Typed queries With codegen, `db` is fully typed to your schema: ```tsx theme={null} const { data } = db.useQuery("Message", { where: { roomId } }); // data: Message[] const { data: issue } = db.useQueryOne("Issue", issueId); // issue: Issue | null ``` Type errors catch mismatched filter keys or wrong entity names at compile time. ## Pagination Live pagination works; each page is its own subscription: ```tsx theme={null} const { data, hasMore, loadMore } = db.useInfiniteQuery("Message", { pageSize: 50, }); ``` ## Policies apply Live queries respect [policies](/concepts/policies). A subscribed client only receives rows it's allowed to read. If a row becomes readable (or unreadable) due to a policy re-evaluation, the subscription updates. ## Performance Pylon maintains an index of active subscriptions keyed by entity + indexed filter fields. A write that affects N matching subscriptions is O(N), not O(all subscriptions). Practical numbers: * Tens of thousands of concurrent subscriptions per server * Sub-millisecond fan-out per subscriber * Writes stay fast because the subscription index is kept small via the schema's declared indexes See the [`bench` example](https://github.com/pylonsync/pylon/tree/main/examples/bench) to run the measurements locally. ## Falling back to non-live Sometimes you want a one-shot read without the subscription overhead. Call a query function once with `db.fn` (or `callFn`) instead of subscribing: ```tsx theme={null} const rows = await db.fn("listMessages", { roomId }); ``` Where `listMessages` is a server function: ```ts theme={null} // functions/listMessages.ts import { query, v } from "@pylonsync/functions"; export default query({ args: { roomId: v.id("Room") }, async handler(ctx, args) { return ctx.db.query("Message", { roomId: args.roomId }); }, }); ``` ## Next <CardGroup> <Card title="Realtime overview" icon="radio" href="/concepts/realtime"> Every realtime primitive and which to use. </Card> <Card title="React SDK" icon="react" href="/clients/react"> All the hooks the client exposes. </Card> </CardGroup> # Optimistic updates Source: https://docs.pylonsync.com/concepts/optimistic-updates Local-first UI without the flash. A mutation that paints its result into the UI before the server confirms is an optimistic update. Pylon's sync engine supports the pattern natively. The optimistic ghost and the canonical row share an id, so the WebSocket broadcast lands as a field-level merge instead of a delete-then-replace flash. ```tsx theme={null} import { db } from "@pylonsync/react"; const send = db.useMutation< { channelId: string; body: string }, { messageId: string } >("sendMessage", { optimistic: (args, ctx) => ({ entity: "Message", data: { id: ctx.id, channelId: args.channelId, authorId: me.id, body: args.body, createdAt: ctx.now, }, }), }); await send.mutate({ channelId, body }); ``` The user sees their message appear instantly. The canonical row arrives over the WebSocket a moment later and merges in place: no flash, no temp-row swap, no manual cleanup. <Note> For an owned-row create, `db.insert("Entity", …)` is already optimistic. Mark the owner field [`field.owner()`](/concepts/entities#owned-writes-fieldowner) (0.3.261+) so the server stamps and verifies it from the session. Use the `optimistic` callback below when the write also runs server logic such as cross-entity validation, denormalization, or multi-row effects. </Note> ## How it works 1. `useMutation` calls `optimistic(args, ctx)` to build the ghost row. `ctx.id` is a freshly-minted Pylon-shaped row id (40-char hex); `ctx.now` is a stable timestamp for the gesture. 2. The hook paints the ghost into the local store via `optimisticInsertWithId`. Live queries re-render immediately. 3. The hook calls the server function with the original args **plus** `_optimisticId: ctx.id`. 4. The server function passes `args._optimisticId` into `ctx.db.insert("Entity", { id, ... })`. The runtime validates the id is well-formed and uses it verbatim. 5. The change log emits a broadcast with `row_id = ctx.id`. It arrives at the client over the WebSocket and the local store treats it as an idempotent merge, same `row_id`, fields refreshed. 6. **On rejection**, the optimistic ghost is rolled back without leaving a tombstone, so retrying the mutation works. ## The server function To accept optimistic ids, your mutation needs one extra arg: ```ts theme={null} import { mutation, v } from "@pylonsync/functions"; export default mutation({ args: { channelId: v.id("Channel"), body: v.string(), _optimisticId: v.optional(v.string()), }, async handler(ctx, args) { const messageId = await ctx.db.insert("Message", { ...(args._optimisticId ? { id: args._optimisticId } : {}), channelId: args.channelId, authorId: ctx.auth.userId, body: args.body, createdAt: new Date().toISOString(), }); return { messageId }; }, }); ``` The runtime validates `_optimisticId` is a 40-char lowercase hex string (the shape `generateId()` produces). Anything else returns an `INVALID_ID` error before the row is inserted. If two clients somehow mint the same id, the second insert fails with a typed `OPTIMISTIC_ID_CONFLICT` error code so retry logic can mint a fresh id and try again. ## Multiple rows in one gesture Some mutations touch more than one entity — accepting an invite, for example, inserts a Membership row AND an AuditLog row. Return an array from the builder: ```tsx theme={null} const acceptInvite = db.useMutation<{ inviteId: string }, { membershipId: string }>( "acceptInvite", { optimistic: (args, ctx) => [ { entity: "Membership", data: { id: ctx.id, userId: me.id, /* … */ }, }, { entity: "AuditLog", data: { id: ctx.id, action: "invite.accept", actorId: me.id, at: ctx.now }, }, ], }, ); ``` All ghosts use the same `ctx.id`, so they're rolled back together on rejection. ## When to skip it Optimistic updates help when the server almost always succeeds. Skip them when: * The mutation can fail in ways the user must see immediately (e.g. payment, ID verification). Showing the ghost only to remove it 200ms later is worse than a small spinner. * The server transforms the data significantly (e.g. computes a slug, generates a thumbnail, runs through an AI). The ghost would be visibly different from the canonical row, so the optimistic update gives no benefit. * The feature is rarely-used (settings dialogs, admin pages). The complexity isn't worth the polish. Optimistic updates fit chat, comments, todos, likes, reactions, and drag-and-drop reordering where the server usually accepts the write. ## Manual control For mutations that don't fit the `useMutation` shape (background syncs, multi-step flows), the underlying primitives are exported from `@pylonsync/sync`: ```ts theme={null} import { generateId } from "@pylonsync/sync"; const id = generateId(); db.sync.store.optimisticInsertWithId("Message", id, { id, body, /* … */ }); try { await myCustomFlow({ id, body }); } catch { db.sync.store.rollbackOptimisticInsert("Message", id); } ``` `rollbackOptimisticInsert` removes the ghost without leaving a tombstone, so a future legitimate insert with the same id (e.g. eventual consistency from a workflow) isn't blocked. # Policies Source: https://docs.pylonsync.com/concepts/policies Row-level access rules that live next to your schema. A policy is a boolean expression evaluated on every row access. It lives beside your entities. ```ts theme={null} import { policy } from "@pylonsync/sdk"; const noteOwner = policy({ name: "note_owner", entity: "Note", allowRead: "true", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == data.authorId", allowDelete: "auth.userId == data.authorId", }); ``` ## What the expressions see | Binding | Type | Contains | | --------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auth.userId` | `string \| null` | Current user's id, `null` if unauthenticated | | `auth.isAdmin` | `boolean` | `true` for the `admin` role / admin token / Studio session | | `auth.tenantId` | `string \| null` | Selected organization id (multi-tenant apps) | | `data.*` | `object` | The row: incoming payload on insert; the **current stored row** on read/update/delete | | `existing.*` | `object` | Synonym for the current row (same as `data.*` on read/update/delete) | | `now` | `string` | Current UTC time as an ISO-8601 string, for time windows | | `ago("30d")` | `string` | The instant that long before `now`, for rolling windows. Units `s`/`m`/`h`/`d`/`w`, compound allowed (`"1d12h"`). A malformed duration fails at boot, not silently at evaluation | Roles are checked with the `auth.hasRole("x")` / `auth.hasAnyRole("a", "b")` functions (below). There is no `auth.roles` array and no `auth.email` binding in policy expressions. (Email/roles live on the session and the SSR page `auth` prop, just not in the policy evaluator.) ## Each action * **`allowRead`** — runs on query results. If false, row is filtered out. * **`allowInsert`** — runs against the proposed `data` (the incoming row). * **`allowUpdate`** — runs against the current stored row (bound as both `data` and `existing`), so ownership checks see the truth, not the caller's patch. * **`allowDelete`** — runs against the current row (`data` / `existing`). If a policy is omitted, that action is denied by default. ## Operators The policy language is deliberately tiny. The complete operator set is: ``` == != // equality < <= > >= // ordering (see below) && || ! // boolean logic true false null // literals 42 -3 4.5 // numeric literals (int / float / negative) "string" 'string' // string literals (either quote) ``` Plus three built-in forms: ``` auth.hasRole("admin") // role check auth.hasAnyRole("admin", "owner") // any-of role check ago("30d") // timestamp that duration before now (s/m/h/d/w) exists(Entity where field == <expr> [and field == <expr>]*) // correlated subquery ``` Ordering (`<` `<=` `>` `>=`) compares **numbers** numerically, two **strings** chronologically when both are valid ISO-8601 timestamps (else lexicographically), and is **deny-safe** otherwise: comparing null, a boolean, or a number against a non-numeric string is always false. This is what makes `data.publishAt <= now` deny rows that aren't published yet. There is still **no `in`, no `ends_with`/`starts_with`, and no arithmetic (`+ - * /`).** Membership is expressed with `exists(...)`, not `in`. String prefix/suffix matching belongs in a [function](/concepts/functions). ## Examples ### Public read, author-only write ```ts theme={null} policy({ name: "post_public", entity: "Post", allowRead: "true", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == existing.authorId", allowDelete: "auth.userId == existing.authorId", }); ``` ### Members of an org Membership lives in a join entity (`OrgMember { orgId, userId }`) and is checked with `exists(...)`: ```ts theme={null} policy({ name: "org_member", entity: "Document", allowRead: "exists(OrgMember where orgId == existing.orgId and userId == auth.userId)", allowInsert: "exists(OrgMember where orgId == data.orgId and userId == auth.userId)", allowUpdate: "exists(OrgMember where orgId == existing.orgId and userId == auth.userId)", }); ``` ### Admin override ```ts theme={null} policy({ name: "admin_all", entity: "AuditLog", allowRead: "auth.isAdmin", // or: auth.hasRole("admin") allowInsert: "auth.isAdmin", }); ``` ### Time window (scheduled publishing) `now` plus an ordering comparison gates rows by time — public once published, with the author still able to preview: ```ts theme={null} policy({ name: "post_published", entity: "Post", allowRead: "data.publishAt <= now || auth.userId == data.authorId", }); ``` ### Rolling time window `ago(...)` compares against a moving cutoff instead of a stored timestamp. As a read policy it hides old rows everywhere; more commonly it belongs in a **sync scope** (`sync: { where: ... }` on the entity), where it bounds the client replica of an append-only table — recent rows stay live, old rows stay reachable through direct reads and queries: ```ts theme={null} const Event = entity( "Event", { name: field.string(), createdAt: field.datetime() }, // Replicate only the last 30 days into browsers; the table itself // grows forever without growing every client's replica or catch-up. { sync: { where: 'data.createdAt >= ago("30d")', limit: 20_000 } }, ); ``` ## Policy composition Multiple policies on the same entity **AND** together — all must pass. Use this to layer a broad rule with a narrow exception: ```ts theme={null} const readable = policy({ name: "readable", entity: "Doc", allowRead: "true" }); const noDrafts = policy({ name: "no_drafts", entity: "Doc", allowRead: "existing.status != 'draft' || auth.userId == existing.authorId", }); ``` ## Server functions bypass policies Policies guard the **raw** `/api/entities/*` endpoints. Server functions (`ctx.db.insert`, `ctx.db.update`, etc.) run with elevated access — they trust you to enforce your own checks inside the handler. This lets you write escape-hatch operations (admin tools, batch imports) without contorting your policy language. ## Next <CardGroup> <Card title="Functions" icon="function" href="/concepts/functions"> Write server logic for anything policies can't express. </Card> <Card title="Live queries" icon="radio" href="/concepts/live-queries"> Policies also filter subscription results. </Card> </CardGroup> # Reactive queries Source: https://docs.pylonsync.com/concepts/reactive-queries Write a server query once and re-render clients when its dependencies change. # Reactive queries A reactive query is a server-side `query()` handler that re-runs automatically whenever the data it reads changes. The client subscribes once. The server pushes a new result every time a mutation touches anything in the handler's dependency set. This pattern is called a live joined query. Convex introduced it. Pylon uses the same model, with native auth, multi-tenant policies, and self-hosting built in. ## Writing a reactive query Any `query()` handler is eligible. It needs no opt-in flag and no special decorator. Call `db.useReactiveQuery` instead of a one-shot `useFn` call or fetch to enable it at the call site. ```ts theme={null} // functions/getFeed.ts import { query, v } from "@pylonsync/functions"; export default query({ args: { userId: v.id("User") }, async handler(ctx, args) { const posts = await ctx.db.list("Post"); return Promise.all( posts .filter((p) => p.authorId !== args.userId) // exclude self .slice(0, 50) .map(async (p) => ({ ...p, author: await ctx.db.get("User", p.authorId), })), ); }, }); ``` The runtime watches every `ctx.db.*` call this handler makes and records the set of entities and row ids it touched. In the example above, the dependency set is `{ Post: *, User: { authorId1, authorId2, ... } }`. ## Subscribing from React ```tsx theme={null} import { db } from "@pylonsync/react"; function Feed({ userId }: { userId: string }) { const { data: feed, loading, error } = db.useReactiveQuery<FeedItem[]>( "getFeed", { userId }, ); if (loading) return <Spinner />; if (error) return <ErrorBanner error={error} />; return feed.map((item) => <PostCard key={item.id} item={item} />); } ``` On mount, the hook sends `reactive-subscribe` over the WebSocket. The server runs `getFeed`, captures dependencies, registers the subscription, and pushes the initial result. After that, every server-side mutation touching `Post` or a `User` row in the captured set triggers a re-run. The server hashes the new result and pushes it only when it differs from the last value sent. A mutation that does not change the rendered output still costs one re-run on the server, but causes no client re-render. On unmount, the hook sends `reactive-unsubscribe`. The server drops the subscription and stops re-running. ## Auth context across re-runs The handler always runs under the subscriber's auth context on every re-run, never the mutating user's. A policy like `auth.userId == row.ownerId` applied at first run applies on every later re-run. This is the only correct behavior. If re-runs used the mutating user's auth, a Stripe webhook (running as an elevated admin) would re-evaluate `getFeed` with admin privileges and push the unfiltered result to a logged-in user. That would be a silent read-policy bypass. ## Dependency tracking granularity The runtime tracks two levels: * Entity-level: any read of `ctx.db.list("Post")` or `ctx.db.query("Post", {...})` marks the dependency as "entity `Post`, any row". Mutations to any Post row dirty the subscription. * Row-level: `ctx.db.get("User", "u_123")` marks the dependency as "entity `User`, row `u_123`". Mutations to other User rows do not dirty the subscription. The dependency set is precise for `get` and `lookup`, and coarse for `list`, `query`, and `search`. Handlers that mix both get the union: list reads add entity-level dependencies, and targeted reads add row-level dependencies on top. Row-level dependencies are capped per subscription (default 256). Beyond the cap, the subscription falls back to entity-level matching. This cap stops runaway queries from bloating the registry. ## Coalescing and re-run cadence Multiple change events touching the same subscription within one tick coalesce to a single re-run. The re-runner thread drains the dirty set on a notify-driven cadence, with no fixed interval. It wakes immediately when work arrives. Bursts of writes do not cause per-event re-runs. ## When to use a reactive query instead of an entity query | Use case | Reactive query | Entity query (useQuery) | | ------------------------------------------ | ------------------------- | --------------------------- | | Server-side joins (entity + related rows) | ✅ | ❌ | | Aggregations (count, sum, group-by) | ✅ | ❌ | | Computed fields derived from multiple rows | ✅ | ❌ | | Simple `where` filter on one entity | ✅ | ✅ | | Fully cached IndexedDB replica | ❌ | ✅ | | Offline-first writes | Server roundtrip required | Optimistic UI works offline | Use a reactive query when the value rendered on screen comes from a function that touches multiple entities, or computes something the client cannot easily derive from cached rows. Use an entity query (`db.useQuery`) when the screen shows a list of rows from one entity and the client can filter, sort, or paginate locally for free. ## Cross-machine support Reactive queries work on multi-machine deployments. The runtime forwards change events into the registry from both the local mutation path and the cluster bus subscriber (see [horizontal scaling](/operations/scaling)). A mutation on machine A dirties subscriptions on machine B. B's re-runner runs the handler and pushes the result to its connected client. ## Limits * One handler call per re-run. There is no incremental dataflow (Materialize or differential dataflow). For expensive handlers, the re-run cost equals the full handler cost. Use pagination or a `limit` to keep handlers fast. * The hash check skips the client push when the new result hash matches the last one sent. It does not skip the re-run itself. A handler that reads 1,000 rows but produces a small derived value still runs the full query on every dirty event. * Reactive subscriptions require the TS function runtime (Bun process). When the runtime is not available (no `functions/` directory), the hook receives a `REACTIVE_UNAVAILABLE` error push, and the consumer can fall back to a one-shot fetch. # Realtime Source: https://docs.pylonsync.com/concepts/realtime Choose among Pylon's live queries, presence, sync, and multiplayer primitives. Pylon handles realtime everywhere in the stack. One WebSocket, opened by the same binary that serves your app, carries live data, presence, and multiplayer updates. There is no single realtime API. Pylon offers a few primitives, each suited to a different kind of live state. This page maps the options, and each primitive has its own page with full detail. ## Pick the right primitive | You want | Reach for | Persisted? | Deep dive | | ---------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------- | ------------------------------------------------------ | | A live list/row of one entity, filtered | [`db.useQuery`](/concepts/live-queries) | ✅ synced replica | [Live queries](/concepts/live-queries) | | A server-side join / aggregate / computed value, live | [`db.useReactiveQuery`](/concepts/reactive-queries) | ✅ (re-derived) | [Reactive queries](/concepts/reactive-queries) | | Live full-text + faceted search | [`db.useSearch`](/concepts/search) | ✅ | [Search](/concepts/search) | | An instant write that reconciles with the server | [`db.useMutation`](/concepts/optimistic-updates) | ✅ | [Optimistic updates](/concepts/optimistic-updates) | | Presence — cursors, typing, "who's here", broadcast | `useRoom` | ❌ ephemeral | [React client](/clients/react#presence--rooms) | | Server-generated output streamed to everyone watching (agent tokens, job progress) | `ctx.rooms.broadcast` — server side | ❌ ephemeral | [Functions](/concepts/functions#server-push-to-a-room) | | Server-generated output streamed back to the one client that called | `ctx.stream.write` — server side | ❌ ephemeral | [Functions](/concepts/functions#streaming-llm-output) | | Authoritative multiplayer sim (game / MMO tick loop) | `useShard` | ❌ in-memory sim | [React client](/clients/react#multiplayer-shards) | | A connection / sync status indicator | `useSyncStatus` | — | [React client](/clients/react) | ## Two layers: synced data and ephemeral signals The first four rows above are synced data. They flow through the entity store, respect [policies](/concepts/policies), and land in the local replica, so they survive reload and work offline. The remaining rows are ephemeral signals: `useRoom` (presence and broadcast), the two server-push primitives, and `useShard` (simulation). A cursor position or a game tick does not belong in your database. The server broadcasts it to the room's current members and then forgets it. Do not model presence as an entity, and do not drive a 60fps game loop through `db.useQuery`. ## Streaming server output: one client, or all of them Both server-push rows above run inside a mutation or action (neither exists on query ctx). They differ in who receives the output: * **`ctx.stream.write(text)`** writes to the HTTP response of the call in flight. It reaches exactly one client: whoever made the request, using `db.streamFn(fn, args)` to read it back. If you close the tab, the output is gone. * **`ctx.rooms.broadcast(room, topic, data)`** pushes over the WebSocket to every subscriber of a presence room, whether or not they made the call. It resolves `{ delivered: false }` when the room has no members. An empty room causes no error. Use `ctx.stream.write` for a chat box waiting on its own answer. Use `ctx.rooms.broadcast` when the output has to reach more than the caller: * an agent whose tokens must survive a reload * a second device following the same session * a scheduled job that has no HTTP caller at all Handlers commonly do both: write to the caller for the lowest-latency path, and broadcast so every other watcher stays in sync. Clients join with `useRoom(roomId, userId)` and read pushed messages off the sync engine: ```tsx theme={null} import { useRoom, getSync } from "@pylonsync/react"; useRoom("chat-42", userId); // joins the room — required to receive pushes useEffect(() => { return getSync().subscribeRoomMessages("chat-42", (m) => { if (m.topic === "agent.delta") append((m.payload as { text: string }).text); }); }, []); ``` See [Functions → Server push to a room](/concepts/functions#server-push-to-a-room). ## Best practice: cross-tab live state goes through `db.useQuery` The most common realtime feature is a shared scalar that must update everywhere at once: a live counter, remaining capacity, or "12 people viewing." Build it with a public, PII-free projection entity subscribed with `db.useQuery`, not with a reactive query. Reactive server queries (`db.useReactiveQuery`) behave as leader-tab-only in practice: a follower tab's reactive subscription may never deliver its initial result. Entity sync (`db.useQuery`) reaches every tab. So: 1. Keep the sensitive table deny-all: `allowRead: "false"`. 2. Have the mutation also maintain a tiny projection entity with `allowRead: "true"` and client writes denied. 3. Subscribe the UI to the projection with `db.useQuery`. ```ts theme={null} // app.ts — the sensitive table stays private; the counter is public const Signup = entity("Signup", { email: field.string(), createdAt: field.datetime() }); const WaitlistStat = entity("WaitlistStat", { count: field.int() }); policy({ name: "signup_private", entity: "Signup", allowInsert: "true", allowRead: "false" }); policy({ name: "stat_public", entity: "WaitlistStat", allowRead: "true" }); // writes server-only ``` ```ts theme={null} // functions/joinWaitlist.ts — one mutation keeps both in sync (transactional) export default mutation({ args: { email: v.string() }, auth: "public", async handler(ctx, args) { await ctx.db.insert("Signup", { email: args.email, createdAt: new Date().toISOString() }); const stat = (await ctx.db.query("WaitlistStat"))[0]; if (stat) await ctx.db.update("WaitlistStat", stat.id, { count: stat.count + 1 }); else await ctx.db.insert("WaitlistStat", { count: 1 }); }, }); ``` ```tsx theme={null} // Any number of tabs see the count tick up live const { data: stat } = db.useQuery("WaitlistStat"); return <span>{stat[0]?.count ?? 0} signed up</span>; ``` Use `db.useReactiveQuery` for its strength: a server-side join or rollup rendered in a single leader view, like a feed that joins `Post` to `User`. Do not rely on it for cross-tab fan-out of a shared value. ## How it works Internally, each subscription rides one WebSocket per client. The server keeps an index of active subscriptions keyed by entity and indexed filter fields, so a write fans out in O(matching subscriptions), not O(all subscriptions). On multi-machine deployments, a cluster bus forwards change events between nodes. A mutation on machine A updates subscribers on machine B. See [horizontal scaling](/operations/scaling) for the architecture. ## Next <CardGroup> <Card title="Live queries" icon="radio" href="/concepts/live-queries"> The `db.useQuery` hook and how fan-out scales. </Card> <Card title="React client" icon="react" href="/clients/react"> Every realtime hook the client exposes, including presence and shards. </Card> </CardGroup> # Scheduling Source: https://docs.pylonsync.com/concepts/scheduling Run recurring cron jobs and one-shot deferred functions without extra infrastructure. Pylon runs background work with the same functions you already write. It needs no separate worker process, queue service, or cron daemon. There are two shapes: recurring (cron) and one-shot (deferred). ## Recurring: `cron(...)` Declare a cron in your manifest. It fires a function every time the schedule matches (the scheduler checks once a minute). ```ts app.ts theme={null} import { buildManifest, cron, discoverAppRoutes } from "@pylonsync/sdk"; export default buildManifest({ name: "myapp", version: "0.1.0", entities: [/* ... */], routes: await discoverAppRoutes(), crons: [ cron("0 * * * *", "hourlyRollup"), // every hour, on the hour cron("*/5 * * * *", "pollInbox"), // every 5 minutes cron("0 9 * * 1", "weeklyDigest"), // Mondays at 09:00 ], }); ``` The second argument is the name of a function in `functions/`: a normal `query`, `mutation`, or `action`: ```ts functions/hourlyRollup.ts theme={null} import { mutation } from "@pylonsync/functions"; export default mutation({ internal: true, // not reachable over HTTP — only the scheduler runs it args: {}, async handler(ctx) { // Server-side ctx.db.* is trusted — write your entities directly. const events = await ctx.db.query("Event"); await ctx.db.insert("HourlyRollup", { count: events.length, at: new Date().toISOString() }); }, }); ``` <Note> **Make cron functions `internal: true`.** A cron function is also a regular function, so without `internal: true`, it is reachable at `/api/fn/<name>`. Marking it internal means only the scheduler (and `ctx.runMutation`) can invoke it. </Note> **Auth.** A cron has no caller, so it runs with anonymous auth, the same as Pylon's own built-in maintenance jobs. You usually do not need to do anything about that. A function's own `ctx.db.*` calls run server-side and are not subject to entity policies (those gate client sync, not trusted server code), so a maintenance cron reads and writes its entities directly. Elevate only in two cases, and always with an audit `reason`: ```ts theme={null} // 1. The cron chains an internal:true function via the scheduler, or // 2. You run with PYLON_STRICT_FN_POLICIES=1 (function db ops policy-checked). ctx.auth.elevate({ admin: true, reason: "nightly rollup" }); await ctx.scheduler.runAfter(0, "rebuildSearchIndex", {}); ``` `reason` is mandatory. Every elevation is logged, so an operator can audit who elevated and why. Pylon never grants admin to a cron implicitly. **Schedule format.** Standard 5-field cron: `minute hour day-of-month month day-of-week`. A schedule that does not parse, or a `function` name that is not registered, is logged loudly at boot and skipped. A typo fails visibly in `pylon dev` instead of silently never running. **Durability.** Cron jobs are backed by a persistent job store. A fire that is in flight survives a restart, and the schedule resumes after a redeploy. Postgres stores the queue in the application database. SQLite stores it in a local sidecar database. <Warning> **Execution is at least once.** A worker can finish an external side effect and stop before it records completion. Another worker can then run the job again. Make every scheduled function idempotent. </Warning> On Postgres, all replicas use one shared queue. Workers use row locks and short leases to claim jobs. Another replica can recover a job after its lease expires. One elected cron scheduler adds each matching cron to the shared queue once per tick. During a rolling release, each replica claims only functions that exist in that release. SQLite jobs stay on one machine. Do not run one SQLite database on multiple replicas. ## One-shot: `ctx.scheduler` To run something later (not on a repeating schedule), schedule it from inside a mutation or action: ```ts theme={null} // run a function after a delay await ctx.scheduler.runAfter(60_000, "sendReminder", { todoId }); // 60s later // run at a specific time await ctx.scheduler.runAt(dueDate.getTime(), "expireHold", { holdId }); // cancel a pending run await ctx.scheduler.cancel(scheduleId); ``` The scheduled function runs with the auth identity of the caller that scheduled it. Pylon enforces this identity at schedule time. The `args` you pass are delivered verbatim. Like crons, these scheduled calls are durable across restarts. When a Postgres mutation calls `runAfter` or `runAt`, Pylon commits the scheduled job in the same database transaction as the mutation writes. A rollback removes both the writes and the job. Scheduling from an action cannot share a transaction with its external work. <Tip> Before `cron(...)` existed, the common pattern for recurring work was a self-perpetuating function: one that re-armed itself with `ctx.scheduler.runAfter(...)` at the end. That still works, but `cron(...)` is clearer and cannot silently stop if a re-arm is missed. </Tip> # Search Source: https://docs.pylonsync.com/concepts/search Configure native, in-process faceted full-text search without a separate index server. Pylon ships a built-in, faceted full-text search layer. Add a `search:` block to an entity to get BM25 ranking, live facet counts, and sort across millions of rows, with no separate search server such as Meilisearch or Elasticsearch. The index lives inside the same SQLite or Postgres database, in the same transaction as your writes. ## Declaring a searchable entity ```ts theme={null} import { entity, field } from "@pylonsync/sdk"; const Product = entity( "Product", { name: field.string(), description: field.richtext(), brand: field.string(), category: field.string(), color: field.string(), price: field.float(), rating: field.float(), stock: field.int(), createdAt: field.datetime(), }, { search: { // BM25 match across these columns. Order is the weighting hint. text: ["name", "description"], // Facet counts maintained for these columns. facets: ["brand", "category", "color"], // Allowed sort keys. Anything outside this list is rejected. sortable: ["price", "rating", "createdAt"], }, }, ); ``` On schema push, Pylon creates an FTS5 shadow table and a roaring-bitmap facet index. Every insert, update, and delete maintains both inside the same transaction as the row write, so the index does not lag behind the row. ## Searching from the client `db.useSearch` is the React hook. It returns ranked hits, per-facet counts, a total, and timing. ```tsx theme={null} import { db } from "@pylonsync/react"; function Catalog() { const [query, setQuery] = useState(""); const [filters, setFilters] = useState<Record<string, string>>({}); const [page, setPage] = useState(0); const { hits, facetCounts, total, tookMs, loading } = db.useSearch<Product>("Product", { query, filters, facets: ["brand", "category", "color"], sort: ["price", "asc"], page, pageSize: 24, }); // facetCounts is { brand: { Atlas: 1000, Orbit: 800, … }, … } // Counts reflect the live result set after applying every filter // *except* the one being counted — Algolia / Meilisearch semantics. } ``` The hook re-runs automatically when matching rows in the entity change, so facet counts and result lists stay synchronized with writes. It needs no manual invalidation and no refetch button. ## Searching from a server function ```ts theme={null} import { mutation } from "@pylonsync/functions"; export default mutation({ async handler(ctx, args) { const result = await ctx.db.search("Product", { query: args.q, filters: { brand: "Atlas" }, facets: ["category", "color"], page: 0, pageSize: 50, }); return result; }, }); ``` The same query shape works server-side via `ctx.db.search`. Use it when you need to search inside a transaction or pre-aggregate before returning. ## How it works * **Text matching**: SQLite FTS5 or Postgres `tsvector`, with BM25 ranking by default, configurable per field. * **Facets**: roaring bitmaps stored in a `_facet_bitmap` table, one bitmap per `(entity, column, value)`. Intersection across active filters is bit-AND, which is faster than `WHERE` clauses by 10–100× at scale. * **Sort and paginate**: when the planner needs to sort across the entire match set, it materializes hit ids into a temporary table, joins back to the row table, and applies `ORDER BY` together with `LIMIT` and `OFFSET`. This is the only way to paginate consistently across a sorted projection. * **Aggregation safety**: faceted search refuses to run on entities whose read policy depends on per-row data. Otherwise, facet counts would leak the existence of rows the caller cannot read. To opt in, make your read policy row-independent (for example, `auth.userId != null`), or scope reads with a server function. ## API `POST /api/search/:entity` accepts: ```json theme={null} { "query": "red sneakers", "filters": { "category": "shoes", "brand": "Atlas" }, "facets": ["brand", "color", "size"], "sort": ["price", "asc"], "page": 0, "pageSize": 24 } ``` Returns: ```json theme={null} { "hits": [/* matching rows, fully populated */], "total": 142, "facetCounts": { "brand": { "Atlas": 12, "Orbit": 8 }, "color": { "red": 142 } }, "tookMs": 3 } ``` ## Performance Native search trades the operational cost of a separate index server for slightly higher per-query latency on very large datasets (over 10 million rows). For many B2B SaaS workloads, it is faster overall: no network hop, no asynchronous indexing lag, and no second system to monitor. The `examples/store` example runs a 10,000-product catalog with 3-facet search at sub-5ms p95 on a \$5 VPS. ## Next <CardGroup> <Card title="Faceted search example" icon="store" href="https://github.com/pylonsync/pylon/tree/main/examples/store"> Walk through the store with code highlights. </Card> <Card title="Live queries" icon="bolt" href="/concepts/live-queries"> How search results stay up to date. </Card> </CardGroup> # Resumable Streams Source: https://docs.pylonsync.com/concepts/streaming Every fn stream is buffered and sequence-numbered on the server. A dropped connection reconnects and misses nothing, including the final result. `ctx.stream.write` streams progressive output (agent tokens, build logs, progress ticks) to the client that called the function. Every stream in Pylon is resumable. The server buffers each frame under a stream id with a steadily increasing sequence number, so a closed laptop, an unreliable mobile network, or a proxy idle timeout costs nothing. The client reconnects at its last cursor and catches up. The handler never notices the gap. It keeps writing whether or not anyone is connected. ## Server side: nothing changes ```ts theme={null} export default action({ args: { question: v.string() }, timeout: 300, handler: async (ctx, { question }) => { const res = await ctx.llm.stream( { messages: [{ role: "user", content: question }] }, (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); }, ); return { usage: res.usage }; }, }); ``` You use the same `ctx.stream` as before. Buffering, sequencing, and resume all happen on the server. `ctx.stream.writeEvent(event, data)` emits a typed SSE frame (`event: <name>` on the wire). ## Client side: resume is automatic ```ts theme={null} import { streamFn } from "@pylonsync/react"; for await (const chunk of streamFn("ask", { question })) { append(chunk); } ``` `streamFn` tracks the stream's `id:` cursor as frames arrive. If the connection drops before the terminal frame, it reconnects on its own to `GET /api/fn-streams/<id>?since=<cursor>` and keeps yielding from exactly where it left off, with no duplicate chunks and no gap. The generator still returns the function's final result, even when the reconnect happens after the handler has already finished. Pass `resume: false` to opt out. ## Surviving a page reload Auto-resume covers network failures within one page's lifetime. To survive a full reload, or to watch the same run from another device, persist the stream id: ```ts theme={null} const gen = streamFn("ask", { question }, { onStreamId: (id) => db.update("Run", runId, { streamId: id }), }); ``` Later, from anywhere: ```ts theme={null} import { resumeStream } from "@pylonsync/react"; for await (const chunk of resumeStream(run.streamId)) { append(chunk); // full replay from the start, then live-tail } ``` Swift mirrors both: `client.streamFn(name, args:, onStreamId:, onResult:)` auto-resumes, and `client.resumeStream(id, since:)` attaches from anywhere. ## The wire contract `POST /api/fn/<name>` with `Accept: text/event-stream` upgrades to SSE when the handler first streams (a handler that returns without streaming still answers with plain JSON, unchanged). The SSE response carries: * `X-Pylon-Stream-Id`: the stream id, sent on the initial response and every resume. * `id: <seq>` on every frame: the resume cursor. The native `EventSource` API sends it back automatically as `Last-Event-ID`. * data frames (multi-line payloads split across `data:` lines per the SSE spec), typed frames from `writeEvent`, and a terminal `event: result` or `event: error` frame. Resume connections also carry a `retry:` hint and a heartbeat comment every 15 seconds. The initial connection stays byte-compatible with client parsers older than 0.4.22. `GET /api/fn-streams/<id>` accepts `Last-Event-ID` or `?since=<seq>`, replays everything after the cursor, then continues streaming new frames as they arrive. Errors: `404` for an unknown or expired id, and `410 STREAM_GONE` (with `oldestSeq`) when the cursor falls below the buffer's retention window. Access control: a stream started by a signed-in user is readable by that user only, plus admins. Tenant-scoped calls require the same active tenant. Streams from public functions are guarded by the id alone: 160 bits from the system CSPRNG, not from the time-ordered row-id generator. ## Scope and settings Buffers are in-memory and bounded. Resume survives any transport failure, but not a server restart. The producing handler would not survive a restart either. Restart-durable execution is what [workflows](/concepts/workflows) are for. Completed streams stay resumable for an hour, then a sweeper reclaims them. | Env | Default | Meaning | | ------------------------------- | ------- | ----------------------------------------------------------------------------------- | | `PYLON_STREAM_BUFFER_MAX_BYTES` | 4 MB | Per-stream buffer, oldest frames evict first (a resume below the window gets `410`) | | `PYLON_STREAM_RETAIN_SECS` | 3600 | How long completed streams stay resumable | | `PYLON_STREAM_MAX` | 2000 | Concurrently buffered streams | ## Streams versus rooms `ctx.rooms.broadcast` sends output to every currently connected subscriber of a presence room: the second tab, the second device, live. It does not replay. A subscriber that reconnects misses whatever was sent during the gap. Use rooms for live fan-out, and the stream id for anything that must survive a disconnect. Doing both in one handler is normal. # Vector Search Source: https://docs.pylonsync.com/concepts/vector-search Embeddings as a field type and exact k-NN as a query, using field.vector(dims), ctx.llm.embed, and ctx.db.vectorSearch. Pylon ships vector search as three pieces that compose: 1. **`field.vector(dims)`**: an embedding column on an entity. 2. **`ctx.llm.embed(texts)`**: batch embeddings from OpenAI or Voyage. 3. **`ctx.db.vectorSearch(entity, query)`**: exact k-nearest-neighbor over the stored vectors. Pylon needs no extension, no sidecar, and no separate vector database. The embedding is a regular column: a packed float array stored as BLOB on SQLite and BYTEA on Postgres. The search itself is an exact scan, scored in Rust. ## Declare the field ```ts theme={null} import { entity, field } from "@pylonsync/sdk"; export const Doc = entity("Doc", { title: field.string(), body: field.string(), status: field.string(), embedding: field.vector(1536).optional(), }); ``` `field.vector(dims)` fields are always server-only. A 1536-dimension embedding is over 6KB per row, so it never travels over the sync stream, never appears in HTTP entity reads, and is stripped from search hits. Server functions read it via `ctx.db.get`. Writes validate the shape: the value must be a number array of exactly `dims` finite elements (or `null` to clear an optional field). Anything else fails with `VECTOR_INVALID`. ## Write embeddings `ctx.llm.embed` is available in mutations and actions. A mutation can embed and store in one transactional scope. The provider call holds the write transaction open for its duration, so for high-write apps, prefer the action shape: embed outside the transaction, then store with a small mutation: ```ts theme={null} // functions/indexDoc.ts — action: embed (external I/O), store via a mutation import { action, v } from "@pylonsync/functions"; export default action({ args: { docId: v.string() }, handler: async (ctx, { docId }) => { const doc = await ctx.runQuery("getDoc", { docId }); if (!doc) return { ok: false }; const [embedding] = await ctx.llm.embed([`${doc.title}\n\n${doc.body}`]); await ctx.runMutation("saveEmbedding", { docId, embedding }); return { ok: true }; }, }); // functions/saveEmbedding.ts — mutation: the transactional write import { mutation, v } from "@pylonsync/functions"; export default mutation({ args: { docId: v.string(), embedding: v.array(v.number()) }, handler: (ctx, { docId, embedding }) => ctx.db.update("Doc", docId, { embedding }), }); ``` (Actions have no `ctx.db`. Reads and writes from an action go through `ctx.runQuery` or `ctx.runMutation`.) `ctx.llm.embed` resolves its provider independently of chat: | Env | Effect | | ----------------------------------------------------- | ----------------------------------------------------------------------------------- | | `OPENAI_API_KEY` | Default: OpenAI `text-embedding-3-small` (1536 dims), even when chat runs Anthropic | | `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY` | Voyage `voyage-3.5` (1024 dims) | | `PYLON_EMBEDDINGS_MODEL` | Model override | | `PYLON_EMBEDDINGS_API_KEY` | Key override (either provider) | | `PYLON_EMBEDDINGS_BASE_URL` | OpenAI-compatible endpoint override | Match `field.vector(dims)` to the model: 1536 for `text-embedding-3-small`, 3072 for `text-embedding-3-large`, 1024 for `voyage-3.5`. `embed` is not available in queries. A reactive query re-runs on every dependency change and would re-bill the provider each time. Embed in a mutation or action, then store the vector. ## Search `ctx.db.vectorSearch` lives on `ctx.db`, available from queries and mutations. The typical flow embeds the search text in an action, then searches from a query: ```ts theme={null} // functions/searchDocs.ts — action: embed the text, delegate the search import { action, v } from "@pylonsync/functions"; export default action({ args: { q: v.string() }, handler: async (ctx, { q }) => { const [vector] = await ctx.llm.embed([q]); return ctx.runQuery("findSimilar", { vector }); }, }); // functions/findSimilar.ts — query: read-only vector search import { query, v } from "@pylonsync/functions"; export default query({ args: { vector: v.array(v.number()) }, handler: async (ctx, { vector }) => { const { hits } = await ctx.db.vectorSearch("Doc", { field: "embedding", vector, limit: 5, filter: { status: "published" }, }); return hits.map((h) => ({ id: h.id, score: h.score, title: h.doc.title })); }, }); ``` The query shape: * `field`: which `vector(dims)` field to search (an entity may have several). * `vector`: the query embedding. Its length must match the declared dims. * `limit`: the maximum number of hits, default 10, capped at 200. * `metric`: `"cosine"` (default, higher means closer), `"dot"`, or `"l2"` (Euclidean distance, lower means closer). * `filter`: an equality pre-filter applied in SQL before scoring. A plain value means equality, an array means `IN`, and `null` means `IS NULL`. Hits come back best-first as `{ id, score, doc }`. `doc` is the full row with vector fields stripped. Re-fetch by id if you need the embedding itself. The same query works over HTTP: `POST /api/vector-search/Doc` with the query as the JSON body. The route enforces read policies. Entities whose read policy depends on row data are refused (`SEARCH_REQUIRES_ROW_INDEPENDENT_POLICY`), because top-k ranking over every row would leak the proximity of rows the caller cannot read. This is the same rule faceted search aggregates follow. ## Scaling limits The scan is an exact k-NN search, not an approximate index. Every non-NULL embedding is decoded and scored on each search. That is the right trade-off until your table grows large: exact search gives perfect recall, needs no index maintenance, and adds no extra infrastructure. Rough guidance at 1536 dimensions: 10,000 rows score in a few milliseconds, and 100,000 rows score in the low hundreds of milliseconds. Past that point, use `filter` to shrink the candidate set (by tenant, status, or collection), or move that entity's retrieval to a dedicated vector store. An approximate nearest-neighbor (ANN) index can land behind this same API later, without changing your code. ## RAG in one action ```ts theme={null} export default action({ args: { question: v.string() }, handler: async (ctx, { question }) => { const [vector] = await ctx.llm.embed([question]); // findSimilar is the query from the Search section above. const hits = await ctx.runQuery("findSimilar", { vector }); const context = hits.map((h) => h.body).join("\n---\n"); const res = await ctx.llm.complete({ messages: [{ role: "user", content: question }], system: `Answer from this context only:\n${context}`, }); return { answer: res.content, sources: hits.map((h) => h.id) }; }, }); ``` # Durable Workflows Source: https://docs.pylonsync.com/concepts/workflows Multi-step processes that survive restarts: completed results replay, sleeps span days, and events resume a paused run. A workflow is a TypeScript function that composes `step`, `sleep`, and `waitForEvent` calls. The engine persists every step result, so a workflow survives server restarts and deploys. Completed steps never re-run. A 7-day `sleep` costs nothing while it waits. An external event wakes a paused run exactly where it stopped. Use a workflow when a process spans more time than one function call should hold: * onboarding sequences * agent pipelines with human approval gates * billing dunning * report generation with retries ## Writing a workflow Workflows live in a `workflows/` directory next to `functions/`. One file, one default-exported `workflow(...)`: ```ts theme={null} // workflows/onboarding.ts import { workflow } from "@pylonsync/functions"; export default workflow("onboarding", async (wf, ctx) => { // A completed step returns its recorded output during replay. const user = await wf.step("load-user", () => ctx.runQuery("getUser", { id: wf.input.userId }), ); await wf.step("send-welcome", () => ctx.email.send({ to: user.email, subject: "Welcome!", text: "Glad you're here.", }), ); // Pauses the workflow. No machine time is spent waiting. await wf.sleep("24h"); // Pauses until POST /api/workflows/<id>/event delivers this event. const confirmation = await wf.waitForEvent("email_confirmed"); return { done: true, confirmation }; }); ``` Each step's closure runs with a full action `ctx`: `ctx.runQuery`, `ctx.runMutation`, `ctx.llm`, `ctx.email`, and `ctx.scheduler`. It runs under the same idle timeout and cancellation semantics as any action. ## Starting and driving Start a workflow from any mutation or action. `ctx.workflows.start` returns the instance id immediately, and the engine's background driver takes over from there: ```ts theme={null} // functions/onSignup.ts export default mutation({ async handler(ctx, args) { const user = await ctx.db.insert("User", args); const { id } = await ctx.workflows.start("onboarding", { userId: user.id, }); return { userId: user.id, onboardingWorkflow: id }; }, }); ``` Deliver an event to a run paused on `waitForEvent` the same way, for example from the webhook action that received the confirmation: ```ts theme={null} await ctx.workflows.sendEvent(workflowId, "email_confirmed", { ok: true }); ``` Steps execute through the background job queue, and sleeps wake on schedule. SQLite stores state in `<app-db>.workflows.db`. Postgres stores state in shared application tables. The admin API drives the same engine for operators: `POST /api/workflows/start`, `POST /api/workflows/<id>/event` (both with `Authorization: Bearer $PYLON_ADMIN_TOKEN`). Inspect runs at `GET /api/workflows` or `GET /api/workflows/<id>` (step-by-step results, timings, retry counts), or cancel a run with `POST /api/workflows/<id>/cancel`. ## The determinism contract On every advance, the whole workflow function re-runs from the top. Completed steps replay from their recorded outputs. This gives you plain TypeScript control flow (branches, loops, early returns), with one rule: **The sequence of `step`, `sleep`, or `waitForEvent` calls must be identical on every replay, for the same input and step outputs.** * Branch on `wf.input` and on step outputs freely. Both are stable. * Never branch on wall-clock time, randomness, or external state read outside a step. Put those reads inside a step, then branch on its recorded output. * Step names must be unique within a run. The replay cache is name-keyed, so a mismatch fails the run loudly rather than reusing the wrong output. ## Retries and failure A throwing step fails the current advance. The engine retries the same step (default 3 attempts, configurable per workflow): ```ts theme={null} export default workflow("sync-crm", handler, { maxRetries: 5 }); ``` Once retries are exhausted, the run lands in `failed` with the error and the step that caused it, inspectable via the API. Code between steps should be side-effect free. Workflow step execution is at least once. A step can finish an external side effect and stop before Pylon records its result. Pylon can then run that step again. Make step bodies idempotent when they call an external system. ## Multiple replicas Postgres replicas share workflow state. A replica takes a short lease before it advances a run. It renews the lease while the handler runs. A lease token prevents a stale worker from replacing newer state. Another replica resumes the run after an expired lease. SQLite workflow state is local to one machine. Use Postgres for horizontal scaling. # Installation Source: https://docs.pylonsync.com/installation Install the Pylon CLI and runtime. ## Requirements * **Bun** ≥ 1.0 is a runtime dependency. The `pylon` server binary spawns Bun as a child process to run TypeScript functions and render React SSR. Every install path below except Pylon Cloud needs Bun on the host. * **Rust:** a recent stable toolchain (edition 2021). You need it only if you build the CLI from source. The prebuilt binary and Docker image need no Rust. * **Node.js** ≥ 18 (optional, for client tooling) ## Fastest start: scaffold an app The quickest path to a running full-stack app is the project scaffolder. It needs no global install, because it adds `@pylonsync/cli` as a dev dependency: ```bash theme={null} npm create @pylonsync/pylon@latest my-app cd my-app npm run dev ``` This scaffolds a full-stack SSR app (schema, policies, functions, and file-based React routes under `app/`). `npm run dev` runs the bundled CLI. Install the standalone CLI below if you want `pylon` on your `PATH` for other projects or CI. ## Skip the install: use Pylon Cloud If you do not want to manage a binary or a server, [Pylon Cloud](/cloud) hosts the same backend you would run yourself. Sign up at [www.pylonsync.com/dashboard](https://www.pylonsync.com/dashboard), then: ```bash theme={null} pylon login pylon deploy --target cloud ``` You will still want the CLI for local development and deploys. Install it below. ## Install the CLI <CodeGroup> ```bash one-line installer (recommended) theme={null} curl -fsSL https://www.pylonsync.com/install.sh | bash ``` ```bash Docker theme={null} docker pull ghcr.io/pylonsync/pylon:latest ``` ```bash Cargo (compiles from source) theme={null} cargo install --git https://github.com/pylonsync/pylon pylon-cli ``` </CodeGroup> The one-line installer downloads a prebuilt binary to `~/.local/bin`. It supports Linux and macOS, on x86\_64 and arm64. It needs no Rust toolchain. Verify: ```bash theme={null} pylon --version pylon doctor ``` `pylon doctor` checks that bun, node, and the supporting tooling are available. ## Install Bun Pylon uses Bun to run TypeScript server functions and to bundle the client code generator. If you do not have it: ```bash theme={null} curl -fsSL https://bun.sh/install | bash ``` ## Client SDKs ### Web (React, Next.js, Vite, vanilla JS) <CodeGroup> ```bash bun theme={null} bun add @pylonsync/sdk @pylonsync/react ``` ```bash npm theme={null} npm install @pylonsync/sdk @pylonsync/react ``` ```bash pnpm theme={null} pnpm add @pylonsync/sdk @pylonsync/react ``` </CodeGroup> For Next.js, also add `@pylonsync/next`. See [Clients → React](/clients/react) and [Clients → Next.js](/clients/next). ### React Native (iOS + Android) ```bash theme={null} bun add @pylonsync/sdk @pylonsync/react @pylonsync/react-native bun add @react-native-async-storage/async-storage expo-sqlite ``` See [Clients → React Native](/clients/react-native). ### Swift (iOS, macOS, tvOS, watchOS, Linux) In your `Package.swift`: ```swift theme={null} .package(url: "https://github.com/pylonsync/pylon-swift.git", from: "0.3.0"), ``` Then per target: ```swift theme={null} .target(name: "MyApp", dependencies: [ .product(name: "PylonClient", package: "pylon-swift"), .product(name: "PylonSync", package: "pylon-swift"), .product(name: "PylonSwiftUI", package: "pylon-swift"), // optional ]) ``` Linux: `apt-get install libsqlite3-dev` for the SQLite-backed offline replica. See [Clients → Swift](/clients/swift). ## Directory conventions A Pylon app usually looks like this: ``` my-app/ app.ts # schema + policies + manifest functions/ # server functions (*.ts) client/ # your React components web/ # Vite app (or Next.js, etc.) package.json vite.config.ts src/main.tsx package.json pylon.manifest.json # generated pylon.client.ts # generated ``` `pylon dev app.ts` watches `app.ts` and `functions/`, and regenerates `pylon.manifest.json` and `pylon.client.ts` on every change. ## Environment variables | Variable | Default | Purpose | | ----------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PYLON_DB_PATH` | `.pylon/dev.db` | SQLite file location (ignored if `DATABASE_URL` is set) | | `DATABASE_URL` | *(unset)* | `postgres://...` connection string. When set, Pylon uses Postgres for application data, auth state, jobs, and workflows. | | `PYLON_SESSION_DB` | next to the app database | SQLite auth-state sidecar. Postgres ignores this setting. | | `PYLON_JOBS_DB` | next to the app database | SQLite job-queue sidecar. Postgres ignores this setting. | | `PYLON_WORKFLOWS_DB` | next to the app database | SQLite workflow sidecar. Postgres ignores this setting. | | `PYLON_JOB_WORKERS` | `2` | Number of local job workers. Shared Postgres claims prevent duplicate concurrent ownership. | | `PYLON_CLUSTER_BUS` | *(unset)* | Redis URL for realtime fanout between self-hosted replicas. Pylon Cloud supplies a managed relay. | | `PYLON_CLUSTER_REQUIRED` | `false` | Refuse to start without the shared Postgres runtime stores and a cluster bus. | | `PYLON_FILES_DIR` | `.pylon/uploads` | File upload storage | | `PYLON_CORS_ORIGIN` | from `manifest.auth.trustedOrigins` | CORS allowlist override (comma-separated). `*` only in dev. Loopback always auto-trusted. | | `PYLON_DEV_MODE` | `true` | Dev-only behavior (generous rate limits, CORS `*` fallback when neither manifest nor env is set) | | `PYLON_PORT` | `4321` | HTTP port | | `PYLON_RATE_LIMIT_MAX` | `100` | Max anonymous requests per window (per IP). Kept low, because anonymous traffic is the main target for repeated login attempts. | | `PYLON_RATE_LIMIT_MAX_AUTHED` | `1000` | Max authenticated requests per window (per user id). Higher than the anonymous limit, because polling dashboards routinely exceed 100 requests/min for one legitimate user. | | `PYLON_RATE_LIMIT_WINDOW` | `60` | Window in seconds. Applies to both the anonymous and authenticated limits. | | `PYLON_FN_RATE_LIMIT_MAX` | `30` | Function calls per window | See the [CLI reference](/operations/cli) for the full list. # Introduction Source: https://docs.pylonsync.com/introduction Pylon is an agent-native full-stack framework with a typed backend, realtime sync, and React SSR in one server. ## What is Pylon? Pylon is a full-stack framework for coding agents. You or your agent write the schema, policies, and functions in TypeScript. Pylon runs the backend and React SSR on Bun behind one Rust server. It generates a typed client. It keeps shared state in sync for dashboards, CRMs, collaborative tools, and chat. The same server handles the database, the API, auth, and WebSocket connections. It runs on SQLite or Postgres. You deploy it with one command. <CardGroup> <Card title="Quickstart" icon="bolt" href="/quickstart"> Build a live app in five minutes. </Card> <Card title="Core concepts" icon="book" href="/concepts/entities"> Entities, policies, functions, live queries. </Card> <Card title="SSR" icon="window" href="/ssr/overview"> File-based routes, instant navigation, and image optimization without Next.js. </Card> <Card title="Deploy" icon="rocket" href="/operations/deploy"> Self-host, Pylon Cloud, or Vercel + Pylon Cloud. </Card> </CardGroup> ## What you get * **Typed schema:** declare entities in TypeScript and get types everywhere. * **Row-level policies:** keep access rules next to your data, not in middleware. * **Server functions:** call queries, mutations, and actions from the client over RPC. * **Live queries:** `db.useQuery(...)` subscribes to results and updates them after a write. * **Auth:** email magic links, OAuth, and guest sessions, with no third-party SDK. * **SSR:** file-based React routes with [`<Link>`](/ssr/link), [`<Image>`](/ssr/image), and built-in [Tailwind](/ssr/styling), with no Next.js. * **One server:** a Rust binary that runs your TypeScript and SSR on Bun, on one port. ## One backend runtime Most realtime backends are three systems: a database, an API server, and a realtime pub/sub layer. You keep them in sync yourself. Pylon puts those jobs in one runtime. Writes go through the same process that serves reads and pushes updates. A live query re-executes when a dependent row changes. It streams the diff to each subscriber, with no separate cache-invalidation or fan-out layer. ## What it's good for * SaaS apps with live dashboards * internal tools, CRMs, and ERPs * collaborative editors * multiplayer games and worlds * any case where two browsers show the same thing at the same time ## Storage Pylon runs on SQLite or Postgres. SQLite is the no-setup default for local development and single-node production up to tens of gigabytes. Use Postgres for horizontal scale, existing Postgres infrastructure, or shared-nothing deployments. The schema, policies, functions, and client APIs stay the same. Set `DATABASE_URL=postgres://...` to use the Postgres adapter. ## How to read these docs Start with the [Quickstart](/quickstart). It shows the shape of a Pylon app in five minutes. Then use these sections: * [Core concepts](/concepts/entities): entities, policies, functions, and live queries. * [SSR](/ssr/overview): file-based routing, `<Link>`, `<Image>`, and the full-stack story. * [Deploy](/operations/deploy): self-hosting. * [Deploying to Vercel](/operations/vercel): the Next.js + Pylon Cloud production stack. * [Examples on GitHub](https://github.com/pylonsync/pylon/tree/main/examples): full apps you can clone (CRM, ERP, chat, 3D world, SSR demos, and more). # Migrating from Convex Source: https://docs.pylonsync.com/migrate/convex Move a Convex app to Pylon: a near-identical typed schema and function model, plus the big change — declarative policies replace per-function auth checks. Convex is the closest model to Pylon of any backend here. Both give you a typed schema, server functions (`query` / `mutation` / `action`), and reactive queries. Most of your code moves with small edits. The one real change is access control. Convex has no policy layer: you write the auth check by hand inside every function. Pylon has declarative policies with default-deny. Moving to Pylon means writing each rule once per entity instead of in every function, and an entity with no policy is blocked rather than open. ## Why Pylon fits * `defineTable` with `v` validators maps almost one-to-one to `entity()` with `field`. * `query` and `mutation` handlers with `ctx.db` map to Pylon `query` and `mutation` handlers with `ctx.db`. * `useQuery` reactive hooks map to `db.useQuery`. * Scheduling, actions, and file storage all have direct equivalents. What Pylon adds or changes: * **Declarative policies.** In Convex, an unguarded function is a potential IDOR: it is open unless its author remembered the check. In Pylon you write a `policy()` per entity, it is enforced on every read and write, and the default is deny. This is the main reason the move is worth it. * Server-side rendering: file-based routes, one binary serving frontend and API. * Auth built in (email/password, magic code, OAuth, guest), so you do not wire up a separate provider. ## Concept map | Concept | Convex | Pylon | | --------------- | ---------------------------------------------------------- | --------------------------------------------------- | | Schema | `defineSchema({ posts: defineTable({...}) })` | `entity("Post", {...})` + `buildManifest(...)` | | Field types | `v.string/number/boolean/object/array` | `field.string/int/float/bool/json` | | Relation | `v.id("authors")` | `field.id("Author")` | | Document id | `_id`, an opaque string | auto 40-char hex, or a custom id | | Created time | `_creationTime` (automatic) | add a `createdAt: field.datetime()` | | Index | `.index("by_author", ["authorId"])` | `indexes: [{ name, fields }]` | | Read (server) | `ctx.db.query("posts").withIndex(...).order(...).take(10)` | `ctx.db.query("Post", {...})` / `list` / `get` | | Get one | `ctx.db.get("posts", id)` | `ctx.db.get("Post", id)` | | Read (client) | `useQuery(api.posts.list, args)` | `db.useQuery("Post", {...})` | | Insert | `ctx.db.insert("posts", {...})` | `ctx.db.insert("Post", {...})` | | Update | `ctx.db.patch("posts", id, {...})` | `ctx.db.update("Post", id, {...})` | | Replace | `ctx.db.replace("posts", id, {...})` | `ctx.db.update(...)` with the full row | | Delete | `ctx.db.delete("posts", id)` | `ctx.db.delete("Post", id)` | | Write (client) | `useMutation(api.posts.create)` | `db.useMutation("create")` / `db.useEntity("Post")` | | Function types | `query` / `mutation` / `action` | `query` / `mutation` / `action` | | Access control | code in every function (`getUserIdentity` + throw) | `policy({...})` — declarative, default-deny | | Auth identity | `ctx.auth.getUserIdentity()` → `subject` | `ctx.auth.userId` | | Auth (built in) | `@convex-dev/auth` | Pylon auth (password / magic / OAuth / guest) | | Scheduling | `ctx.scheduler.runAfter/runAt`, `crons.ts` | `ctx.scheduler`, crons | | Storage | `ctx.storage.generateUploadUrl` / `getUrl` | Pylon files provider | | Export | `npx convex export` (JSONL per table) | source for the migration below | | Deploy | `convex deploy` | `pylon deploy` (cloud) or single-binary self-host | ## Migrating your schema `defineTable` becomes `entity`, and the `v` validators become `field` builders. **Convex** (`schema.ts`): ```typescript theme={null} import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ authors: defineTable({ name: v.string() }), posts: defineTable({ title: v.string(), body: v.string(), authorId: v.id("authors"), published: v.boolean(), }).index("by_author", ["authorId"]), }); ``` **Pylon** (`app.ts`): ```typescript theme={null} import { entity, field, buildManifest, discoverAppRoutes } from "@pylonsync/sdk"; const Author = entity("Author", { name: field.string() }); const Post = entity("Post", { title: field.string(), body: field.string(), authorId: field.id("Author"), published: field.bool(), createdAt: field.datetime().defaultNow(), // replaces the automatic _creationTime }, { indexes: [{ name: "by_author", fields: ["authorId"], unique: false }], }); export default buildManifest({ name: "my-app", version: "0.1.0", entities: [Author, Post], policies: [/* see below */], routes: await discoverAppRoutes(), }); ``` Notes: * `v.id("table")` becomes `field.id("Entity")`. * Convex's automatic `_creationTime` has no equivalent, so add an explicit `createdAt: field.datetime().defaultNow()`. * `v.union(v.literal("draft"), v.literal("live"))` becomes `field.enum(["draft", "live"])`. A complex `v.object`/`v.union` becomes `field.json()`. ## Access control: from per-function checks to policies This is the migration's real work, and its payoff. In Convex you check the identity and throw by hand in every function. **Convex** (in each function): ```typescript theme={null} export const getPost = query({ args: { postId: v.id("posts") }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (identity === null) throw new Error("Unauthenticated"); const post = await ctx.db.get("posts", args.postId); if (post && post.authorId !== identity.subject && !post.published) { throw new Error("Not authorized"); // repeated in every function that reads a post } return post; }, }); ``` **Pylon** — write the rule once, enforced on every read and write: ```typescript theme={null} policy({ name: "post_access", entity: "Post", allowRead: "data.published == true || auth.userId == data.authorId", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == data.authorId", allowDelete: "auth.userId == data.authorId", }); ``` With the policy in place, `db.useQuery("Post", ...)` and `ctx.db` reads return only the rows the caller may see, and writes are checked, without a per-function guard. Because Pylon is default-deny, an entity with no policy is fully blocked, so a forgotten rule fails closed instead of leaking. Keep in a function only the checks a policy cannot express: multi-step business rules, or gates on data the policy engine does not resolve. For an org-membership gate, use `ctx.requireMember(orgId, { role: [...] })` inside the function. ## Migrating reads and writes The server API is nearly the same shape. ```typescript theme={null} // Convex — server read await ctx.db.query("posts").withIndex("by_author", q => q.eq("authorId", a)).order("desc").take(10); // Pylon — server read await ctx.db.query("Post", { authorId: a, $order: { createdAt: "desc" }, $limit: 10 }); ``` ```typescript theme={null} // Convex — server write await ctx.db.insert("posts", { title, authorId, published: false }); await ctx.db.patch("posts", id, { title: "New" }); await ctx.db.delete("posts", id); // Pylon — server write await ctx.db.insert("Post", { title, authorId, published: false }); await ctx.db.update("Post", id, { title: "New" }); await ctx.db.delete("Post", id); ``` On the client, `useQuery(api.posts.list, args)` becomes `db.useQuery("Post", {...})`, and `useMutation(api.posts.create)` becomes `db.useMutation("create")` or the optimistic `db.useEntity("Post")`. ## Migrating your data **1. Export.** `npx convex export --path ./out` writes a `snapshot_<ts>.zip`. Each table is a `<table>/documents.jsonl` file, one JSON document per line, including `_id` and `_creationTime`. ```bash theme={null} npx convex export --path ./out --include-file-storage ``` **2. Convert ids.** Convex `_id` values are opaque strings. Pylon ids are 40-character lowercase hex, so you cannot reuse them directly (Pylon rejects a bad id with `INVALID_ID`). Use one of two methods: * Derive the id (simplest). Convert each `_id` to a Pylon id with a fixed function, and apply the same function to every `v.id` reference. `sha1(_id)` gives 40 lowercase hex characters. * Map the id. Generate a new Pylon id per row with `generateId()` from `@pylonsync/sync`, keep an old-to-new map, and rewrite references in a second pass. Convert `_creationTime` (a millisecond number) to your `createdAt` field with `new Date(row._creationTime).toISOString()`. **3. Import into Pylon.** Read each `documents.jsonl` line by line and send batches to the admin batch endpoint (admin token; bypasses per-entity policies). ```javascript theme={null} import { createHash } from "node:crypto"; const toPylonId = (id) => createHash("sha1").update(id).digest("hex"); for (const batch of chunk(rows, 500)) { const operations = batch.map((row) => ({ op: "insert", entity: "Post", data: { id: toPylonId(row._id), authorId: toPylonId(row.authorId), // v.id reference converted the same way title: row.title, body: row.body, published: row.published, createdAt: new Date(row._creationTime).toISOString(), }, })); await fetch(`${PYLON_URL}/api/batch`, { method: "POST", headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ operations }), }); } ``` **4. Files.** The export's `_storage/` folder holds each blob. Upload each to Pylon's file storage and rewrite the stored ids. **5. Users.** How users are stored depends on your Convex auth. With Convex Auth, users are a `users` table you export like any other. With a third-party provider, export the users from that provider. Import each into your Pylon `User` entity, keyed by email. Sessions do not transfer, so users sign in again. ## Migrating auth In a Convex function you read `ctx.auth.getUserIdentity()` and use `identity.subject`. In Pylon you read `ctx.auth.userId`. For sign-in, Pylon has the flows built in: * Email and password: `POST /api/auth/password/register` and `/password/login`. * Magic code: `POST /api/auth/magic/send` then `/magic/verify`. * OAuth: `GET /api/auth/login/:provider?callback=<url>` (Google, GitHub, Apple, Microsoft, and about 20 more). * Guest: `POST /api/auth/guest`. Store the returned token with `setSessionToken(token)`, which also re-syncs the replica for the new user. ## Scheduling, actions, and storage * `ctx.scheduler.runAfter(ms, fn, args)` and `runAt` map to Pylon's scheduler. A `crons.ts` `cronJobs()` table maps to Pylon crons. * `action({...})` for external I/O maps to a Pylon `action`, which also calls `ctx.runQuery` / `ctx.runMutation`. * `ctx.storage.generateUploadUrl()` and `getUrl(id)` map to the Pylon files provider. ## What does not map cleanly Decide on these before you commit: * Business-logic authorization. Simple ownership checks become policies. A multi-step rule that a policy cannot express stays as an explicit check inside the Pylon function. * Discriminated unions. `v.union(v.literal(...))` maps to `field.enum([...])`; a richer union maps to `field.json()` and you validate it in a function. * The automatic `_creationTime`. Pylon has no automatic creation timestamp, so add a `createdAt` field. * Opaque ids in client code. Convex `Id<"table">` values become plain Pylon string ids after the conversion above. None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite. ## Get started 1. Create a new Pylon app: `npm create @pylonsync/pylon@latest my-app`. 2. Turn each `defineTable` into an `entity`, and write a `policy()` for every entity to replace the per-function checks. 3. Run `npx convex export`, the conversion, and the `/api/batch` import against a staging app first. 4. Point the client at Pylon: `init`, the auth calls, `db.useQuery`, and `db.useEntity`. 5. Run `pylon deploy`, or self-host the single binary. # Migrating from Firebase Source: https://docs.pylonsync.com/migrate/firebase Move a Firestore app to Pylon: the concept map, schemaless-to-typed schema, security rules to policies, data export and import, and the parts that need a decision. This guide covers **Firestore** (collections and documents). Firebase's older **Realtime Database** is a different model, a single JSON tree, and needs its own mapping. If your app is on the Realtime Database, most of the auth and functions sections still apply, but the data model does not. ## Why Pylon fits Firebase and Pylon share the shape of a backend: a data store, auth, server functions, file storage, and access rules. Most of your app maps directly: * documents with fields and references map to entities with typed fields and foreign keys; * security rules map to per-entity policies, and both **deny by default**; * `onSnapshot` live listeners map to reactive queries against a local replica; * Firebase Auth maps to Pylon auth (email/password, email link, OAuth, anonymous). The one real shift: **Firestore is schemaless; Pylon is schema-first.** In Firestore a document is any shape. In Pylon you declare each entity's fields and types once, and get types everywhere. The main migration work is writing that schema for data that never had one. What Pylon adds: * Hosted server functions in TypeScript (`query` / `mutation` / `action`), each in a transaction, instead of deploying Cloud Functions. * Server-side rendering: file-based routes, one binary serving frontend and API. * Relational queries with `include`, plus aggregates and vector search. * A single Rust binary you can self-host, or one-command Pylon Cloud. ## Concept map | Concept | Firebase (Firestore) | Pylon | | --------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | | Client init | `initializeApp(...)`, `getFirestore`, `getAuth` | `init({ appName })` | | Data unit | collection + document (schemaless) | `entity("Name", {...})` (typed) + `buildManifest(...)` | | Field types | string, number, boolean, Timestamp, Map, Array, Reference, Geopoint | `field.string/int/float/bool/datetime/json` | | Relation | reference field or an id string (no enforcement) | `field.id("Target")` + `include` | | Document id | auto 20-char base62, or a custom id | auto 40-char hex, or a valid custom id | | Read one | `getDoc(doc(db, "c", "id"))` | `db.useQueryOne("E", id)` / `ctx.db.get("E", id)` | | Query | `getDocs(query(collection(db,"posts"), where(...), orderBy(...), limit(...)))` | `db.useQuery("Post", { where, orderBy, limit })` | | Live read | `onSnapshot(q, cb)` | `db.useQuery(...)` (reactive by default) | | Create | `setDoc(...)` / `addDoc(...)` | `db.useEntity("E").insert({...})` / `ctx.db.insert(...)` | | Update | `updateDoc(...)` | `.update(id, {...})` / `ctx.db.update(...)` | | Delete | `deleteDoc(...)` | `.remove(id)` / `ctx.db.delete(...)` | | Atomic multi-write | `writeBatch` / `runTransaction` | a `mutation` handler (one transaction), or `POST /api/batch` | | Server time | `serverTimestamp()` | `field.datetime().defaultNow()`, or set an ISO string | | Rules | `firestore.rules`, deny by default | `policy({...})`, deny by default | | Rule: current row | `resource.data.*` | `data.*` / `existing.*` | | Rule: incoming write | `request.resource.data.*` | `data.*` | | Rule: user id | `request.auth.uid` | `auth.userId` | | Rule: cross-doc | `get()` / `exists()` | `exists(Entity where ...)` | | Auth (email/password) | `createUser/signInWithEmailAndPassword` | `POST /api/auth/password/register` + `/password/login` | | Auth (email link) | `sendSignInLinkToEmail` / `signInWithEmailLink` | `POST /api/auth/magic/send` + `/magic/verify` | | Auth (OAuth) | `signInWithPopup(new GoogleAuthProvider())` | `GET /api/auth/login/:provider?callback=` | | Auth (anonymous) | `signInAnonymously` | `POST /api/auth/guest` | | Auth state | `onAuthStateChanged` → `user.uid` | `db.useUser()` (client), `ctx.auth.userId` (server) | | Server logic | Cloud Functions `onCall` | `query` / `mutation` / `action` functions | | Write trigger | `onDocumentWritten` | do the work in the mutation (see the gaps) | | Storage | `ref` / `uploadBytes` / `getDownloadURL` | Pylon files provider | | Deploy | `firebase deploy` | `pylon deploy` (cloud) or single-binary self-host | ## Migrating your schema Firestore has no schema, so first read your data and write one. For each collection, declare a Pylon entity with typed fields. Turn a reference (or an id string) into a `field.id("Target")` foreign key. **Firestore** — a `posts` collection, documents shaped like: ```json theme={null} { "title": "Hello", "body": "...", "authorId": "u1", "createdAt": "<Timestamp>" } ``` **Pylon** (`app.ts`): ```typescript theme={null} import { entity, field, buildManifest, discoverAppRoutes } from "@pylonsync/sdk"; const Profile = entity("Profile", { nickname: field.string(), createdAt: field.datetime(), }); const Post = entity("Post", { authorId: field.id("Profile"), // a Firestore reference / id string becomes an FK field title: field.string(), body: field.string(), createdAt: field.datetime(), }, { indexes: [{ name: "by_author", fields: ["authorId"], unique: false }], }); export default buildManifest({ name: "my-app", version: "0.1.0", entities: [Profile, Post], policies: [/* see below */], routes: await discoverAppRoutes(), }); ``` Notes: * Map Firestore field types to Pylon: string, number, boolean, and Timestamp map to `field.string/int/float/bool/datetime`. A Map (nested object) or Array maps to `field.json()`. Geopoint and Bytes have no direct type: store a Geopoint as `field.json()` (`{ lat, lng }`) and Bytes as a base64 `field.string()`. * A **subcollection** (`posts/{id}/comments`) has no direct equivalent. Flatten it to a top-level `Comment` entity with a `postId: field.id("Post")` field. * Firestore's composite-index requirements become `indexes` on the entity. ## Migrating your security rules Both systems **deny by default**, so this is a translation, not a change of posture. Write a `policy()` for every entity, because an entity with no policy is blocked. **Firestore** (`firestore.rules`): ``` match /posts/{postId} { allow read: if resource.data.published == true || (request.auth != null && request.auth.uid == resource.data.author); allow write: if request.auth != null && request.auth.uid == resource.data.author; } ``` **Pylon** (`policy(...)` in `app.ts`): ```typescript theme={null} policy({ name: "post_access", entity: "Post", allowRead: "data.published == true || auth.userId == data.author", allowInsert: "auth.userId == data.author", allowUpdate: "auth.userId == data.author", allowDelete: "auth.userId == data.author", }); ``` How to convert each rule: * `request.auth.uid` maps to `auth.userId`. `request.auth != null` maps to `auth.userId != null`. * `resource.data.*` (the current row) maps to `data.*` on reads, and to `existing.*` on update and delete. * `request.resource.data.*` (the incoming write) maps to `data.*` in the insert and update policy. * `get()` / `exists()` cross-document checks map to `exists(Entity where field == auth.userId)`. * To lock an immutable field (Firestore's `request.resource.data.author == resource.data.author`), declare the column `field.string().owner()`. It stamps `auth.userId` on insert, rejects a false owner, and locks the value on update. ## Migrating reads and writes Reads. A Firestore query becomes `db.useQuery`, which is reactive by default (no separate `onSnapshot`). ```typescript theme={null} // Firestore import { getDocs, collection, query, where, orderBy, limit } from "firebase/firestore"; const q = query( collection(db, "posts"), where("authorId", "==", authorId), orderBy("createdAt", "desc"), limit(10), ); const snap = await getDocs(q); // Pylon (live) const posts = db.useQuery<Post>("Post", { where: { authorId }, orderBy: { createdAt: "desc" }, limit: 10, }); ``` Writes. `setDoc`/`addDoc`/`updateDoc`/`deleteDoc` become the client entity hook or a server function. ```typescript theme={null} // Firestore import { collection, addDoc, doc, updateDoc, deleteDoc, serverTimestamp } from "firebase/firestore"; await addDoc(collection(db, "posts"), { title, authorId, createdAt: serverTimestamp() }); await updateDoc(doc(db, "posts", id), { title: "New" }); await deleteDoc(doc(db, "posts", id)); // Pylon — client, optimistic const posts = db.useEntity("Post"); posts.insert({ title, authorId, createdAt: new Date().toISOString() }); posts.update(id, { title: "New" }); posts.remove(id); // Pylon — server, in a transaction export default mutation({ args: { title: v.string() }, async handler(ctx, { title }) { return ctx.db.insert("Post", { title, authorId: ctx.auth.userId, createdAt: new Date().toISOString() }); }, }); ``` A `writeBatch` or `runTransaction` becomes one `mutation` handler (the whole handler is a transaction) or a `POST /api/batch` call. ## Migrating your data Use the Admin SDK to read your data out. The managed `gcloud firestore export` writes a LevelDB format meant for restore into Firestore or BigQuery, not for loading into another database, so it is the wrong tool here. **1. Export with the Admin SDK.** Firestore has no single "dump everything" call, so enumerate collections and recurse into subcollections. ```javascript theme={null} const admin = require("firebase-admin"); admin.initializeApp(); const db = admin.firestore(); const snap = await db.collection("posts").get(); const rows = snap.docs.map((d) => ({ id: d.id, ...d.data() })); // For subcollections, list them per document and recurse: // for (const d of snap.docs) { const subs = await d.ref.listCollections(); ... } ``` **2. Convert ids, references, and timestamps.** Firestore ids are 20-character base62 strings. Pylon ids are 40-character lowercase hex, so you cannot reuse Firestore ids directly (Pylon rejects a bad id with `INVALID_ID`). Use one of two methods: * Derive the id (simplest). Convert each Firestore id to a Pylon id with a fixed function, and apply the same function to every reference. `sha1(firestoreId)` gives 40 lowercase hex characters, so it fits Pylon's format and needs no lookup table. Derived ids are not time-ordered, so sort by your `createdAt` fields. * Map the id. Generate a new Pylon id per row with `generateId()` from `@pylonsync/sync`, keep an old-to-new map, and rewrite references in a second pass. Also convert a Firestore Timestamp to an ISO string, and a reference to its target id in a `field.id` column. **3. Import into Pylon.** The admin batch endpoint loads rows quickly and bypasses per-entity policies. It needs an admin token. Send batches of a few hundred rows. ```javascript theme={null} import { createHash } from "node:crypto"; // Firestore id -> valid Pylon id (40-char lowercase hex). Deterministic, so // references map the same way with no lookup table. const toPylonId = (id) => createHash("sha1").update(id).digest("hex"); for (const batch of chunk(rows, 500)) { const operations = batch.map((row) => ({ op: "insert", entity: "Post", data: { id: toPylonId(row.id), authorId: toPylonId(row.authorId), // reference -> FK field title: row.title, body: row.body, createdAt: row.createdAt.toDate().toISOString(), // Timestamp -> ISO string }, })); await fetch(`${PYLON_URL}/api/batch`, { method: "POST", headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ operations }), }); } ``` **4. Files.** Download each object from Firebase Storage and upload it to Pylon's file storage, then rewrite the stored paths and URLs. **5. Users.** Export your Firebase Auth users (`firebase auth:export users.json`) and import each into your Pylon `User` entity, keyed by email. Sessions do not transfer, so users sign in again. Password hashes do not transfer either; move users to Pylon's magic-code or OAuth sign-in, where email is the stable key. ## Migrating auth Firebase's providers each have a Pylon equivalent. ```typescript theme={null} // Firebase — email + password await createUserWithEmailAndPassword(auth, email, password); await signInWithEmailAndPassword(auth, email, password); // Pylon — email + password await fetch("/api/auth/password/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const res = await fetch("/api/auth/password/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); setSessionToken(token); // stores the token and re-syncs the replica for this user ``` * Email link (`sendSignInLinkToEmail` / `signInWithEmailLink`) maps to Pylon's magic code: `POST /api/auth/magic/send` then `/magic/verify`. * OAuth (`signInWithPopup(new GoogleAuthProvider())`) maps to a redirect to `GET /api/auth/login/:provider?callback=<url>`. Pylon supports Google, GitHub, Apple, Microsoft, and about 20 more providers. * Anonymous (`signInAnonymously`) maps to a Pylon guest session (`POST /api/auth/guest`). * `onAuthStateChanged` → `user.uid` maps to `db.useUser()` on the client and `ctx.auth.userId` on the server. ## What does not map cleanly Decide on these before you commit: * Schemaless documents. Firestore lets each document be any shape. Pylon is schema-first, so you define the fields. Keep genuinely freeform data in a `field.json()` column, but an update replaces the whole field (there is no per-key merge like `updateDoc`'s dotted paths). * Subcollections. Flatten each subcollection to a top-level entity with a parent `field.id` column. There is no nested-collection path in Pylon. * Document references. A Firestore `Reference` field becomes a plain id string in a `field.id` column. You resolve it with `include`, not a second `getDoc`. * Write triggers. Firestore's `onDocumentWritten` reacts to any write on a path. Pylon has no per-write trigger. Do the derived work inside the `mutation` that makes the write, or use a reactive query or a scheduled job. * Field types with no Pylon equivalent: Geopoint (`field.json()` as `{lat,lng}`) and Bytes (base64 in a `field.string()`). * Realtime Database. Its JSON-tree model is out of scope for this guide. None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite. ## Get started 1. Create a new Pylon app: `npm create @pylonsync/pylon@latest my-app`. 2. Write a schema (`entity` and `field`) for each Firestore collection, and a `policy()` for every entity. 3. Run the Admin SDK export, the conversion, and the `/api/batch` import against a staging app first. 4. Point the client at Pylon: `init`, the auth calls, `db.useQuery`, and `db.useEntity`. 5. Run `pylon deploy`, or self-host the single binary. # Migrating from InstantDB Source: https://docs.pylonsync.com/migrate/instantdb Move an app off Instant Cloud to Pylon: the concept map, the data export and import, and the parts that need a decision. ## What is happening On August 20, 2026, Instant announced the end of Instant Cloud. On August 25, 2026, Instant announced that the team joins OpenAI. These are the facts from both announcements: * New signups are closed. * Instant Cloud apps stop on August 31, 2027. * Backups stay available until August 31, 2028. * Instant refunds subscriptions that started after July 31, 2026. * Instant stays open source, but the team does not maintain it. Instant recommends that you self-host it. The team gives a reason for the move: "most of our users began to use Instant through agents." Public sources: Instant's [*The Instant team joins OpenAI*](https://www.instantdb.com/essays/instant_team_joins_openai), the Hacker News thread [*Instant (Instantdb.com) Sunsetting*](https://news.ycombinator.com/item?id=49375277), plus Instant's [self-hosting](https://www.instantdb.com/docs/self-hosting) and [Cloud-to-self-host migration](https://www.instantdb.com/docs/self-hosting/migrate) docs. If your app runs on Instant Cloud, you have about one year to move it. ## Your two options **1. Self-host Instant.** Instant is open source, so you can run it yourself. The stack is large. To self-host Instant, you operate: * a Clojure/JVM server on PostgreSQL 16 (with `wal_level = logical` and the `pg_hint_plan` extension); * a reverse proxy; * an email provider (Postmark or SendGrid); * the Java heap, tuned with `JAVA_OPTS`. Instant quotes about $30 per month for a side-project VPS, and $600 or more per month for a business AWS setup. You also maintain the code. The team is at OpenAI, so bug fixes, security patches, and new features are now your work or the community's. Self-host Instant if you have an operations team and you want the exact Instant stack. **2. Move to a managed backend.** You chose Instant Cloud because you did not want to run a database, a JVM, and an email provider. Self-hosting gives you all three. Pylon Cloud keeps the managed experience. It uses the same live-sync and permissions-as-code model. You deploy it with one command. Pylon also self-hosts as one Rust binary on SQLite or Postgres, which is much lighter than the Instant stack. This guide covers option 2. ## Why Pylon fits Pylon shares Instant's model. Most of your app maps directly: * a schema of entities with typed fields and relations; * permissions as code (per-entity, per-action boolean expressions that run on the server); * a live client with reactive queries against a local replica, plus optimistic writes that reconcile; * built-in auth (email codes and OAuth) and presence/rooms for ephemeral state. Pylon adds four things: * Hosted server functions. Instant runs logic on the client behind permission rules. For server logic, you run your own backend with the Admin SDK. Pylon runs your `query`, `mutation`, and `action` functions for you, each in a transaction. * Server-side rendering. Pylon uses file-based routes (`app/page.tsx`). One binary serves the frontend and the API. * A wider auth set: password, magic code, about 25 OAuth providers, passkeys, and TOTP. * Vector search and aggregates in the query layer. ## Concept map | Concept | InstantDB | Pylon | | ---------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- | | Client init | `init({ appId, schema })` (`@instantdb/react`) | `init({ appName })` (`@pylonsync/react`) | | Schema | `i.schema({ entities, links })`, `i.entity({...})` | `entity("Name", {...}, {...})` + `buildManifest(...)` | | Field types | `i.string/number/boolean/date/json()` | `field.string/int/float/bool/datetime/json()` | | Relation | `links: { forward, reverse, has: 'one'\|'many' }` | `field.id("Target")` foreign key, or `relations` + `include` | | Unique / indexed | `.unique()`, `.indexed()` | `.unique()`, `indexes: [{ fields, unique }]` | | Create | `db.transact(db.tx.todos[id()].create({...}))` | `db.useEntity("Todo").insert({...})` / `ctx.db.insert(...)` | | Update | `db.tx.todos[id].update({...})` | `.update(id, {...})` / `ctx.db.update(...)` | | Delete | `db.tx.todos[id].delete()` | `.remove(id)` / `ctx.db.delete(...)` | | Link | `db.tx.a[id].link({ b: bId })` | set the `field.id` field, or `ctx.db.link(...)` | | Live read | `db.useQuery({ todos: { $: { where } } })` | `db.useQuery("Todo", { where, orderBy, include, limit })` | | Nested read | `{ goals: { todos: {} } }` | `{ include: { todos: {} } }` | | Filter ops | `$gt $lt $in $like $ilike`, `and` / `or` | `$gt $lt $in $like`, `where` object | | One-shot read | `db.queryOnce(...)`, admin `db.query(...)` | `ctx.db.query / list / get` (server) | | Aggregates | not in the client API | `db.useAggregate(...)` | | Permissions | `instant.perms.ts` — `view/create/update/delete`, default allow | `policy({ allowRead/Insert/Update/Delete })`, default deny | | Lock a field | `newData.x == data.x` in an `update` rule | `field.string().owner()` (built in), or `existing.*` | | Auth (email) | `db.auth.sendMagicCode` / `signInWithMagicCode` | `POST /api/auth/magic/send` + `/magic/verify` | | Auth (OAuth) | `db.auth.signInWithIdToken` / `createAuthorizationURL` | `GET /api/auth/login/:provider?callback=` | | Auth state | `db.useAuth()` → `{ user }` | `db.useUser()` (client), `ctx.auth` (server) | | Presence / rooms | `db.room().usePresence` / `useTopicEffect` | `useRoom(id, userId)` + `useRoomMessages`, `ctx.rooms.broadcast` | | Storage | `db.storage.uploadFile`, `$files` | Pylon files provider | | Bulk / admin | `@instantdb/admin` `db.query` / `db.transact` | `POST /api/batch` (admin token), or a seed `mutation` | | Server logic | none hosted; run your own backend | `query` / `mutation` / `action` functions, `ctx.db` | | SSR | none | file-based SSR, `app/page.tsx`, `serverData` | | Deploy | self-host only (after the sunset) | `pylon deploy` (cloud) or single-binary self-host | ## Migrating your schema Instant declares entities and links separately. Pylon declares an entity with its fields, and it expresses a relation as a foreign-key field. **Instant** (`instant.schema.ts`): ```typescript theme={null} import { i } from '@instantdb/react'; const _schema = i.schema({ entities: { profiles: i.entity({ nickname: i.string(), createdAt: i.date() }), posts: i.entity({ title: i.string(), body: i.string(), createdAt: i.date(), }), }, links: { postAuthor: { forward: { on: 'posts', has: 'one', label: 'author' }, reverse: { on: 'profiles', has: 'many', label: 'authoredPosts' }, }, }, }); ``` **Pylon** (`app.ts`): ```typescript theme={null} import { entity, field, buildManifest, discoverAppRoutes } from "@pylonsync/sdk"; const Profile = entity("Profile", { nickname: field.string(), createdAt: field.datetime(), }); const Post = entity("Post", { authorId: field.id("Profile"), // the postAuthor link becomes a foreign-key field title: field.string(), body: field.string(), createdAt: field.datetime(), }, { indexes: [{ name: "by_author", fields: ["authorId"], unique: false }], }); export default buildManifest({ name: "my-app", version: "0.1.0", entities: [Profile, Post], policies: [/* see below */], routes: await discoverAppRoutes(), }); ``` Notes: * Instant's `.unique()` and `.indexed()` map to Pylon's `.unique()` and the `indexes` option. * A one-to-many link becomes a `field.id("Parent")` on the many side. Add an index on that field for query speed. * Instant creates `id` automatically. Pylon does the same. Instant's `$users` maps to a Pylon `User` entity. Instant's `$files` maps to Pylon's file storage. ## Migrating your permissions Do this step carefully. It is a security change, not only a syntax change. Instant allows by default. A namespace or action with no rule is open. Pylon denies by default. An entity with no policy is fully blocked. So you write a `policy()` for every entity. This includes entities that had no Instant rule, because those were open. For each one, decide whether it is truly public (`allow: "true"`) or was open by accident. The rule shapes match almost one to one. **Instant** (`instant.perms.ts`): ```typescript theme={null} const rules = { todos: { allow: { view: "auth.id != null", create: "isOwner", update: "isOwner && isStillOwner", delete: "isOwner", }, bind: { isOwner: "auth.id != null && auth.id == data.creatorId", isStillOwner: "auth.id != null && auth.id == newData.creatorId", }, }, } satisfies InstantRules; ``` **Pylon** (`policy(...)` in `app.ts`): ```typescript theme={null} policy({ name: "todo_owner", entity: "Todo", allowRead: "auth.userId != null", allowInsert: "auth.userId == data.creatorId", allowUpdate: "auth.userId == data.creatorId", allowDelete: "auth.userId == data.creatorId", }); ``` How to convert each rule: * `view/create/update/delete` map to `allowRead/allowInsert/allowUpdate/allowDelete`. * `auth.id` maps to `auth.userId`. Pylon policies have no `auth.email`. Gate on `auth.userId`, `auth.roles`, `auth.hasAnyRole(...)`, or `auth.tenantId`. * `data.*` is the row in both systems. Inline Instant's `bind` macros. * To lock an immutable field, use `field.string().owner()` in Pylon. It stamps `auth.userId` on insert. It rejects a false owner value. It locks the value on update. You do not need Instant's `newData.x == data.x` rule. ## Migrating reads and writes Reads. Instant nests linked namespaces in the query object. Pylon uses an `include` map. ```typescript theme={null} // Instant const { data } = db.useQuery({ posts: { author: {}, $: { where: { authorId }, order: { createdAt: 'desc' } } }, }); // Pylon const posts = db.useQuery<Post>("Post", { where: { authorId }, include: { author: {} }, orderBy: { createdAt: "desc" }, }); ``` Writes. Instant's `db.transact(db.tx...)` becomes a client hook (optimistic) or a server function. ```typescript theme={null} // Instant db.transact(db.tx.todos[id()].create({ title, done: false, creatorId: user.id })); db.transact(db.tx.todos[todoId].update({ done: true })); db.transact(db.tx.todos[todoId].delete()); // Pylon — client, optimistic const todos = db.useEntity("Todo"); todos.insert({ title, done: false, creatorId: userId }); todos.update(todoId, { done: true }); todos.remove(todoId); // Pylon — server, in a transaction export default mutation({ args: { title: v.string() }, async handler(ctx, { title }) { return ctx.db.insert("Todo", { title, done: false, creatorId: ctx.auth.userId }); }, }); ``` ## Migrating your data Use Instant's export. Do not read the triple store directly. **1. Export from Instant.** The backup is a zip file. It contains one NDJSON file per entity, a `config.json` (schema and rules), and a `files/` folder. ```bash theme={null} npx instant-cli@latest backup download --latest ``` For a scripted dump, use the Admin SDK. `db.query({ profiles: {}, posts: {} })` reads everything, because the admin token bypasses permissions. **2. Convert ids and relationships.** Instant ids are UUIDs. Instant links are separate triples. Pylon ids are 40-character lowercase hex strings. So you cannot reuse Instant's ids directly, and Pylon rejects a UUID with `INVALID_ID`. Use one of two methods: * Derive the id (simplest). Convert each Instant UUID to a Pylon id with a fixed function. Apply the same function to every foreign key. `sha1(uuid)` gives 40 lowercase hex characters, so it fits Pylon's format. It needs no lookup table, and the relationships stay correct. Derived ids are not time-ordered, so sort by your `createdAt` fields. * Map the id. Generate a new Pylon id per row with `generateId()` from `@pylonsync/sync`. Keep an old-UUID-to-new-id map. Rewrite the foreign keys in a second pass. In both methods, fold each Instant link onto the child row as its `field.id` field during the conversion. **3. Import into Pylon.** The admin batch endpoint loads rows quickly and bypasses per-entity policies. It needs an admin token. Send batches of a few hundred rows. ```bash theme={null} POST /api/batch Authorization: Bearer <PYLON_ADMIN_TOKEN> { "operations": [ { "op": "insert", "entity": "Profile", "data": { "id": "...", "nickname": "eva", "createdAt": "..." } }, { "op": "insert", "entity": "Post", "data": { "id": "...", "authorId": "...", "title": "...", "body": "...", "createdAt": "..." } } ] } ``` A sketch of the conversion: ```javascript theme={null} import { createHash } from "node:crypto"; // Instant UUID -> valid Pylon id (40-char lowercase hex). Deterministic, // so foreign keys map the same way with no lookup table. const toPylonId = (uuid) => createHash("sha1").update(uuid).digest("hex"); // read entities/posts.ndjson, one JSON object per line for (const batch of chunk(rows, 500)) { const operations = batch.map((row) => ({ op: "insert", entity: "Post", data: { id: toPylonId(row.id), authorId: toPylonId(row.author), // link folded into a foreign-key field title: row.title, body: row.body, createdAt: row.createdAt, }, })); await fetch(`${PYLON_URL}/api/batch`, { method: "POST", headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ operations }), }); } ``` **4. Files.** Download the files from the backup's `files/` folder. Upload them to Pylon's file storage. Rewrite the stored file paths. **5. Users.** Import each Instant `$users` row into your Pylon `User` entity. Key each user by email. Sessions do not transfer. Both systems use passwordless email auth, so each user signs in again with a magic code. The email links the user to the imported data. You do not migrate passwords. ## Migrating auth Instant defaults to magic codes. Pylon has the same passwordless email flow, so the user experience stays the same. ```typescript theme={null} // Instant await db.auth.sendMagicCode({ email }); await db.auth.signInWithMagicCode({ email, code }); // Pylon await fetch("/api/auth/magic/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); const res = await fetch("/api/auth/magic/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, code }), }); const { token } = await res.json(); setSessionToken(token); // stores the token and re-syncs the replica for this user ``` OAuth also maps. Instant's `signInWithIdToken` and `createAuthorizationURL` become a redirect to `GET /api/auth/login/:provider?callback=<url>`. Pylon supports Google, GitHub, Apple, Microsoft, and about 20 more providers. Pylon also supports passkeys and TOTP if you want to add them. ## What does not map cleanly Check these points before you commit: * Per-viewer field permissions. Instant's `fields` block shows a field to its owner and hides it from others, while the row stays public. Pylon hides a field from all clients (`field.serverOnly()` or `.syncOmit()`), not per viewer. To keep per-viewer access, serve the field through a server function, or move it to its own owner-scoped entity. * Schemaless data. Instant lets you add arbitrary attributes and deep-merge into JSON with `merge()`. Pylon is schema-first. Freeform data goes in a `field.json()` field. An update replaces the whole field, so there is no per-key merge. * Sessions. Sessions do not transfer. Users sign in again. This is a non-issue for passwordless email. * Instant-specific query features. Deep InstaQL nesting and `$users`/`$files` tricks need a rewrite to Pylon's `include` and its `User` and files model. None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite. ## Get started 1. Create a new Pylon app: `npm create @pylonsync/pylon@latest my-app`. 2. Rewrite the schema (`entity` and `field`). Write a `policy()` for every entity. Remember default-deny. 3. Run the export, conversion, and `/api/batch` import against a staging app first. 4. Point the client at Pylon: `init`, the auth calls, `db.useQuery`, and `db.useEntity`. 5. Run `pylon deploy`, or self-host the single binary. Start early. Instant Cloud stops on August 31, 2027. # Migrating from Supabase Source: https://docs.pylonsync.com/migrate/supabase Move a Supabase app to Pylon: Postgres schema to typed entities, RLS to policies, the pg_dump export path, auth, and the parts that need a decision. Supabase and Pylon share the most: a relational store with a real schema and foreign keys, per-row access policies, auth, functions, and file storage. This is the closest migration of the set. The main work is turning your SQL schema into Pylon entities and your RLS policies into Pylon policies. ## Why Pylon fits * Postgres tables with typed columns and foreign keys map to Pylon entities with typed fields and `field.id` relations. * Row Level Security policies map to per-entity policies, and Pylon evaluates them the same way (an existing-row read side and an incoming-write side). * Supabase Realtime maps to reactive queries against a local replica. * Supabase Auth (email/password, magic link, OAuth, anonymous) maps one-to-one to Pylon auth. What Pylon adds or does differently: * Your schema and policies live in **TypeScript**, next to your app, not in SQL migration files and a separate RLS layer. * Hosted server functions (`query` / `mutation` / `action`) in TypeScript, instead of Deno Edge Functions and `plpgsql`. * Server-side rendering: file-based routes, one binary serving frontend and API. * Pylon runs on SQLite or Postgres and deploys with one command, or self-hosts as a single binary. ## Concept map | Concept | Supabase | Pylon | | ---------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | | Client init | `createClient(url, anonKey)` | `init({ appName })` | | Schema | SQL migrations / dashboard (Postgres) | `entity("Name", {...})` + `buildManifest(...)` | | Field types | `text`, `int`, `timestamptz`, `jsonb`, `uuid`, `bool` | `field.string/int/float/datetime/json/bool` | | Relation | Postgres FK (`references`) | `field.id("Target")` + `include` | | Primary key | `uuid default gen_random_uuid()` / bigint identity | auto 40-char hex, or a custom id | | Read | `.from('posts').select().eq(...).order(...).limit(...)` | `db.useQuery("Post", { where, orderBy, limit })` | | Embedded join | `.select('*, author:profiles(*)')` | `{ include: { author: {} } }` | | Single row | `.single()` | `db.useQueryOne("Post", id)` / `ctx.db.get(...)` | | Live read | `.channel(...).on('postgres_changes', ...)` | `db.useQuery(...)` (reactive by default) | | Insert | `.insert({...}).select()` | `db.useEntity("Post").insert({...})` / `ctx.db.insert(...)` | | Update | `.update({...}).eq('id', id)` | `.update(id, {...})` / `ctx.db.update(...)` | | Delete | `.delete().eq('id', id)` | `.remove(id)` / `ctx.db.delete(...)` | | Upsert | `.upsert({...}, { onConflict })` | insert-or-update in a `mutation` | | Access control | RLS: `enable`, then `create policy` | `policy({...})` (deny by default, no enable step) | | Policy: existing row | `using (...)` | `allowRead`, and `existing.*` on update/delete | | Policy: incoming write | `with check (...)` | `allowInsert` / `allowUpdate` on `data.*` | | Policy: user id | `auth.uid()` | `auth.userId` | | Auth (email/password) | `signInWithPassword(...)` | `POST /api/auth/password/login` | | Auth (magic link) | `signInWithOtp({ email })` | `POST /api/auth/magic/send` + `/magic/verify` | | Auth (OAuth) | `signInWithOAuth({ provider })` | `GET /api/auth/login/:provider?callback=` | | Auth (anonymous) | `signInAnonymously()` | `POST /api/auth/guest` | | Auth state | `onAuthStateChange` / `getUser()` → `user.id` | `db.useUser()` (client), `ctx.auth.userId` (server) | | Server logic | Edge Functions (Deno) + `plpgsql` | `query` / `mutation` / `action` (TypeScript) | | DB triggers | `plpgsql` triggers | do the work in the `mutation` | | Storage | `storage.from('b').upload(...)` / `getPublicUrl(...)` | Pylon files provider | | Deploy | Supabase (managed) | `pylon deploy` (cloud) or single-binary self-host | ## Migrating your schema Each Postgres table becomes a Pylon entity. Columns become typed fields, and a foreign key becomes a `field.id`. **Supabase** (SQL): ```sql theme={null} create table public.posts ( id uuid primary key default gen_random_uuid(), author_id uuid not null references auth.users(id), title text not null, body text, created_at timestamptz not null default now() ); ``` **Pylon** (`app.ts`): ```typescript theme={null} import { entity, field, buildManifest, discoverAppRoutes } from "@pylonsync/sdk"; const Post = entity("Post", { authorId: field.id("User"), // references auth.users(id) becomes an FK field title: field.string(), body: field.string().optional(), createdAt: field.datetime().defaultNow(), }, { indexes: [{ name: "by_author", fields: ["authorId"], unique: false }], }); export default buildManifest({ name: "my-app", version: "0.1.0", entities: [Post], policies: [/* see below */], routes: await discoverAppRoutes(), }); ``` Notes: * Map Postgres types to Pylon: `text` → `field.string`, `int`/`bigint` → `field.int`, `numeric`/`float` → `field.float`, `timestamptz` → `field.datetime`, `jsonb` → `field.json`, `bool` → `field.bool`. * A `references auth.users(id)` foreign key becomes `field.id("User")`. Other table references become `field.id("ThatEntity")`. * Postgres check constraints and defaults map to field modifiers (`.optional()`, `.unique()`, `.default(...)`, `.defaultNow()`). ## Migrating your policies Supabase Row Level Security and Pylon policies are the same idea: a per-table read side and a per-table write side. One difference to audit: **RLS is off by default on a new Supabase table**, so any table where you never enabled it is fully open through the API. Pylon denies by default with no enable step, so every entity needs an explicit `policy()`. List your tables and check which ones never had RLS enabled, then decide whether each should be public or locked down. **Supabase** (RLS): ```sql theme={null} alter table public.posts enable row level security; create policy "owner read" on posts for select using ( auth.uid() = author_id ); create policy "owner insert" on posts for insert with check ( auth.uid() = author_id ); create policy "owner update" on posts for update using ( auth.uid() = author_id ) with check ( auth.uid() = author_id ); create policy "owner delete" on posts for delete using ( auth.uid() = author_id ); ``` **Pylon** (`policy(...)`): ```typescript theme={null} policy({ name: "post_owner", entity: "Post", allowRead: "auth.userId == data.authorId", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == data.authorId", allowDelete: "auth.userId == data.authorId", }); ``` How to convert each policy: * `auth.uid()` maps to `auth.userId`. `auth.jwt()` claims (roles) map to `auth.roles` / `auth.hasAnyRole(...)`. * `using (...)` is the existing-row side. It maps to `allowRead`, and to `existing.*` in the update and delete policy. * `with check (...)` is the incoming-write side. It maps to `allowInsert` and `allowUpdate`, evaluated on `data.*` (the row being written). * To lock an immutable field, use `field.string().owner()` instead of a `with check` that compares the new value to the old one. * A cross-table check (`exists (select 1 from members ...)`) maps to `exists(Member where orgId == data.orgId and userId == auth.userId)`. ## Migrating reads and writes Reads. A `supabase-js` query becomes `db.useQuery`, which is reactive by default, so you do not wire up a separate realtime channel. ```typescript theme={null} // Supabase const { data } = await supabase .from("posts") .select("*, author:profiles(*)") .eq("author_id", authorId) .order("created_at", { ascending: false }) .limit(10); // Pylon (live) const posts = db.useQuery<Post>("Post", { where: { authorId }, include: { author: {} }, orderBy: { createdAt: "desc" }, limit: 10, }); ``` Writes. `.insert`/`.update`/`.delete` become the client entity hook or a server function. ```typescript theme={null} // Supabase await supabase.from("posts").insert({ title, author_id: uid }).select(); await supabase.from("posts").update({ title: "New" }).eq("id", id); await supabase.from("posts").delete().eq("id", id); // Pylon — client, optimistic const posts = db.useEntity("Post"); posts.insert({ title, authorId: userId }); posts.update(id, { title: "New" }); posts.remove(id); // Pylon — server, in a transaction export default mutation({ args: { title: v.string() }, async handler(ctx, { title }) { return ctx.db.insert("Post", { title, authorId: ctx.auth.userId }); }, }); ``` One Supabase quirk to remember: `.insert`/`.update`/`.delete` do not return the affected rows unless you chain `.select()`. Pylon's `ctx.db.insert` returns the new id, and `db.useEntity` paints the change locally. ## Migrating your data Supabase is Postgres, so export is standard SQL tooling. This is the easy part. **1. Export with `pg_dump`.** Use the connection string from Dashboard → Connect. ```bash theme={null} # data only, as COPY statements pg_dump "$SUPABASE_DB_URL" --data-only --use-copy -f data.sql # or per-table CSV for a scripted transform psql "$SUPABASE_DB_URL" -c "\copy (select * from posts) to 'posts.csv' csv header" ``` **2. Convert ids.** Supabase ids are usually UUIDs. Pylon ids are 40-character lowercase hex, so you cannot reuse a UUID directly (Pylon rejects a bad id with `INVALID_ID`). Use one of two methods: * Derive the id (simplest). Convert each UUID to a Pylon id with a fixed function, and apply the same function to every foreign key. `sha1(uuid)` gives 40 lowercase hex characters. Derived ids are not time-ordered, so sort by your `created_at` fields. * Map the id. Generate a new Pylon id per row with `generateId()` from `@pylonsync/sync`, keep an old-to-new map, and rewrite foreign keys in a second pass. **3. Import into Pylon.** The admin batch endpoint loads rows quickly and bypasses per-entity policies. It needs an admin token. Send batches of a few hundred rows. ```javascript theme={null} import { createHash } from "node:crypto"; const toPylonId = (uuid) => createHash("sha1").update(uuid).digest("hex"); for (const batch of chunk(rows, 500)) { const operations = batch.map((row) => ({ op: "insert", entity: "Post", data: { id: toPylonId(row.id), authorId: toPylonId(row.author_id), // FK converted the same way title: row.title, body: row.body, createdAt: row.created_at, }, })); await fetch(`${PYLON_URL}/api/batch`, { method: "POST", headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ operations }), }); } ``` **4. Files.** Download each object from Supabase Storage and upload it to Pylon's file storage, then rewrite the stored paths and URLs. **5. Users.** Supabase users live in `auth.users`. Import each into your Pylon `User` entity, keyed by email, and map the old user UUID to the new Pylon id the same way you map foreign keys. Sessions and password hashes do not transfer, so users sign in again with magic code or OAuth, where email is the stable key. ## Migrating auth Each Supabase auth method has a Pylon equivalent. ```typescript theme={null} // Supabase — email + password await supabase.auth.signInWithPassword({ email, password }); // Pylon — email + password const res = await fetch("/api/auth/password/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); setSessionToken(token); // stores the token and re-syncs the replica for this user ``` * Magic link (`signInWithOtp({ email })`) maps to `POST /api/auth/magic/send` then `/magic/verify`. * OAuth (`signInWithOAuth({ provider })`) maps to a redirect to `GET /api/auth/login/:provider?callback=<url>`. Pylon supports Google, GitHub, Apple, Microsoft, and about 20 more providers. * Anonymous (`signInAnonymously()`) maps to a Pylon guest session (`POST /api/auth/guest`). * `getUser()` / `onAuthStateChange` → `user.id` maps to `db.useUser()` on the client and `ctx.auth.userId` on the server. ## What does not map cleanly Decide on these before you commit: * Raw SQL and `rpc`. Supabase lets the client run stored procedures and, through the SQL editor, arbitrary SQL. Pylon's data access is through entities and server functions, not arbitrary SQL from the client. Move `plpgsql` functions and complex queries into a Pylon `query` or `mutation`. * Database triggers. A `plpgsql` trigger that runs on write becomes logic inside the `mutation` that makes the write, or a scheduled job. * Postgres-only features. Views, materialized views, and extensions do not carry over as-is. Pylon has its own vector search, so `pgvector` maps to `field.vector` and `ctx.db.vectorSearch`. * RLS defaults. A table where RLS was never enabled was open; in Pylon it needs an explicit policy. Audit every table. * Realtime scope. Supabase realtime subscribes to a table and a filter string. Pylon's reactive queries subscribe to the query itself, so you do not re-derive relevance on the client. None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite. ## Get started 1. Create a new Pylon app: `npm create @pylonsync/pylon@latest my-app`. 2. Turn each table into an `entity`, and each RLS policy into a `policy()`. Audit tables that had RLS off. 3. Run the `pg_dump` export, the conversion, and the `/api/batch` import against a staging app first. 4. Point the client at Pylon: `init`, the auth calls, `db.useQuery`, and `db.useEntity`. 5. Run `pylon deploy`, or self-host the single binary. # Agent handoff Source: https://docs.pylonsync.com/operations/agent-handoff Sign your coding agent into Pylon Cloud with a one-time paste. The Pylon Cloud dashboard mints a one-time 8-character code. The code signs a coding agent into your account, installs the Pylon skill, and gives the agent an opening prompt. It works with any agent that runs shell commands from a markdown prompt (Claude Code, OpenAI Codex CLI, OpenCode, Cursor, Aider, grok build). You paste the code once and type nothing. ## Use it 1. Sign in at [www.pylonsync.com](https://www.pylonsync.com/dashboard). 2. Open an organization. The first card on the page is **Hand off to your coding agent**. 3. Click **Generate prompt**. The dialog shows a short code (e.g. `ABCD-1234`) inside a paste-ready blurb. 4. Click **Copy prompt**, paste into your coding agent, press return. The agent will: * install the Pylon CLI (`curl -fsSL https://www.pylonsync.com/install.sh | bash`) * sign in with `pylon login --code ABCD-1234` * load `https://www.pylonsync.com/pylon-skill.md` into wherever it persists project rules (`~/.claude/skills/pylon/SKILL.md` for Claude Code, `.cursor/rules/pylon.mdc` for Cursor, `CONVENTIONS.md` for Aider, etc.) * run `pylon projects list` + `pylon status` so it knows the starting state * ask you what you want to build ## Short-code security The dashboard mints a real `pk.*` Pylon API key first (the same kind you create manually in `/dashboard/account/cli-tokens`). It then stores the key on the server behind the short code. The prompt you paste into the agent contains only the code. The token never appears in the chat history, the model provider's logs, or any analytics that records the prompt. When the agent runs `pylon login --code ABCD-1234`, the CLI calls `/api/fn/exchangeCliAuthCode` on the cloud, which: * atomically sets the stored token to null and stamps `consumedAt` * returns the token to the CLI in one round-trip * the CLI writes it to `~/.config/pylon/credentials.json` (mode 0600) If nobody redeems the code, the token stays inert in the database for 5 minutes and then becomes unreachable. A revoke from `/dashboard/account/cli-tokens` removes it. ## Properties * **Single-use.** A second `exchangeCliAuthCode` call with the same code returns `CODE_USED`. * **Short TTL.** Five minutes. Past that the code returns `CODE_EXPIRED`. * **High entropy.** `XXXX-XXXX` over a 30-symbol confusable-pruned alphabet gives about 40 bits. The 5-minute window and the per-IP rate limit make a brute-force guess in flight infeasible. * **Revokeable like any other token.** The minted key appears at `/dashboard/account/cli-tokens` as `Coding agent · MMM D`. Click trash to end the agent's session immediately. ## Working without the dashboard If you script this and do not want to click through the UI, the `--code` exchange endpoint is a regular Pylon function. You can write your own pre-mint flow: ```bash theme={null} # from a script holding an existing CLI token in PYLON_TOKEN curl -s https://www.pylonsync.com/api/auth/api-keys \ -H "Authorization: Bearer $PYLON_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"CI agent · '"$(date +%F)"'"}' ``` Or use the regular `pylon login` paste flow when you already have a token. The code-exchange path is an agent-onboarding convenience. ## What the prompt contains The full prompt the dashboard generates: ``` I just signed up for Pylon Cloud and want you to help me build. Please run these steps in order: 1. Install the Pylon CLI: curl -fsSL https://www.pylonsync.com/install.sh | bash 2. Sign in to my Pylon Cloud account (single-use code, expires in 5 min): pylon login --code ABCD-1234 3. Load https://www.pylonsync.com/pylon-skill.md as your reference for Pylon conventions and save it wherever your tool persists project rules. Common locations: - Claude Code → ~/.claude/skills/pylon/SKILL.md - Cursor → .cursor/rules/pylon.mdc - Aider → append to CONVENTIONS.md - Codex / OpenCode / others → wherever you load long-lived context 4. Show me what's on my account so we know where we're starting: pylon projects list If the list is empty, create one yourself once we've picked a name: `pylon projects create <slug>` provisions it, waits until it's live at https://<slug>.smallware.run, and sets it as the working context. To target an existing project instead, run `pylon projects use <slug>` from this directory. Then ask me one question: what am I trying to build? For context: Pylon is a realtime backend framework for TypeScript apps — schema, server functions, live queries, auth, jobs, files, and search in a single binary. It pairs with Next.js on Vercel out of the box. Docs at https://docs.pylonsync.com. The CLI you just installed can do everything the dashboard does (secrets, logs, deploys, domains, db backups, etc.) — `pylon --help` lists every command. ``` # CLI reference Source: https://docs.pylonsync.com/operations/cli Every pylon command, what it does, when to use it. The `pylon` binary covers every operator task: the dev loop, deploys, secrets, logs, db, data, domains, deployments, members, and project health. Anything you do from the Pylon Cloud dashboard, you can do from the CLI and pipe to other shell tools. ```bash theme={null} pylon --help # full command list pylon <cmd> --help # per-command flags ``` ## Install + sign in ```bash theme={null} curl -fsSL https://www.pylonsync.com/install.sh | bash pylon login # device-authorization flow: prints a login code, opens # www.pylonsync.com/cli, and polls until you type + approve # that code in the browser. No token paste. pylon login --token <token> # non-interactive: pass a token directly pylon login --token-stdin # read the token from stdin (CI / pipes) # (PYLON_CLI_TOKEN=… also works) pylon login --code XXXX-XXXX # agent-handoff path: redeems a one-time code minted by # the dashboard's "Hand off to your coding agent" card. # See /operations/agent-handoff. pylon logout # delete ~/.config/pylon/credentials.json ``` `PYLON_CLOUD_URL` overrides the default `https://www.pylonsync.com` for staging or self-hosted Pylon Cloud installs. ## Project context Most commands target a single project. Resolution order (first match wins): 1. `--project <slug>` flag on the command 2. `PYLON_PROJECT` env var 3. `.pylon/project` file in cwd or any ancestor (written by `pylon projects use`) 4. Interactive picker (TTY only) ```bash theme={null} pylon projects list # all projects you can see pylon projects create my-app # create + provision a project, set it as context pylon projects use my-app # writes .pylon/project + adds to .gitignore pylon projects current # what's the current context? pylon projects use "" # clear the context ``` ### Create `pylon projects create <slug>` provisions a project on Pylon Cloud without the dashboard. It creates the project in your org, waits for the machine to start (usually under a minute; `--db postgres` adds a managed-database provision first), pins the slug as your local context, and prints the live `https://<slug>.smallware.run` URL. ```bash theme={null} pylon projects create my-app # SQLite, region iad, waits until live pylon projects create my-app --name "My App" # display name (defaults to the slug) pylon projects create my-app --org acme # required only if you're in multiple orgs pylon projects create my-app --db postgres # managed Postgres instead of SQLite pylon projects create my-app --region ord # Fly region pylon projects create my-app --no-wait # return immediately; check with `pylon status` ``` With `--json` the final line is a single machine-readable object (`{"ok":true,"slug":"my-app","status":"running","url":"https://my-app.smallware.run",...}`), so agents and scripts can chain directly into `pylon deploy`. ## Test ```bash theme={null} pylon test # run *.test.ts / *.test.tsx under tests/ (or functions/) pylon test credits # only files whose path contains "credits" pylon test --json # machine-readable summary pylon test:security # adversarial auth/policy probe vs a running app pylon test:security --target https://api.example.com --json ``` `pylon test` runs each test file with Bun against an in-memory Pylon. It exits non-zero on failure, so it fits into CI. `pylon test:security` is a separate probe that calls a running app and reports auth/policy holes. See the [Testing guide](/testing) for the unit, component, and HTTP patterns. ## Deploy ```bash theme={null} pylon deploy # default → Pylon Cloud (packages local # source, uploads via the cli-tokens flow) pylon deploy --target docker # generate Dockerfile to ./deploy/ pylon deploy --target fly # Dockerfile + fly.toml pylon deploy --target compose # docker-compose.yml + Dockerfile pylon deploy --target workers # wrangler.toml (experimental) pylon deploy --target systemd # systemd unit file pylon deploy --target manifest # just manifest + client bindings ``` `pylon deploy` (no flag) runs the hosted deploy. The other targets write IaaS config files to `./deploy/`. ## Secrets ```bash theme={null} pylon secrets list # keys + last-rotated dates (values never leave the cloud) pylon secrets set STRIPE_KEY=sk_… # one-liner pylon secrets set OPENAI_KEY # prompt for value on stdin pylon secrets rm STRIPE_KEY pylon secrets import .env # bulk import from a dotenv file pylon secrets import .env --replace # allow overwriting existing keys ``` Every write fires `syncSecretsToFly` immediately so the running machine sees the new value within \~10s (no redeploy required). ## Logs ```bash theme={null} pylon logs tail # 2s-polling tail of the project's request log, # Ctrl-C to stop. Pipe to grep/jq for filtering. pylon logs tail --json | jq 'select(.status >= 500)' # all 5xx in real time ``` Auto-recovers on `kind: unavailable` (machine stopped, framework predates the tail endpoint, etc.) — backs off to 30s until the surface comes back. ## Database ```bash theme={null} pylon db list # snapshot history pylon db backup # take a fresh snapshot pylon db restore <backup-id> # restore from one ``` Watch the project's Deployments tab for restore progress; restoring rolls the Fly machine. ## Data browser ```bash theme={null} pylon data entities # every entity in the deployed schema pylon data list Order # first 50 rows pylon data get Order ord_9f2a # single row, pretty-printed JSON ``` Owner / admin only (same gate the dashboard data browser uses). ## Domains ```bash theme={null} pylon domains list # custom hostnames + provisioning status pylon domains add api.example.com # adds row; prints the CNAME target pylon domains verify api.example.com # checks DNS + cert provisioning pylon domains rm api.example.com ``` The default `pylon-<slug>.fly.dev` hostname is always available regardless. ## Deployments ```bash theme={null} pylon deployments list # 20 most recent pylon deployments rollback <id> # promote a previous deployment back to current ``` ## Members ```bash theme={null} pylon members list # everyone in the project's org pylon members invite teammate@example.com admin # invite with role pylon members invite teammate@example.com # defaults to member ``` Roles: `owner`, `admin`, `member`. ## Status ```bash theme={null} pylon status # uptime, requests, error rate, jobs, WS/SSE clients pylon status --json # structured output for monitoring scripts ``` One-glance project health — same surface the dashboard's project overview shows. ## JSON mode Every command accepts `--json` and emits parseable output for scripts: ```bash theme={null} pylon projects list --json | jq -r '.projects[].slug' # fail CI on any project not in 'running' status pylon status --json | jq -e '.status == "running"' # nightly backup of every project you can see pylon projects list --json | jq -r '.projects[].slug' | \ xargs -I{} pylon db backup --project {} ``` ## Env vars | Variable | Effect | | ----------------- | ----------------------------------------------------------------- | | `PYLON_CLOUD_URL` | Override the cloud origin (staging / self-hosted) | | `PYLON_PROJECT` | Default project slug (overridden by `--project`) | | `XDG_CONFIG_HOME` | Where `pylon login` stashes credentials (defaults to `~/.config`) | # Deploy Source: https://docs.pylonsync.com/operations/deploy Deploy Pylon to a single VPS, AWS ECS, Cloudflare Workers, or Pylon Cloud. Pylon runs as a Rust binary (plus Bun, which runs your TypeScript and SSR) behind a TLS-terminating reverse proxy. This page covers the supported self-hosted deploy shapes and the experimental Workers path. To skip self-hosting, [Pylon Cloud](/cloud) hosts the same binary. ## Required environment ```sh theme={null} # Core PYLON_PORT=4321 PYLON_DB_PATH=/var/lib/pylon/pylon.db PYLON_FILES_DIR=/var/lib/pylon/uploads PYLON_MANIFEST=/etc/pylon/pylon.manifest.json # Auth (MUST be set in non-dev) PYLON_ADMIN_TOKEN=<64+ random bytes, hex> PYLON_SESSION_DB=/var/lib/pylon/sessions.db # SQLite only; Postgres shares auth state # Client-facing — declarative: prefer manifest.auth.trustedOrigins # in app.ts (one source feeds CORS + CSRF + OAuth-redirect). These # env vars exist as per-gate overrides when ops need to split. # PYLON_CORS_ORIGIN=https://your-app.com # PYLON_CSRF_ORIGINS=https://your-app.com # Mode switch (default is false — only flip in dev) # PYLON_DEV_MODE=false ``` Optional: ```sh theme={null} PYLON_JOBS_DB=/var/lib/pylon/jobs.db # SQLite job sidecar only PYLON_WORKFLOWS_DB=/var/lib/pylon/workflows.db # SQLite workflow sidecar only PYLON_OAUTH_GOOGLE_CLIENT_ID=... # enable Google OAuth PYLON_OAUTH_GITHUB_CLIENT_ID=... # enable GitHub OAuth PYLON_EMAIL_PROVIDER=stack0 # or sendgrid | resend | webhook PYLON_EMAIL_API_KEY=sk_live_... # provider API key PYLON_EMAIL_FROM=noreply@yourdomain.com # verified sender PYLON_EMAIL_ENDPOINT=... # only when PYLON_EMAIL_PROVIDER=webhook # File storage (defaults to local disk) PYLON_FILES_PROVIDER=stack0 # local (default) | stack0 | s3 PYLON_STACK0_API_KEY=sk_live_... # required when provider=stack0 PYLON_STACK0_PROJECT_SLUG=your-project # required when provider=stack0 PYLON_STACK0_FOLDER=uploads # optional folder/prefix PYLON_FILES_DIR=/var/lib/pylon/uploads # local provider only # S3-compatible (AWS S3, R2, Tigris, MinIO). Boot-validated; never silently # falls back to local disk. Credentials from env only (no IAM-role discovery). PYLON_S3_BUCKET=my-bucket # required when provider=s3 PYLON_S3_ACCESS_KEY=AKIA... # required when provider=s3 PYLON_S3_SECRET_KEY=... # required when provider=s3 PYLON_S3_REGION=us-east-1 # optional, default us-east-1 PYLON_S3_ENDPOINT=https://... # optional; R2/Tigris/MinIO (path-style) PYLON_S3_PUBLIC_URL=https://cdn... # optional; public bucket/CDN base PYLON_S3_SESSION_TOKEN=... # optional; STS/temporary credentials PYLON_FILES_URL_PREFIX=/api/files # local provider only # Cookie auth (browser apps) PYLON_COOKIE_DOMAIN=.your-app.com PYLON_COOKIE_SAME_SITE=lax PYLON_COOKIE_SECURE=true ``` Hard requirements that fail to start: * `PYLON_CORS_ORIGIN=*` in non-dev mode is refused * In non-dev mode, the CORS gate refuses to start with no trusted origins. Declare them in `manifest.auth.trustedOrigins`, or set `PYLON_CORS_ORIGIN` * `PYLON_DEV_MODE=true` with `PYLON_ADMIN_TOKEN` unset is refused * `/api/__test__/reset` is disabled unless dev + in-memory + loopback ## Ports Pylon uses up to four adjacent ports, depending on which transports you enable: | Port | Purpose | When | | ----------------------------- | --------------- | ------------------------------------------------------ | | `PYLON_PORT` (default `4321`) | HTTP | Always | | `PYLON_PORT + 1` | WebSocket | When sync engine uses `transport: websocket` (default) | | `PYLON_PORT + 2` | SSE (`/events`) | When clients use `transport: sse` fallback | | `PYLON_PORT + 3` | Realtime shards | When apps use `useShard` / `PylonRealtime` | Your reverse proxy must forward all relevant ports. For deployments with a single public port (Fly.io, Cloud Run), path-route the WebSocket, SSE, and shard upgrades through that one port at the proxy (see the Caddy and nginx examples below), then point the client's sync `wsUrl` at the public origin. There is no server-side WS-URL environment variable; the client's transport config tells it where to connect. ## Shape 1: single VPS (SSH + systemd) This is the simplest and cheapest shape: the pylon binary plus Bun, one systemd unit, and one reverse proxy. `pylon deploy --target systemd` generates this unit, plus step-by-step install commands, for your app. The shape looks like this: ```ini theme={null} # /etc/systemd/system/pylon.service [Unit] Description=pylon After=network-online.target [Service] EnvironmentFile=/etc/pylon/env # App source (app.ts, functions/, app/, node_modules) lives here. # TypeScript functions + SSR need bun on the host: # curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash WorkingDirectory=/opt/my-app ExecStart=/usr/local/bin/pylon start app.ts Restart=on-failure RestartSec=5s User=pylon Group=pylon NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=/var/lib/pylon [Install] WantedBy=multi-user.target ``` Reverse proxy (Caddy or nginx) forwards `:443` → `:4321` plus WebSocket upgrades for `/ws` → `:4322`, `/events` (SSE) → `:4323`, and shard WS → `:4324`. `pylon deploy --target systemd` emits a matching nginx or Caddy reverse-proxy config alongside the unit file (generated by `crates/runtime/src/tls.rs`). ```sh theme={null} systemctl enable --now pylon ``` For backups, run `pylon backup /var/backups/pylon/$(date +%F)` nightly with cron. Test the restore quarterly, using the test at `crates/runtime/tests/backup_restore.rs`. ### Caddy example ``` your-app.com { reverse_proxy localhost:4321 handle /ws { reverse_proxy localhost:4322 } handle /events { reverse_proxy localhost:4323 } handle /shard/* { reverse_proxy localhost:4324 } } ``` ## Shape 2: AWS ECS + Aurora (Terraform) `deploy/terraform/modules/pylon/` provisions: * ECS Fargate service (0.25 vCPU, 512 MB) \~\$9/mo * Aurora Serverless v2 (0.5–2 ACU) \~\$15/mo minimum * ALB with TLS + WebSocket routing * CloudFront CDN + Route53 DNS Minimum bill: \~\$25/mo for a production deployment. Compile Pylon with `--features postgres-live` and set `DATABASE_URL=postgres://...`. ## Shape 3: AWS via SST (TypeScript IaC) For TypeScript-first infrastructure, [SST v3](https://sst.dev) provisions the same AWS shape from a single `sst.config.ts`. It uses the same components (Aurora, Fargate, ALB, EFS, CloudFront) and one CLI for deploys, secrets, and per-PR preview environments. ```bash theme={null} sst deploy --stage production ``` See **[Deploy with SST](/operations/sst)** for the full walkthrough: * secrets * custom domains * multi-port load balancer config * EFS vs S3 storage * horizontal scaling ## Shape 4: Cloudflare Workers (edge, experimental) `crates/workers/` builds a WASM bundle (`worker-build --release`) that runs on Workers with a D1 binding. See `crates/workers/README.md` for current limitations. Idle apps cost \$0, because Workers scales to zero. Cost rises with request volume. See [Workers costs](/operations/workers-costs). Workers does not yet support realtime shards (tick-based sims). They need persistent state that Workers alone cannot hold efficiently. Use shape 1 or 2 for game shards. ## Shape 5: Pylon Cloud ```bash theme={null} pylon login pylon deploy --target cloud ``` Done. See [Cloud](/cloud) for full details. ## Shape 6: local dev ```sh theme={null} pylon dev ``` This starts on port 4321 with `PYLON_DEV_MODE=true` defaults: Studio at `/studio`, hot-reload, and permissive CORS. Do not use this mode in production. ## Health checks * `GET /health` returns 200 with `{"status":"ok","uptime_seconds":N}` * `GET /metrics` returns Prometheus text when `Accept: text/plain` * `GET /readyz` checks DB connectivity Hook these into your load balancer so it drains unhealthy instances. ## Shutdown and rolling deploys `SIGTERM` terminates the process. The runtime does not yet install a signal handler, so SIGTERM does not run an in-process drain. The drain path exists in the runtime, but it is not wired to a signal yet. Two things make an abrupt stop safe in practice: * **SQLite (WAL mode) is crash-consistent.** An abrupt stop does not corrupt the database. The next start recovers the WAL, so there is no unflushed data to lose. * **Drain at the load balancer, not in the process.** For a rolling deploy with zero dropped requests, stop routing new traffic to the old instance. Wait out your load balancer's connection-drain (deregistration) delay so in-flight requests finish. Then stop the process. For a rolling deploy: start the new instance, let the load balancer health check promote it, stop routing traffic to the old one, wait for the drain window, then send `SIGTERM`. `PYLON_DRAIN_SECS` (default 10s) bounds the drain loop for paths that do invoke the runtime's shutdown, such as a programmatic shutdown. It is not a SIGTERM grace period. ## Native clients (iOS, macOS, Android) Native clients hit the same HTTP and WebSocket endpoints as browsers. Keep these points in mind: * **CORS does not apply.** Native HTTP clients skip CORS entirely. `PYLON_CORS_ORIGIN` does not affect them. * **TLS is required on iOS.** App Transport Security rejects `ws://` and `http://` to non-localhost hosts. Always use `wss://` and `https://` in production. Self-signed certificates work in dev with an ATS exception in `Info.plist`. * **Background sessions.** For large file uploads or downloads that should continue when the app is backgrounded, configure `URLSessionConfiguration.background(...)` in your transport. See [Swift SDK](/clients/swift). ## Scale-out Pylon supports multiple processes behind a load balancer. Use Postgres for shared entities, auth state, jobs, workflows, and sync state. Configure `PYLON_CLUSTER_BUS` for live change, presence, and CRDT fanout between self-hosted processes. Pylon Cloud configures its managed relay for you. Jobs and workflow steps use at-least-once execution. Make their handlers idempotent. SQLite remains a single-machine backend. See [Horizontal scaling](/operations/scaling) for the full setup and failure model. For multi-region on Pylon Cloud, set the workspace region during signup. Self-hosted multi-region is a manual setup: one Pylon process per region, with app-level routing. ## What about Docker? The runtime image bundles the pylon binary plus Bun (which runs your TypeScript functions and SSR). It expects your app source mounted (or baked) at `/app` and keeps data on the `/data` volume: ```bash theme={null} docker run -d \ --name pylon \ -p 4321:4321 -p 4322:4322 -p 4323:4323 -p 4324:4324 \ -v $(pwd):/app \ -v /var/lib/pylon:/data \ --env-file /etc/pylon/env \ ghcr.io/pylonsync/pylon:latest ``` Same env vars apply. Mount the data volume so it survives container restarts. For a self-contained image (no runtime mount) or a docker-compose setup with a Postgres sidecar, run `pylon deploy --target docker` or `pylon deploy --target compose` in your project. Both commands generate a Dockerfile that copies your app onto the runtime image and serves it with `pylon start`. # Incident response Source: https://docs.pylonsync.com/operations/incident Reporting security issues, the first five minutes, common incidents, post-mortem template. ## Reporting **Security vulnerabilities**: email `security@pylonsync.com`. Do not file a public issue. Acknowledgment arrives within 48 hours. Target remediation is 7 days for high severity and 30 days for medium severity. **Operational incidents** (in your own deploy): follow your internal runbook. This page describes the general steps that apply to any incident. ## First five minutes 1. **Contain.** If you do not know how far the issue has spread, flip the relevant feature flag or route to maintenance mode. A 503 error is safer than a breach. 2. **Preserve.** Snapshot the database before any destructive recovery: ```sh theme={null} pylon backup /var/snapshots/incident-$(date +%Y%m%d-%H%M%S) ``` 3. **Gather evidence.** Pull logs, metrics, and the recent audit trail: ```sh theme={null} journalctl -u pylon --since "30 min ago" > /tmp/incident.log curl -s http://localhost:4321/metrics > /tmp/incident-metrics.txt ``` 4. **Declare.** Open an incident channel. One person owns coordination and one person drives fixes. Everyone else only observes. ## Common incidents ### Admin token leaked Follow [Admin token rotation → emergency path](/operations/token-rotation#emergency-suspected-compromise). Revoke every session, rotate the token, and audit the window of exposure. If you stored OAuth credentials as admin-level environment variables, rotate those too. ### Policy bypass / data exposure 1. Reproduce with a throwaway session token against staging. 2. Check `audit_log` for rows accessed during the window. In the EU, this triggers the GDPR notification requirement. 3. Patch the issue and ship the fix. Verify that the regression test in `crates/policy/src/lib.rs::tests` covers it. 4. If user data was exposed, start the breach notification workflow. ### Runaway write loop **Signal**: the write rate exceeds the 70k/sec ceiling, the WAL grows without bound, and disk space fills. 1. Identify the source. Check recent deploys and `audit_log` for the offending `user_id` or IP. 2. Rate-limit or block at the proxy. Do not try to fix this inside the server while it is under load. 3. Once the system is stable, investigate the trigger. A common cause is a client stuck in an exponential-backoff retry loop with no jitter. ### WAL file growth past disk budget SQLite in WAL mode delays checkpoints. If a checkpoint is not completing, run: ```sh theme={null} # Offline checkpoint; needs exclusive DB access. systemctl stop pylon sqlite3 /var/lib/pylon/pylon.db "PRAGMA wal_checkpoint(TRUNCATE)" systemctl start pylon ``` If the WAL keeps growing with no checkpoint catching up, a long-running read transaction is probably holding it open. Find it with `sqlite3 … ".pragma stats"` and kill the client holding the lock. ### WS fanout storm **Signal**: ws-broadcast-N threads at 100% CPU, clients reporting missed events, queue-full warnings in the log. 1. Check the client count. The per-IP cap is 64. If one IP reaches 64 connections, a client is stuck in a loop. 2. Check the broadcast rate. The event might come from a retry loop creating 1000 inserts/sec. 3. Restart the WS server (`kill -HUP <pid>`) only as a last resort. Every client must reconnect. ### Cloudflare Workers billing spike **Signal**: Cloudflare emails you that you have used 80% of your monthly budget in a week. See [Workers costs](/operations/workers-costs) for patterns. ### Magic-link emails not arriving 1. Check the spam folder. 2. Verify the email provider environment variables (`PYLON_EMAIL_*`). 3. Check the provider's dashboard. Your account may be paused for a high bounce rate. 4. Check `journalctl -u pylon | grep email` for delivery errors. 5. For domain authentication, verify SPF, DKIM, and DMARC records with [`mxtoolbox.com`](https://mxtoolbox.com). ### Sudden surge of 401 / 403 errors 1. Check whether a deploy went out recently. A regression in policy expressions can lock everyone out. 2. Check the session database integrity. If `sessions.db` is corrupted, every authenticated request fails. 3. If needed, restore the previous session database from backup. Sessions are recoverable; users only need to sign in again. ## Post-mortem template 1. **Summary:** one paragraph covering what happened, how long it lasted, and how far it reached. 2. **Timeline:** UTC timestamps from the first signal to the all-clear. 3. **Root cause:** what went wrong, not who caused it. 4. **What worked:** detection, containment, and communication. 5. **What did not work:** slow alerts, unclear runbooks, and missing graphs. 6. **Action items:** owners and target dates. Track them in the sprint. Publish internally within 5 business days. Follow your privacy policy for external disclosure of user-impacting incidents. ## On Pylon Cloud For Cloud workspaces, Pylon's on-call team is paged on infrastructure-level incidents (DB outage, region-wide failure, control-plane bugs). For app-level incidents (your code, your data), Cloud's dashboard surfaces logs and request traces but you own the response. Status: [status.pylonsync.com](https://status.pylonsync.com) shows region health and incident history. ## Contacts * Security: `security@pylonsync.com` * Public issues / feature requests: [github.com/pylonsync/pylon/issues](https://github.com/pylonsync/pylon/issues) * Cloud support: dashboard → Help → Contact * Your internal oncall: (fill in your rotation) # Horizontal scaling Source: https://docs.pylonsync.com/operations/scaling Running pylon across multiple machines without losing live updates. # Horizontal scaling Pylon is a single Rust binary. A single-machine deploy is the default setup: one machine serves HTTP, WebSocket, SSE, and job execution for the whole app. When traffic outgrows one machine, you scale horizontally by running `pylon` on multiple instances behind a load balancer. WebSocket broadcasts are in-process by default. A mutation handled by machine A fans out only to clients connected to machine A. Clients connected to machine B do not see it until their next reconnect, or until a visibility change triggers the client's `reconcile()` backstop. Live UX with sub-second propagation needs more than this. ## ClusterBus: cross-machine fanout Pylon ships a `ClusterBus` abstraction. Configure a transport, and every change event, presence relay, or CRDT frame published locally also publishes to the bus. Subscriber threads on every peer machine receive these and re-broadcast them to their own WebSocket and SSE clients. The default transport is `NoopBus`. Single-machine deploys pay zero overhead. Pylon provides two production transports: * Redis PUB/SUB for self-hosted deployments. * The PylonSync Durable Object relay for Pylon Cloud. Multi-machine deployments must use Postgres. SQLite cannot be shared safely by multiple Pylon processes. ```bash theme={null} DATABASE_URL=postgres://user:pass@postgres.internal:5432/my_app \ PYLON_CLUSTER_BUS=redis://default:pass@redis.internal:6379/0 \ PYLON_CLUSTER_NAMESPACE=my-app \ pylon start app.ts ``` Postgres stores the entities, sessions, global sync sequence, and persistent change log. Redis carries low-latency events between the running processes. Pylon Cloud uses the built-in relay. It does not add Redis to a customer project. The control plane gives each project a derived relay key. It never gives a customer machine the relay root key. `PYLON_CLUSTER_NAMESPACE` prefixes the Redis channel so multiple unrelated pylon deploys can share one Redis instance without cross-talk. It defaults to `pylon` if unset. **Connection failures at startup are fatal.** Pylon refuses to boot if `PYLON_CLUSTER_BUS` is set but unreachable. If it silently fell back to `NoopBus` on a multi-machine deploy, every machine would miss peer mutations. That produces the same "phantom row" UX failure the bus exists to fix, and it is harder to diagnose. A loud failure is the safer default. The managed relay applies backpressure when its publish queue is full. It retries a publish until the relay accepts it. Each publish has a stable message ID. The relay removes duplicate retries and keeps a bounded replay ring for WebSocket reconnects. ## What's fanned out * **Change events** (`ChangeEvent`): every mutation, action write, and entity-CRUD broadcast. * **Presence relays**: typing indicators, cursor positions, and any message sent through `WsHub::broadcast_presence`. * **CRDT binary frames**: Loro snapshots and updates for clients subscribed through `useLoroDoc`. Snapshot bytes are base64-encoded into the JSON envelope, so a single pubsub channel handles every payload shape. ## Shared sync state Postgres provides one global sequence for all changes. It also stores the persistent change log. Each process keeps a bounded in-memory ring for fast pulls and hydrates that ring from Postgres at startup. The cluster bus mirrors a peer event into each process's local ring. As a result, a write on machine A is available from `/api/sync/pull?since=N` on machine B. If the local ring does not cover the requested range, the pull reads the shared Postgres change log. The client-side `reconcile()` pass remains a safety check. On reconnect or a visibility change, it compares the local replica with the authoritative entity rows and removes stale rows. ## Shared jobs and workflows Postgres deployments use Postgres as the job queue and workflow store. Workers claim ready jobs with row locks and short leases. One replica executes a claim at a time. Another replica can claim the job after the lease expires if the worker stops or loses its machine. Workflow transitions use the same lease model. A lease token prevents a stale worker from replacing newer workflow state. Workflow steps and the workflow state commit in one database transaction. Jobs use at-least-once execution. A worker can finish an external side effect and then stop before it records completion. A later worker can run that job again. Job and workflow handlers must be idempotent. An idempotent handler gives the same result when it runs more than once. Schedules created inside a Postgres mutation commit in the same transaction as the application writes. A rollback removes both the writes and the scheduled job. During a rolling release, each replica claims only job names for which it has a local handler. An old replica does not consume work that only the new release can execute. SQLite keeps jobs and workflows on the local machine. SQLite deployments remain single-machine deployments. ## State that remains local * **Per-client policy filtering.** Each receiving machine re-runs the read policy before it forwards an inbound event to its connected WS and SSE clients. The bus carries raw events. The local fanout remains responsible for authorization. * **Rate-limit counters.** Limits apply per process. With `N` processes, the effective cluster limit is approximately `N` times the configured value. Adjust `PYLON_RATE_LIMIT_MAX` and `PYLON_RATE_LIMIT_MAX_AUTHED` for the number of processes. * **SSR output cache.** Each process has its own cache. Put a CDN in front of the load balancer when pages need a shared cache layer. ## Self-event filtering Pubsub backends deliver every published message to every subscriber, including the publisher itself. Without deduplication, this creates a feedback loop: A publishes, A's subscriber receives, A re-broadcasts, and the event (already shipped locally) is delivered twice. Every envelope carries the publisher's `instance_id` (one per pylon process, minted at startup). Each subscriber filters out events carrying its own id before it re-broadcasts them. Operators do not need to think about this; the filtering is invisible to them. ## Diagnostics Pylon logs the bus mode at startup: ``` [cluster] redis bus connected — channel="my-app:cluster:bus" instance_id=pylon-a3f9c1b2 [cluster] redis subscriber listening on channel "my-app:cluster:bus" ``` Or, for single-machine: ``` [cluster] PYLON_CLUSTER_BUS unset — running with single-machine fanout (NoopBus) ``` If subscriber reconnects happen (for example, the Redis primary cycled, or there was a network blip), you will see them in the logs too: ``` [cluster] redis subscriber connection ended: <error> [cluster] reconnecting redis subscriber in 4s [cluster] redis subscriber listening on channel "my-app:cluster:bus" ``` ## When to enable * Anytime you run more than one `pylon` process serving the same app. * Fly autoscale with `min_machines_running > 1`. * K8s deployments with `replicas > 1`. * Blue/green rollouts where two versions of the binary briefly serve traffic simultaneously. * Local multi-process dev simulating production. ## When to leave it disabled * Single-machine deploys. `NoopBus` is free; adding Redis only adds failure surface for no benefit. * Per-developer local dev. The `reconcile()` backstop covers the rare cases where two tabs need to see each other's writes without a real cluster bus. ## Pylon Cloud Pylon Cloud supports multiple application machines for Postgres projects. The control plane configures the managed Durable Object relay, copies the current image and inline files to each new replica, and fans deploys and secret updates to all replicas. It also disables autostop while more than one machine serves the project. SQLite projects stay on one application machine. Switch the project to Postgres before you request replicas. ## Backend choice Use Redis for self-hosted apps. Pylon Cloud uses the managed Durable Object relay. `ClusterBus` remains transport-agnostic, so other transports can be added without API changes for callers. # Sizing Source: https://docs.pylonsync.com/operations/sizing Measured throughput numbers, capacity planning, and when to switch from SQLite to Postgres. Numbers are per-process throughput. SQLite is single-writer, so vertical scaling of writes is bounded. Reads scale across a connection pool. <Note> The values below were measured on earlier reference hardware (a 2024 Apple M-series laptop, 16 GB RAM). They are pending re-measurement on the current reference machine (2025 Mac Studio M3 Ultra, 96 GB). Treat them as a lower bound until refreshed. </Note> Re-run with: ```sh theme={null} cargo bench -p pylon-runtime --bench bench cargo bench -p pylon-runtime --bench realtime_bench ``` ## Data plane (single-writer SQLite) | Operation | Ops/sec | Per op | | ------------------------------------ | ------- | ------ | | `insert` (User, 3 fields) | 68,000 | 14.6µs | | `insert` (Todo, 4 fields) | 77,000 | 13.0µs | | `update` | 89,000 | 11.2µs | | `delete` + reinsert | 40,000 | 24.7µs | | `get_by_id` | 519,000 | 1.9µs | | `lookup` by unique field | 484,000 | 2.1µs | | `query_filtered` (equality) | 24,000 | 40.8µs | | `query_filtered` (\$like) | 10,000 | 96.9µs | | `list` (1000 rows) | 2,700 | 363µs | | `query_graph` (no filter, 1000 rows) | 1,500 | 660µs | ## Realtime path | Operation | Ops/sec | Per op | | ---------------------------- | ------- | ------ | | `change_log.append` | 5M | 198ns | | `change_log.pull(100)` | 85,000 | 11.7µs | | `ws_hub.broadcast` (enqueue) | 30,000 | 32.5µs | The WS hub `broadcast` number measures the enqueue side. It fans out to 16 shard worker threads, and each thread pushes to its connected clients. The real delivery rate depends on client count, message size, and TCP send buffers. ## What these numbers mean for deploy sizing ### Small (1 vCPU, 1 GB RAM, \~\$5/mo VPS) * Up to \~20k writes/minute sustained, or bursts to 30k/minute * Up to \~10k concurrent WS connections (64 KB stack per reader thread) * Good for a few thousand active users at typical web app request rates ### Medium (2 vCPU, 4 GB RAM, \~\$25/mo VPS) * Up to \~50k writes/minute sustained * Up to \~40k concurrent WS connections * Good for 50k active users, with room for complex queries without cache eviction ### Large (4+ vCPU, 8+ GB RAM) * The write ceiling is still single-writer SQLite (about 70k inserts/sec peak). If writes are your bottleneck, move to Postgres (`postgres-live` feature) or shard the app across databases. * Reads scale with the read-connection pool. 4 pool connections × 500k reads/sec = 2M reads/sec ceiling. ## When to switch backends ### Switch from SQLite to Postgres when * Sustained write rate > 50k/sec (you are at SQLite's single-writer limit) * Multiple processes need to write (replicas, HA failover) * You need online DDL / zero-downtime migrations at scale * Storage > 100 GB (not a hard limit, but WAL checkpoints get painful) ### You can stay on SQLite when * Single-process deployment * Full DB fits comfortably in RAM for the read pool * You back up with `pylon backup` on a schedule ## On Pylon Cloud Cloud supports multiple application machines for Postgres projects. It uses the managed PylonSync relay for cross-machine realtime events. SQLite projects must stay on one application machine. Resize the machine or switch to Postgres before you add replicas. For multiplayer apps with sticky shard connections, latency to your players matters. Set the workspace region accordingly when you sign up. ## Benchmark gaps * Multi-client read contention (connection-pool fair-share) * TLS handshake cost (reverse proxy terminates TLS) * Network round-trip time: production numbers will be bounded by the network first * Shard tick budget for realtime game state: depends on `SimState::tick` For a real capacity estimate under your workload, run `cargo bench -p pylon-runtime` (see the commands at the top of this page) against a representative fixture and the manifest you will ship with. # Deploy with SST Source: https://docs.pylonsync.com/operations/sst Define a stateless Pylon deployment on AWS with Fargate, Aurora Postgres, S3, ALB, and CloudFront. [SST](https://sst.dev) is a TypeScript framework for defining AWS (and other cloud) infrastructure. It is the cleanest way to deploy Pylon to AWS without writing Terraform. One `sst.config.ts` provisions Aurora Postgres, an S3 bucket for uploads, an ECS Fargate cluster, and a CloudFront CDN. The load balancer forwards WebSocket, SSE, and shard ports. The default shape is stateless. Postgres holds app data and sessions, and S3 holds files. The container can scale horizontally without losing state. A working reference config ships in [`deploy/sst/sst.config.ts`](https://github.com/pylonsync/pylon/blob/main/deploy/sst/sst.config.ts). ## What you get * **Aurora Serverless v2 Postgres** for app data and sessions (auto-scaling 0.5–2 ACU, \~\$15/mo minimum) * **S3 bucket** for file uploads (linked to the service for IAM) * **ECS Fargate** running the Pylon container (0.25 vCPU / 512 MB \~ \$9/mo, horizontally scalable) * **Application Load Balancer** with WebSocket, SSE, and shard port forwarding, and sticky sessions * **AWS Secrets Manager** for the admin token and OAuth credentials * **CloudFront CDN** in front of the ALB * **Route 53 and ACM** for custom domains and TLS Total minimum bill: \~\$25/mo for a production deployment. ## Prerequisites ```bash theme={null} # 1. SST CLI (v3 / Ion) curl -fsSL https://ion.sst.dev/install | bash # 2. AWS CLI configured aws configure # or AWS_PROFILE / SSO # 3. Pylon CLI for local dev curl -fsSL https://www.pylonsync.com/install.sh | bash # 4. Docker (for local Postgres in dev) # Already installed if you've used Docker Desktop / OrbStack ``` You will need an AWS account with permissions to create VPCs, ECS services, RDS, ALB, ACM, Route 53, S3, and Secrets Manager. SST's default IAM role assumes broad permissions. Lock it down to match your organization's standards. ## Project layout ``` my-pylon-app/ ├── app.ts # Pylon schema + manifest entry ├── pylon.manifest.json # generated ├── functions/ # server functions ├── Dockerfile # Pylon's published Dockerfile or your own ├── docker-compose.dev.yml # local Postgres for dev ├── sst.config.ts # SST infrastructure definition └── package.json ``` <Note> The container image must bundle the Pylon binary and Bun. The binary spawns Bun to run your TypeScript functions and SSR. Pylon's published image (`ghcr.io/pylonsync/pylon`, which `pylon deploy --target docker` builds on) already includes both. If you write your own Dockerfile, install Bun (`curl -fsSL https://bun.sh/install | bash`), or your app's functions and SSR will fail at runtime. </Note> ## The default config: Aurora and S3 ```typescript theme={null} /// <reference path="./.sst/platform/config.d.ts" /> export default $config({ app(input) { return { name: "my-pylon-app", removal: input?.stage === "production" ? "retain" : "remove", home: "aws", providers: { aws: { region: "us-east-1" } }, }; }, async run() { // ── Secrets ────────────────────────────────────────────────── const adminToken = new sst.Secret("PylonAdminToken"); const oauthGoogle = new sst.Secret("OAuthGoogleClientSecret"); const oauthGithub = new sst.Secret("OAuthGithubClientSecret"); // ── Database ───────────────────────────────────────────────── // Aurora Serverless v2 — auto-scales 0.5 → 2 ACU. Holds app data // AND sessions; the container is stateless. const db = new sst.aws.Postgres("PylonDb", { scaling: { min: "0.5 ACU", max: "2 ACU" }, }); // ── File storage ───────────────────────────────────────────── // Pylon's S3 backend signs requests with static credentials read // from env — it does NOT auto-discover the ECS task role — so // provision a bucket-scoped IAM user and hand the service its key. const uploads = new sst.aws.Bucket("PylonUploads"); const s3User = new aws.iam.User("PylonS3User"); new aws.iam.UserPolicy("PylonS3UserPolicy", { user: s3User.name, policy: uploads.arn.apply((arn) => JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Action: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], Resource: `${arn}/*`, }, ], }), ), }); const s3Key = new aws.iam.AccessKey("PylonS3Key", { user: s3User.name }); // ── Cluster + service ──────────────────────────────────────── const cluster = new sst.aws.Cluster("PylonCluster", { vpc: { id: db.nodes.vpc.id }, }); new sst.aws.Service("PylonService", { cluster, cpu: "0.25 vCPU", memory: "512 MB", image: { dockerfile: "./Dockerfile" }, health: { command: ["CMD-SHELL", "curl -fsS http://localhost:4321/health || exit 1"], interval: "30 seconds" }, // Stateless container — safe to scale horizontally. scaling: { min: 1, max: 4, cpuUtilization: 70 }, link: [uploads], environment: { // Core DATABASE_URL: db.url, PYLON_PORT: "4321", PYLON_DEV_MODE: "false", // File storage — pylon's built-in S3 backend (SigV4 presigned URLs). PYLON_FILES_PROVIDER: "s3", PYLON_S3_BUCKET: uploads.name, PYLON_S3_REGION: "us-east-1", PYLON_S3_ACCESS_KEY: s3Key.id, PYLON_S3_SECRET_KEY: s3Key.secret, // Auth (secrets) PYLON_ADMIN_TOKEN: adminToken.value, // Client-facing PYLON_CORS_ORIGIN: "https://your-app.com", PYLON_CSRF_ORIGINS: "https://your-app.com", // OAuth (optional) PYLON_OAUTH_GOOGLE_CLIENT_ID: "your-google-client-id", PYLON_OAUTH_GOOGLE_CLIENT_SECRET: oauthGoogle.value, PYLON_OAUTH_GITHUB_CLIENT_ID: "your-github-client-id", PYLON_OAUTH_GITHUB_CLIENT_SECRET: oauthGithub.value, }, loadBalancer: { // ALB rules forward all four Pylon ports: // 4321 → HTTP API // 4322 → WebSocket sync // 4323 → SSE fallback (/events) // 4324 → realtime shards rules: [ { listen: "443/https", forward: "4321/http" }, { listen: "4322/tcp", forward: "4322/tcp" }, { listen: "4323/tcp", forward: "4323/tcp" }, { listen: "4324/tcp", forward: "4324/tcp" }, ], // ACM cert provisioned automatically when domain is set: domain: { name: "api.your-app.com", dns: sst.aws.dns() }, idleTimeout: "3600 seconds", // Required when scaling.max > 1: keep WebSockets pinned to one // replica so reconnects don't lose presence state. stickySessions: true, }, }); }, }); ``` <Note> **Credentials are read from environment variables only.** Pylon's S3 backend signs requests with `PYLON_S3_ACCESS_KEY` and `PYLON_S3_SECRET_KEY` (plus an optional `PYLON_S3_SESSION_TOKEN` for temporary or STS credentials). It does not auto-discover the ECS task role from the container credentials endpoint or EC2 IMDS, so `link: [uploads]` alone is not enough. Provision a bucket-scoped IAM user's static key as shown above, or inject the task role's temporary credentials (access key, secret, and session token) into the environment through your entrypoint. The server validates the S3 environment at boot and fails fast if `PYLON_FILES_PROVIDER=s3` but a required variable is missing. It never silently falls back to local disk, which on a stateless Fargate container would lose uploads on every redeploy. </Note> ## Local development Mirror the production stack locally so SQL queries, indexes, and policies behave the same way. The cheapest setup is Postgres in Docker plus local-disk file storage. You do not need MinIO for dev unless your code exercises S3-specific behavior. `docker-compose.dev.yml`: ```yaml theme={null} services: db: image: postgres:16-alpine ports: ["5432:5432"] environment: POSTGRES_USER: pylon POSTGRES_PASSWORD: pylon POSTGRES_DB: pylon volumes: - pylon-db:/var/lib/postgresql/data volumes: pylon-db: ``` Boot it once: ```bash theme={null} docker compose -f docker-compose.dev.yml up -d ``` Run Pylon against it: ```bash theme={null} DATABASE_URL=postgres://pylon:pylon@localhost:5432/pylon pylon dev ``` A `.env` file at the project root keeps this out of your shell history: ```env theme={null} DATABASE_URL=postgres://pylon:pylon@localhost:5432/pylon PYLON_FILES_DIR=./.pylon/uploads ``` `pylon dev` reads `.env` automatically. Restart the dev server when you change it. To also test against S3 locally, point `PYLON_FILES_PROVIDER=s3` at [MinIO](https://min.io) running in the same docker-compose: ```yaml theme={null} services: # ...db service above... minio: image: minio/minio:latest command: server /data --console-address ":9001" ports: ["9000:9000", "9001:9001"] environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ``` ```env theme={null} PYLON_FILES_PROVIDER=s3 PYLON_S3_BUCKET=pylon-uploads PYLON_S3_ENDPOINT=http://localhost:9000 PYLON_S3_ACCESS_KEY=minioadmin PYLON_S3_SECRET_KEY=minioadmin ``` The `file_storage` plugin treats MinIO as a drop-in S3 replacement. It uses the same wire protocol. ## Set secrets Before the first deploy, set the secret values with the SST CLI: ```bash theme={null} # Generate a random admin token (256 bits) openssl rand -hex 32 | xargs sst secret set PylonAdminToken # OAuth secrets from Google Cloud Console / GitHub OAuth Apps sst secret set OAuthGoogleClientSecret sst secret set OAuthGithubClientSecret ``` Secrets are stored in AWS Parameter Store, encrypted with a KMS key SST creates per-app. ## Deploy ```bash theme={null} # First time — provisions everything (~10 minutes for Aurora cold start) sst deploy --stage production # Subsequent deploys (only the container changes) — `<2 min` sst deploy --stage production ``` Once the deploy finishes, SST prints the ALB URL. Test it: ```bash theme={null} curl https://api.your-app.com/health # → {"status":"ok","uptime_seconds":42} ``` ## Custom domain The `domain` block under `loadBalancer` provisions an ACM certificate and points Route 53 at the ALB. If you use a different DNS provider, swap the `dns` provider: ```typescript theme={null} domain: { name: "api.your-app.com", dns: sst.cloudflare.dns(), // or sst.vercel.dns() } ``` ## Multiple environments ```bash theme={null} sst deploy --stage staging # → staging.your-app.com sst deploy --stage production # → api.your-app.com sst deploy --stage pr-42 # → pr-42.preview.your-app.com (per-PR previews) ``` Each stage gets its own Aurora cluster, S3 bucket, and ALB. Use the `removal` config so non-production environments tear down cleanly: ```typescript theme={null} app(input) { return { removal: input?.stage === "production" ? "retain" : "remove", // ... }; } ``` ## Alternative: single-replica with local state (EFS and SQLite) If you want to run Pylon as a single replica with SQLite and local files (for example, an internal tool, a hobby app, or a hard requirement to avoid Postgres and S3), mount EFS instead of using Aurora and S3: ```typescript theme={null} const efs = new sst.aws.Efs("PylonEfs", { vpc: { id: cluster.nodes.vpc.id } }); new sst.aws.Service("PylonService", { cluster, cpu: "0.25 vCPU", memory: "512 MB", image: { dockerfile: "./Dockerfile" }, scaling: { min: 1, max: 1 }, // single replica only volumes: [{ efs, path: "/var/lib/pylon" }], environment: { PYLON_DB_PATH: "/var/lib/pylon/pylon.db", PYLON_SESSION_DB: "/var/lib/pylon/sessions.db", PYLON_FILES_DIR: "/var/lib/pylon/uploads", // no DATABASE_URL → uses SQLite at PYLON_DB_PATH }, }); ``` Trade-offs: * ✅ About \$10/mo cheaper (no Aurora minimum) * ✅ One backing service to think about * ❌ Cannot scale horizontally: SQLite is single-writer, and mounting EFS into multiple containers corrupts the database * ❌ EFS latency (about 5ms) is noticeably slower than Aurora (about 1ms in-VPC) * ❌ Backups are your responsibility (Aurora has them built in) Use the default Aurora and S3 shape unless you have a specific reason not to. ## CDN in front of the ALB For static-asset caching and global edge presence: ```typescript theme={null} const cdn = new sst.aws.Cdn("PylonCdn", { origins: [{ domainName: service.url }], domain: { name: "your-app.com", dns: sst.aws.dns() }, }); ``` CloudFront caches GET responses with appropriate `Cache-Control` headers. WebSocket and SSE traffic should bypass the CDN. Point your sync engine's `wsUrl` directly at the ALB: ```typescript theme={null} createSyncEngine("https://your-app.com", { wsUrl: "wss://api.your-app.com:4322", }); ``` ## Horizontal scale The default config already scales horizontally (`scaling: { min: 1, max: 4 }`). Increase it for read-heavy apps: ```typescript theme={null} scaling: { min: 2, max: 20, cpuUtilization: 70, } ``` Requirements for a correct multi-replica deploy: 1. **Postgres backend** ✅: `DATABASE_URL` is set, with no SQLite. The default config satisfies this requirement. 2. **Externalized file storage** ⚠️: the default config points at S3, which is not wired yet (see the warning under *The default config*). Use `PYLON_FILES_PROVIDER=stack0` for externalized files, or keep a single replica on the EFS and SQLite shape. 3. **Sticky WebSocket sessions** ✅: `stickySessions: true` on the load balancer. The default config satisfies this requirement. 4. **Cross-replica live fanout** ❌: WebSocket broadcasts are in-process by default, so a mutation handled by replica A never reaches clients on replica B until their next `reconcile()`. The default config does not address this. Set the cluster bus before scaling past one replica. Jobs and workflows need no Redis. They use the shared Aurora database and can fail over between replicas. Their handlers must be idempotent because execution is at least once. For cross-replica fanout, run ElastiCache Redis and point every replica at it with `PYLON_CLUSTER_BUS`. Pylon refuses to boot if the bus is set but unreachable (loud failure beats silent split-brain). See [Horizontal scaling](/operations/scaling). ```typescript theme={null} const redis = new sst.aws.Redis("PylonBus", { vpc: { id: db.nodes.vpc.id } }); new sst.aws.Service("PylonService", { // ... link: [uploads, redis], environment: { // ... PYLON_CLUSTER_BUS: redis.url, // redis://… — cross-machine change/presence/CRDT fanout PYLON_CLUSTER_NAMESPACE: "my-pylon-app", }, }); ``` For shard-based multiplayer, prefer dedicated single-replica services per region. Shards do not scale horizontally within a region. ## Observability ```typescript theme={null} new sst.aws.Service("PylonService", { // ... logging: { retention: "30 days", }, }); ``` Logs ship to CloudWatch automatically. For richer observability: * **Metrics**: Pylon exposes `/metrics` in Prometheus format. Scrape with [AWS Managed Prometheus](https://aws.amazon.com/prometheus/) or [Grafana Cloud](https://grafana.com/products/cloud/). * **Traces**: add OpenTelemetry exporter environment variables, and SST injects the right IAM permissions for X-Ray. ## Compared to Pylon Cloud If you are not committed to AWS, [Pylon Cloud](/cloud) gives you the same managed Postgres, S3, TLS, and WebSocket-aware load balancing with one CLI command (`pylon deploy --target cloud`) and per-use pricing. SST is the right choice when: * You must run in AWS (compliance, existing infrastructure, reserved instance commitments) * You want to compose Pylon with other AWS services (Bedrock, SageMaker, IoT Core, Kinesis) * You want full infrastructure-as-code control over networking, IAM, and secrets Both are valid. Pylon's wire protocol is identical regardless of how you host it. ## Troubleshooting **WebSocket connections fail with 504.** The ALB idle timeout defaults to 60s. The default config bumps it to 1 hour with `idleTimeout: "3600 seconds"`. **Cold start takes 10+ minutes the first time.** Aurora Serverless v2's first cold start provisions storage. Subsequent deploys take under 2 minutes. **Uploads do not land in S3.** This is expected: the runtime's storage selector does not wire the S3 backend yet (see the warning under *The default config*), so `PYLON_FILES_PROVIDER=s3` writes to local container disk. Use `PYLON_FILES_PROVIDER=stack0` for externalized storage, or use the single-replica EFS and SQLite shape instead. (The `link: [uploads]` IAM grant is still correct for when S3 support lands.) **Local dev errors with `connection refused`.** Postgres is not running. `docker compose -f docker-compose.dev.yml ps` should show the `db` service as `Up`. If it does not, run `docker compose up -d db`. **Local dev sessions disappear on restart.** Sessions are stored in Postgres now (the same `DATABASE_URL`), so they persist as long as the Postgres volume does. If you run `docker compose down -v` (note the `-v` flag), the volume is destroyed and sessions clear. **Secrets are not visible in the container.** Make sure you ran `sst secret set` in the same stage you are deploying to. Secrets are set per stage. ## Cost optimization | Component | Default cost | Optimization | | ---------------------------------- | ---------------- | ---------------------------------------------------------------- | | Aurora Serverless v2 (0.5 ACU min) | \~\$15/mo | Drop `min` to `0.5 ACU` and let it scale up only on load | | Fargate (0.25 vCPU / 512 MB) | \~\$9/mo | Use Fargate Spot for non-prod | | ALB | \~\$16/mo | Single ALB for all stages via shared listeners | | S3 (bucket + traffic) | \~\$0–5/mo | Lifecycle policies for old uploads; CloudFront in front to cache | | NAT Gateway | \~\$32/mo | Skip the NAT (single AZ) for non-prod; use VPC endpoints in prod | | CloudWatch logs | \~\$0.50/GB | Set `retention: "7 days"` for non-prod | | Secrets Manager | \$0.40/secret/mo | Few secrets; minor cost | A bare-bones non-production stack runs about $30/mo. Production with NAT and multi-AZ runs about $80 to \$150/mo, depending on traffic. For low-traffic apps, [Pylon Cloud](/cloud) is often cheaper than the AWS minimums. ## Reference config The full working config is at [`deploy/sst/sst.config.ts`](https://github.com/pylonsync/pylon/blob/main/deploy/sst/sst.config.ts). Clone it as a starting point: ```bash theme={null} cp deploy/sst/sst.config.ts your-app/sst.config.ts cd your-app sst secret set PylonAdminToken sst deploy --stage production ``` # Sync relay (Durable Objects) Source: https://docs.pylonsync.com/operations/sync-relay Move live sync fan-out off your machine onto a Cloudflare Durable Object, so sockets survive deploys and idle machines can sleep. The sync relay is an optional delivery tier for change events. Your Pylon machine keeps everything it handles today: the change log, the `seq` counter, mutations, pulls, and auth. Only the live WebSocket fan-out moves. A `PylonSync` Durable Object holds the client sockets (using Cloudflare's WebSocket hibernation), keeps a bounded ring of recent events, and filters every frame per subscriber with the same policy engine the machine uses. This gives you: * **Sockets survive deploys and restarts.** Clients stay connected to the DO while the machine restarts. When the machine comes back and pushes updates, clients see a catch-up instead of a disconnect. * **Idle machines can sleep.** Live sockets no longer keep the machine awake. The DO serves reconnect catch-up from its ring without waking the origin. * **Fan-out CPU leaves the machine.** Per-subscriber filtering and broadcast run at the edge. The machine's own WS keeps working unchanged. The relay is additive (dual-write). Roll it out by attaching the sink first, comparing results, then pointing clients at the relay. ## Server setup Deploy the relay worker with its own config, `crates/workers/wrangler.relay.toml`. That config declares only the `PylonSync` Durable Object plus the shared secret. It is not the full `wrangler.toml`, which also wires D1, KV, and R2 to run all of Pylon on Workers. ```bash theme={null} cd crates/workers wrangler deploy --config wrangler.relay.toml printf '%s' "$(openssl rand -hex 32)" | \ wrangler secret put PYLON_RELAY_SECRET --config wrangler.relay.toml ``` The worker's build pins `worker-build@0.1.2` (the release that matches the `worker` 0.5 crate the DO targets). The Durable Object uses SQLite-backed storage (`new_sqlite_classes`), which works on the Workers free plan and is Cloudflare's default for new namespaces. Verify the deploy with the smoke script. It checks routing and auth-gating without needing the secret: ```bash theme={null} crates/workers/scripts/relay-smoke.sh https://pylon-sync-relay.<subdomain>.workers.dev ``` Point the machine at it: ```bash theme={null} PYLON_SYNC_RELAY_URL=https://your-worker.workers.dev PYLON_SYNC_RELAY_SECRET=<same value as PYLON_RELAY_SECRET> # optional: PYLON_SYNC_RELAY_APP=<app id> # default: PYLON_PROJECT_ID, then the manifest name PYLON_SYNC_RELAY_PUBLIC_WS_URL=<wss url> # default: derived from the relay URL PYLON_SYNC_RELAY_TOKEN_TTL_SECS=900 # relay auth blob lifetime ``` With both set, the machine does three things: * **Pushes its manifest to the DO at boot.** The DO compiles its `PolicyEngine` from the manifest. Until the manifest arrives, the DO delivers nothing (it fails closed). * **Pushes every committed change event.** Each push is batched, HMAC-signed, and fire-and-forget with one retry. The disk log stays the source of truth, so a dropped push degrades to a catch-up read, never data loss. * **Serves `GET /api/sync/relay-token`.** It authenticates the caller, enriches org roles, and mints a signed auth blob that the DO's filter runs against. ## Client setup ```ts theme={null} init({ baseUrl, relay: true }); ``` That is the whole client change (TS and Swift both take `relay: true`). Before each socket attempt, the engine fetches a fresh token from `/api/sync/relay-token` and dials the relay with that token and its current cursor. The DO replays the ring tail past that cursor before sending live frames. Everything else (pull, push, mutations, auth) still talks to `baseUrl`. ## Security model * The subscriber's identity travels as a machine-minted, HMAC-signed blob carrying the enriched auth context (user, roles, active org). The DO verifies the signature with the shared secret and never touches a database. Tampering with the blob, or its expiry, fails verification. * Filtering matches the machine's WS hub decision for decision: * policy evaluated on the raw row * `serverOnly` and `syncOmit` fields stripped before the wire * visibility flips synthesized as delete tombstones * unscoped admin bypass * default-deny for entities with no policy * Blobs expire (15 minutes by default). The DO closes expired sockets with code `4401`. The client then re-handshakes against the machine, which is how a revoked role takes effect. * Machine-to-DO pushes are HMAC-signed with a timestamp (±5 min replay window). ## Limits * **Change events only.** Rooms, CRDT row subscriptions, and reactive queries use the machine's own WS. Apps that use those features keep the direct connection. * **`exists(...)` read policies deny at the relay.** The DO has no database to resolve them against, so it fails closed. Apps whose synced entities use `exists()` in read policies are not relay-eligible yet. * **Deep history stays on the machine.** A reconnect cursor older than the DO's ring closes with code `4410`. The engine's normal `/api/sync/pull` catch-up (which runs on every reconnect anyway) covers the gap. * Role changes made mid-connection take effect when the blob expires, not immediately. ## Design The full design is in `docs/SYNC_DURABLE_OBJECTS_DESIGN.md` in the repo. It covers why the machine keeps `seq` and the change log, why the DO owns delivery but never truth, and the rejected alternatives. # Admin token rotation Source: https://docs.pylonsync.com/operations/token-rotation Rotate PYLON_ADMIN_TOKEN without downtime, or in an emergency after suspected compromise. `PYLON_ADMIN_TOKEN` authenticates every privileged route: * `/api/auth/session` POST in non-dev * `/api/auth/upgrade` in non-dev * `/api/admin/users/:id/export` (GDPR export) * `/api/admin/users/:id/purge` (GDPR delete) * `/api/sync/push` from admin contexts * Jobs / workflows / scheduler control planes * `/studio` in non-dev Treat the token like an SSH key: * minimum 32 bytes of randomness * never commit it to git * rotate it on any suspicion of compromise ## Without downtime (two-token rotation) The server only reads `PYLON_ADMIN_TOKEN` at startup. Rotation requires a restart. To do it without dropping traffic: 1. **Prepare**. Generate the new token: ```sh theme={null} openssl rand -hex 32 > /etc/pylon/admin_token.new ``` 2. **Deploy side-by-side**. Start a new instance with the new token and let the load balancer health check promote it. Stop routing traffic to the old instance, then let your load balancer's connection drain finish so mid-request admin calls complete before you send `SIGTERM`. (SIGTERM itself is not graceful; see [Deploy → Shutdown and rolling deploys](/operations/deploy#shutdown-and-rolling-deploys). The drain must happen at the load balancer.) 3. **Update clients**. Update any automation (CI, runbooks, cron, admin UIs) that hardcodes the old token. Search Vault, 1Password, GitHub Actions secrets, Cloudflare environment variables, and other secret stores for the old token prefix before you delete the value. 4. **Verify and clean up**. Test one admin endpoint with the new token. If it works, delete the old token from your secret store. ## Emergency (suspected compromise) 1. **Generate a new token.** Skip the no-downtime rotation process. In an emergency, the delay is not worth the risk: ```sh theme={null} openssl rand -hex 32 > /etc/pylon/admin_token.new ``` 2. **Revoke every active session and force re-login.** There is no global session-purge endpoint. Invalidate all sessions by clearing the session database and restarting. Every user signs back in on their next request. Sessions are recoverable; only the login is lost: ```sh theme={null} systemctl stop pylon rm /var/lib/pylon/sessions.db* systemctl start pylon ``` 3. **Rotate OAuth secrets too.** They share the same exposure if the admin account was used to configure them. 4. **Audit `audit_log`** for the period the old token was valid. The [`audit_log` plugin](/plugins/integrations) records who did what and when. 5. **File an incident report** per [`SECURITY.md`](https://github.com/pylonsync/pylon/blob/main/SECURITY.md). ## On Pylon Cloud Admin tokens are scoped per workspace and managed through the dashboard. Rotation is a one-click operation with no downtime; Cloud handles the side-by-side restart for you. Old tokens stop working immediately on rotation. ## Avoid * **Do not use the admin token as a session token.** Reserve it as a break-glass credential for emergencies. * **Do not commit the token to git, even in a test fixture.** The pre-commit hook rejects 32+ character hex strings in tracked files. * **Do not pass it as a URL query parameter.** Use `Authorization: Bearer` only. URL parameters leak into proxy logs and browser history. * **Do not reuse the token across environments.** A staging token and a production token must differ. ## Rotation cadence | Risk profile | Rotation frequency | | --------------------------------- | ---------------------------- | | Low-traffic side project | Yearly | | Production user-facing app | Quarterly | | Compliance-required (SOC2, HIPAA) | Per your control framework | | Suspected compromise | Immediately (emergency path) | Set a calendar reminder. Add it to your runbook. # Deploying to Vercel Source: https://docs.pylonsync.com/operations/vercel Deploy a Next.js and Pylon app to Vercel with the required env, cookie, and CORS settings. One supported production shape puts the Next.js frontend on Vercel and the Pylon backend on Pylon Cloud (Fly Machines internally). This guide takes you from a scaffolded Next.js and Pylon monorepo to a live production URL. If you do not need a separate Next.js app, Pylon's default scaffold serves server-rendered React and the API from one port (see [Deploy](/operations/deploy)), and you can skip Vercel entirely. If you have not scaffolded yet, generate the Next.js and Pylon monorepo: ```bash theme={null} pylon init my-app --frontend nextjs cd my-app bun install && bun dev ``` That gives you `apps/api` (Pylon backend) and `apps/web` (Next.js frontend), ready to deploy. `apps/web/next.config.js` already proxies `/api/*` to the backend on `:4321`. ## 1. Deploy the backend to Pylon Cloud Sign in at [www.pylonsync.com](https://www.pylonsync.com), create an organization, and connect the GitHub repo. The setup wizard provisions a Fly Machine, points it at `apps/api/app.ts`, and gives you a default hostname: ``` https://pylon-<your-project-slug>.fly.dev ``` Push to the branch you configured (default: `main`), and Pylon Cloud deploys it automatically. You can also add a custom domain (`api.example.com`) in the project's **Domains** tab. We recommend this once you go live, because it keeps the URL stable across project renames. ## 2. Deploy the frontend to Vercel Create a Vercel project pointed at the same GitHub repo. In **Project Settings → Build & Development Settings**: * **Framework Preset**: Next.js (auto-detected) * **Root Directory**: `apps/web` * **Install Command**: `bun install` (or your package manager) * **Build Command**: `next build` Vercel picks up `apps/web/next.config.js` automatically. ## 3. Set the backend URL In **Project Settings → Environment Variables**, add: | Name | Value | Environments | | -------------- | ----------------------------------- | ------------------- | | `PYLON_TARGET` | `https://pylon-<your-slug>.fly.dev` | Production, Preview | The Next.js template's `next.config.js` rewrites `/api/*` to `${PYLON_TARGET}/api/*`, so the browser always talks same-origin to Vercel, and Vercel forwards the request to your Pylon backend. This is the recommended pattern: it avoids CORS preflight requests and lets the session cookie work without cross-site cookie settings. For local dev (`bun run dev`), `PYLON_TARGET` defaults to `http://localhost:4321` so no `.env.local` is required when your backend is `pylon dev` on the same machine. <Tip> If you would rather skip the rewrite and have the browser talk to your Pylon backend directly, set `Domain=.example.com` on the session cookie, and set `allowed-origins` in your Pylon manifest to your Vercel hostname. See the [cookie checklist](#cookie-checklist) below. </Tip> ## 4. Configure CORS (only if you skipped the rewrite) If your Next.js rewrite proxies `/api/*`, you do not need CORS. The browser only sees the Vercel origin. If your frontend talks to Pylon Cloud directly (`fetch("https://api.example.com/api/fn/...")` from the browser), open your project's **Settings → CORS** tab on Pylon Cloud and add your Vercel deployment URLs: ``` https://my-app.vercel.app https://my-app-git-main.vercel.app https://*.my-app.vercel.app ``` Vercel mints a unique preview URL per deployment, so use the wildcard pattern for the preview environment. ## 5. Push and verify ```bash theme={null} git push origin main ``` * **Vercel build**: takes about 30 seconds. Watch the deploy log for the build step. * **Pylon Cloud deploy**: runs in parallel. Check the project's **Deployments** tab. Once both finish, open your Vercel URL. You should see your app, and the Network tab should show `/api/*` requests succeeding (status 200, `Set-Cookie` header on auth endpoints). ## Cookie checklist If session cookies are not sticking, work through this list: * **Cookie name matches.** Pylon emits `${app_name}_session`. Your `createPylonServer({ cookieName })` and `createPylonProxy({ cookieName })` must pass the same name. A mismatch causes silent 401 errors. * **Same-site defaults to Lax.** Pylon defaults to `SameSite=Lax`, which works with Next.js's same-origin pattern. If you must use cross-site cookies (skipping the rewrite), set `PYLON_COOKIE_SAME_SITE=None` and `PYLON_COOKIE_SECURE=true`. * **Domain matches.** If the frontend is `app.example.com` and the backend is `api.example.com`, set `PYLON_COOKIE_DOMAIN=.example.com` so the cookie applies to both subdomains. Do not set this if you are using the rewrite pattern; host-only cookies are tighter. * **iOS Chrome and Safari.** iOS browsers reject domain-scoped cookies when a host-only cookie of the same name is present. Pylon v0.3.140+ handles this by pairing every domain-scoped set with a host-only `Max-Age=0` clear. Make sure your Pylon backend is on a recent framework version. ## Custom domain end-to-end ``` api.example.com → Pylon Cloud (custom domain on the project) app.example.com → Vercel (custom domain on the Vercel project) ``` With this layout you set: * Pylon Cloud project domains: `api.example.com` * Vercel project domains: `app.example.com` * Vercel env var: `PYLON_TARGET=https://api.example.com` * Your `next.config.js` rewrite continues to forward `/api/*` to `${PYLON_TARGET}/api/*` No CORS configuration is needed. The browser only ever talks to `app.example.com`. ## Troubleshooting **Build fails: "Module not found: @pylonsync/next".** You are probably building from the repo root instead of `apps/web`. Set Vercel's **Root Directory** to `apps/web`. **`Too many requests. Limit: 100 per 60s` from the dashboard.** Pylon defaults to 100 requests/min per authenticated user. Raise it with `PYLON_RATE_LIMIT_MAX_AUTHED` (default 1000 since v0.3.148) on your Pylon Cloud project. **`PYLON_BACKEND_UNREACHABLE` errors in Next.js logs.** Your Pylon Cloud machine is stopped (autostop after idle) or restarting. The server helpers time out after 5s, and an `error.tsx` boundary makes this graceful. Restart the machine from the project's **Overview** tab. **Live queries do not update across tabs.** The sync engine WebSocket may be misrouted. Check that `PYLON_TARGET` resolves, and that your browser's WebSocket connection (DevTools → Network → WS) connects to `wss://<your-target>/api/sync` successfully. ## Next steps <CardGroup> <Card title="Custom domains" icon="globe" href="/operations/deploy"> Wire DNS for your Pylon Cloud project. </Card> <Card title="Cookie & sessions" icon="cookie" href="/auth/sessions"> Full cookie config reference. </Card> <Card title="Next.js SDK" icon="react" href="/clients/next"> All `@pylonsync/next` APIs. </Card> <Card title="Pylon Cloud" icon="cloud" href="/cloud"> Project management, scaling, custom domains. </Card> </CardGroup> # Cloudflare Workers costs Source: https://docs.pylonsync.com/operations/workers-costs Cloudflare Workers pricing, runaway request patterns, budget alerts, and kill switches. Idle Workers apps cost \$0 because deployments scale to zero. This page covers the cases that cost money: * scale-to-zero tradeoffs * runaway request patterns * bill caps ## The pricing dimensions that matter As of 2026, the Workers Paid plan (\$5/mo base) includes: * 10M requests/month, then \$0.30 per million * 25B D1 reads/month, then \$0.001 per million * 50M D1 writes/month, then \$1.00 per million * 400k GB-s compute, then \$0.02 per million * 1M Durable Object requests, then \$0.15 per million **Things that cost nothing:** * Idle worker (no requests) * Cold starts * Reading env vars / bindings **Things that cost money and scale with volume:** * Every incoming HTTP request * Every D1 query (reads cheap, writes expensive) * Every DO invocation * WASM execution time (GB-seconds) ## Patterns that exceed a free tier ### Polling loops without backoff A client that polls `/api/sync/pull` every 500ms sends 172,800 requests/day. With 100 such clients, that is 17M requests/day, or about \$5/day. **Fix**: use WebSocket push (Durable Objects on Workers), or widen the polling interval with jitter. Pylon's sync protocol supports cursor-based pulls, so clients pay only for deltas. ### Loops inside a worker handler A scheduled cron Worker triggers a function that writes to D1 in a loop. It gets rate-limited, retries the whole batch, and exceeds the D1 write quota. **Fix**: * paginate writes * circuit-break on retry count * use `ctx.waitUntil` for fire-and-forget work * measure before you ship ### Durable Object hot-spotting One room with 10k concurrent WS clients, all pinned to one DO, generates 1M+ DO requests/hour. **Fix**: shard rooms. On Workers, each Pylon room maps to one (sticky) Durable Object. Split a hot "global chat" room into many smaller rooms (for example, per region) to spread load across DOs instead of pinning everyone to one. ### Crawlers / bots A `/api/entities/Todo` route that is not gated returns 404 to bots endlessly, but each hit still costs a Worker request. **Fix**: use Cloudflare's bot-fight mode and WAF rules for unauthenticated traffic on admin paths. Pylon's router returns 401 or 403 quickly for unauthenticated non-public routes, but Cloudflare can block at the edge before the Worker even runs. ### Errors in tight loops A TypeScript function throws. The client retries immediately without backoff. Every retry costs a Worker and D1 round-trip. **Fix**: * client-side exponential backoff * a server-side circuit breaker for degraded paths * alerts on the 5xx rate rather than the request rate ## Setting a budget cap Cloudflare supports per-worker budget alerts, but not automatic cutoff. You have to write the cutoff yourself. Two patterns: ### Soft cap (recommended): alert and throttle ``` Email alert at 50% of monthly budget → team investigates. Email alert at 80% → add WAF rule blocking anon traffic. Email alert at 95% → page oncall, manual mitigation. ``` This keeps users served while you react. ### Hard cap: kill switch Bind a KV namespace `BUDGET` with a single key `enabled: "1"`. At the top of your `fetch` handler: ```rust theme={null} if env.kv("BUDGET")?.get("enabled").text().await? != Some("1".into()) { return Response::error("budget cap active", 503); } ``` A GitHub Action watches billing and turns off the flag when needed. Users see a 503 response until you re-enable it. Your bill stops growing. ## Monitoring Cloudflare's analytics dashboard shows: * Request rate * Error rate (4xx / 5xx) * Subrequest count (every D1 or DO call counts) * Wall-clock time Add these metrics to your own dashboard. For Pylon specifically, watch: * `/api/sync/pull` rate: anything above 10 req/sec/client is suspicious * `/api/entities/*` error rate: a 403 spike signals a policy regression, and a 5xx spike signals a bug * WS connection count versus rejection rate (IP cap): rejections signal an attack * D1 write volume versus `change_log` append rate: these should match ## When a server is a better fit Workers scale-to-zero does not help if: * You have steady traffic of 100 or more req/sec: a \$25/mo AWS deploy will be cheaper * You need shards or long-lived game simulations: Durable Object hibernation costs add up fast * Your p99 latency matters and cold starts are not acceptable: a warm VPS is more predictable * You need Postgres (`postgres-live` feature): Workers supports D1 only * You need large file uploads: Workers has a 100 MB request cap For those cases, see [Deploy](/operations/deploy) shape 2 (AWS ECS and Aurora) or [Pylon Cloud](/cloud). ## Checklist 1. Enable Cloudflare budget alerts the day you deploy. 2. Add a kill switch KV namespace before problems start. It is easier to remove than to add under pressure. 3. Client-side: always use backoff, jitter, and cursor pagination. 4. Server-side: gate anonymous traffic at the edge, not in the Worker. # Data hygiene Source: https://docs.pylonsync.com/plugins/data The data-layer automation Pylon ships today: automatic multi-tenancy, owner stamping, schema behaviors, and field-level constraints. Pylon's data-layer automation is deliberately small and mostly automatic. Two built-in plugins wire themselves from your schema (you never name them in a config block). The SDK adds a few field-level and entity-level helpers. Everything else (cascades, slugs, derived fields, versioning) is a [server function](/concepts/functions) or a [policy](/concepts/policies), not a plugin. ## `tenant_scope` Row-level multi-tenancy. It is automatic. The signal is a `tenantId` field on the entity. No config entry. ```ts theme={null} import { entity, field } from "@pylonsync/sdk"; export const Project = entity("Project", { tenantId: field.string(), // ← presence of this field turns on scoping name: field.string(), }); ``` Once an entity has a `tenantId` (or `tenant_id`) field: * **Inserts** auto-fill `tenantId = auth.tenantId` when the caller does not provide it. They reject a write whose `tenantId` does not match the caller's. You cannot insert into another tenant. * **Reads / updates / deletes** are scoped to the caller's tenant. * **Admin contexts** bypass scoping on writes. On reads, an admin with no active tenant bypasses scoping; an admin with an active tenant is scoped to that tenant. This makes tenant isolation the default, not a `where` clause you must remember on every query. In your own `query`/`mutation` handlers you still have `auth.tenantId` to scope further. ## `owner_stamp` Per-row ownership. It is automatic. The signal is `field.X().owner()` on the field. ```ts theme={null} export const Listing = entity("Listing", { sellerId: field.string().owner(), // ← stamped from the session on insert title: field.string(), }); ``` On insert the runtime overwrites `sellerId` with `auth.userId` from the session. It rejects any client attempt to set a different user. This makes optimistic local-first writes safe for owned data. The client inserts with its own id for an instant local render. The server validates the owner from the session. Pair it with a policy like `allowUpdate: "data.sellerId == auth.userId"`. ## Schema behaviors `behaviors([...])` on the entity builder run field-injection helpers before the schema is registered. Two ship today: ```ts theme={null} import { e, field, timestamps, softDelete } from "@pylonsync/sdk"; export const Post = e.entity("Post", { title: field.string(), body: field.string(), }).behaviors([timestamps, softDelete]); ``` | Behavior | Adds | | ------------ | ----------------------------------------------------------------- | | `timestamps` | `createdAt` + `updatedAt` datetime fields (marked `defaultNow()`) | | `softDelete` | a `deletedAt` datetime field | Behaviors mutate the `EntityDefinition`'s fields, so the rest of the framework (storage, sync, policies) treats the injected columns as ordinary columns. <Note> Behaviors add the columns. The `defaultNow()` marker drives automatic value-stamping, which is not fully wired yet. Until it ships, set the values in your function handler (or on the client). The columns persist normally. `softDelete` adds the `deletedAt` column. The DELETE-sets-`deletedAt` and list-filtering semantics are app-driven for now (filter `deletedAt == null` in your query). </Note> ## Field-level constraints & defaults The field builder is the real "validation" surface. Pylon records it in the manifest. The runtime and codegen enforce it: ```ts theme={null} export const User = entity("User", { email: field.string().unique(), role: field.enum(["member", "admin"]), // constrained set bio: field.string().optional(), createdAt: field.datetime().defaultNow(), ownerId: field.string().owner(), }); ``` | Helper | Effect | | ------------------------------- | ---------------------------------------------------------- | | `.unique()` | Unique index; duplicate inserts rejected | | `.optional()` | Field may be omitted / null | | `field.enum([...])` | Value must be one of the listed strings | | `.default(v)` / `.defaultNow()` | Insert-time default value | | `.owner()` | Auth-stamped ownership (see [`owner_stamp`](#owner_stamp)) | For richer validation (length limits, regex, cross-field rules), do the check in a `mutation` handler before writing, and gate access with [policies](/concepts/policies). ## Where other data features live Pylon implements these features through fields, functions, policies, or auth, not configurable plugins: | Was documented as | Reality | | ----------------- | ---------------------------------------------------------------------------------------------------------------- | | `validation` | Field constraints (above) + checks in your `mutation` handler | | `slugify` | Compute the slug in your handler / a `field.string().default(...)` | | `computed` | Derive the value in your `mutation` handler before writing | | `cascade` | Delete children explicitly in a `mutation` (runs in one transaction) | | `versioning` | Write snapshot rows from an `after`-write step in a function | | `organizations` | Real framework feature — `Org`/`OrgMember` + `/api/auth/orgs/*`. See [Auth → Organizations](/auth/organizations) | ## Full-text search You configure search per-entity, not as a data plugin. See [Search & AI](/plugins/search-ai#search). # @pylonsync/feature-flags Source: https://docs.pylonsync.com/plugins/feature-flags Evaluate boolean and multivariate flags locally with rollouts, targeting rules, and JSON payloads. Local-eval means flag checks are pure in-memory computations, with no network call per request. Determinism comes from a stable hash of the bucketing key (default `userId`). ## Install ```bash theme={null} bun add @pylonsync/feature-flags ``` ## Inline catalog ```ts theme={null} import { isEnabled, getVariant, evaluateAll } from "@pylonsync/feature-flags"; const flags = { "new-onboarding": { type: "boolean", default: true, rollout: { percent: 25 }, targeting: [ { value: true, when: [{ property: "plan", op: "eq", value: "enterprise" }] }, ], }, "ai-model": { type: "multivariate", default: "gpt-4", variants: [ { name: "gpt-4", weight: 80, payload: { maxTokens: 4096 } }, { name: "claude-opus", weight: 20, payload: { maxTokens: 8192 } }, ], }, } as const; const ctx = { userId: "u_42", properties: { plan: "pro" } }; isEnabled(flags, "new-onboarding", ctx); // boolean getVariant(flags, "ai-model", ctx); // "gpt-4" | "claude-opus" evaluateAll(flags, ctx); // { "new-onboarding": true, "ai-model": {maxTokens: 4096} } ``` ## Predicates | Op | Behavior | | ---------------------------------------- | --------------------------------------------------- | | `eq` / `neq` | Strict equality. | | `in` / `not_in` | Membership in a literal array. | | `gt` / `gte` / `lt` / `lte` | Numeric comparison. | | `contains` / `starts_with` / `ends_with` | String operators. | | `regex` | RegExp match (anchored at the caller's discretion). | Multiple predicates AND together within a `when` block. Multiple rules within a flag's `targeting` array fire in order; first match wins. ## Bucketing `hashBucket(key, percent)` is FNV-1a. It is fast (sub-microsecond per call), well-distributed, and deterministic. PostHog and LaunchDarkly use the same hash for local-eval. For per-tenant rollouts (every member of a tenant sees the same value), set `rollout.key = "orgId"`. For cohort experiments, supply a custom property key. ## SSR bootstrap `evaluateAll(flags, ctx)` resolves every flag's current value. Serialize the result into the SSR-rendered HTML. The client then does not wait for a flag fetch. This removes the "flicker" where a flag-controlled UI flashes before the eval completes. ## Editable flags When `cfg.editable === true`, the manifest fragment adds a `FeatureFlag` entity plus `setFlag` / `deleteFlag` admin mutations. The runtime caches the catalog in-process and invalidates on mutation. Use this for a switch ops must change without a redeploy. # Integrations Source: https://docs.pylonsync.com/plugins/integrations Configure file storage, transactional email, Stripe billing, outbound webhooks, and feature flags. Integrations connect Pylon to the rest of your stack. Some are built-in subsystems selected by environment variables (file storage, email). Others are installable TS packages (Stripe, webhooks, feature flags). None of them are enabled through a `manifest.plugins` list. ## File storage File storage is a built-in subsystem selected by `PYLON_FILES_PROVIDER`, not a plugin entry. ```bash theme={null} # Local disk (default) PYLON_FILES_PROVIDER=local # default PYLON_FILES_DIR=uploads # optional, default "uploads/" PYLON_FILES_URL_PREFIX=/api/files # optional, default "/api/files" # Stack0 CDN PYLON_FILES_PROVIDER=stack0 PYLON_STACK0_API_KEY=sk_live_... # required for stack0 PYLON_STACK0_FOLDER=uploads # optional prefix PYLON_STACK0_BASE_URL=https://... # optional override # S3-compatible: AWS S3, Cloudflare R2, Tigris, MinIO, GCS interop PYLON_FILES_PROVIDER=s3 PYLON_S3_BUCKET=my-bucket # required PYLON_S3_ACCESS_KEY=AKIA... # required PYLON_S3_SECRET_KEY=... # required PYLON_S3_REGION=us-east-1 # optional, default us-east-1 PYLON_S3_ENDPOINT=https://... # optional; set for R2/Tigris/MinIO (path-style) PYLON_S3_PUBLIC_URL=https://cdn... # optional; public/CDN base (public bucket) PYLON_S3_SESSION_TOKEN=... # optional; STS/temporary credentials PYLON_S3_FOLDER=uploads # optional prefix ``` The server validates these at boot. It fails at boot if a provider is selected without its required vars (e.g. `PYLON_FILES_PROVIDER=s3` without the bucket or keys). It never silently degrades to local disk. `local`, `stack0`, and `s3` are the providers that ship today. The `s3` backend uses SigV4 presigned URLs for every operation. Clients PUT/GET bytes straight to the bucket (never through pylon's memory). For a private bucket, pylon tracks per-file ownership. `GET /api/files/<id>` then enforces the same owner check as local disk before it 302-redirects to a short-lived presigned URL. Set `PYLON_S3_PUBLIC_URL` to serve from a public bucket/CDN instead. Credentials come from env only (no automatic IAM-role discovery). Pass `PYLON_S3_SESSION_TOKEN` alongside temporary credentials. Uploads use a 3-step direct-to-storage flow so large files never transit the server's memory: ```ts theme={null} // 1. Ask pylon for an upload slot const init = await fetch("/api/files/init", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, mimeType: file.type, size: file.size, }), }).then((r) => r.json()); // 2. PUT the bytes DIRECTLY to the returned URL (Stack0's S3 for the // stack0 backend, pylon's local-put endpoint for local). Pylon's // process is bypassed for the byte transfer. await fetch(init.uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type }, }); // 3. Confirm so pylon records ownership and returns the canonical URL const stored = await fetch("/api/files/confirm", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assetId: init.assetId }), }).then((r) => r.json()); // stored = { id, url, size } // - stack0: url is the CDN URL // - local: url is /api/files/<id> (served by pylon) ``` The shape is identical for every backend. Clients do not special-case Stack0 vs local. Persist both `stored.url` (display) and `stored.id` (so you can `DELETE /api/files/<id>` later). * `GET /api/files/<id>` serves the bytes. For CDN backends pylon 302-redirects to the CDN URL; for local it streams from disk. * `DELETE /api/files/<id>` removes the asset. Owner-gated: only the user who confirmed the upload (or an admin). <Note> The multipart `POST /api/files/upload` was removed in 0.3.91 — it now returns `410 Gone`. Use the 3-step flow above. </Note> ## Email Transactional email is a built-in subsystem selected by `PYLON_EMAIL_PROVIDER`, not a plugin entry. ```bash theme={null} PYLON_EMAIL_PROVIDER=sendgrid # sendgrid | resend | stack0 | webhook PYLON_EMAIL_API_KEY=... # the provider's API key PYLON_EMAIL_FROM="Acme <noreply@acme.com>" # optional; webhook also needs PYLON_EMAIL_ENDPOINT ``` Send from an action via `ctx.email`. Both forms work. Use positional `send(to, subject, body)` for plain text, or an options object for HTML and attachments: ```ts theme={null} import { action, v } from "@pylonsync/functions"; export default action({ args: { to: v.string() }, async handler(ctx, args) { await ctx.email.send({ to: args.to, subject: "Welcome", text: "Thanks for signing up.", // always required — the text/plain part html: "<p>Thanks for signing up.</p>", // optional HTML body attachments: [{ // optional, base64 content filename: "invite.ics", // Passed through verbatim — `method=REQUEST` is what makes // Gmail/Outlook render an RSVP-able calendar invite. contentType: "text/calendar; method=REQUEST", content: icsBase64, }], }); }, }); ``` Limits: one recipient per send, at most 20 attachments and 15MB of base64 attachment data (≈11MB raw) per email. Larger sends throw `EMAIL_TOO_LARGE` before any network I/O. Auth-flow email (magic codes, verification, invites) uses a separate channel read from `PYLON_AUTH_EMAIL_*`. It falls back to `PYLON_EMAIL_*`. This keeps a shared platform auth key away from app code. See [Auth → Email verification](/auth/email-verification). ## Stripe billing Billing is the installable [@pylonsync/stripe](/plugins/stripe) package. It provides plans, checkout, a billing portal, a signature-verified webhook, and a subscription entity. Full config, hooks, and the webhook endpoint are on its page. ## Outbound webhooks Outbound webhooks to your customers' endpoints are the installable [@pylonsync/webhooks](/plugins/webhooks) package. It provides Svix-compatible HMAC signatures, an exponential-backoff retry schedule, secret rotation, and a delivery-audit entity. See its page. ## Feature flags Runtime feature flags are the installable [@pylonsync/feature-flags](/plugins/feature-flags) package. It provides local-eval boolean and multivariate flags, percentage rollouts, and targeting predicates. See its page. ## Audit log Auditing is a built-in framework feature, not a plugin. The `/api/auth/audit` and `/api/auth/audit/tenant` routes expose the log. Pylon records sensitive auth events automatically. ## Caching Pylon keeps an in-process cache that backs SSR / ISR response caching. It is internal to the runtime today. There is no user-facing `ctx.cache` API. For app-level memoization, cache inside a server function's own logic, or use an external store you call from a function. # Plugins overview Source: https://docs.pylonsync.com/plugins/overview How Pylon composes: installable TS packages plus a small set of auto-wired runtime built-ins. There is no manifest plugin array. "Plugins" in Pylon means two concrete things. Neither is a `manifest.plugins: [...]` list. There is no such array today. 1. **TS packages** (`@pylonsync/*`): installable npm packages that return a manifest fragment (entities, actions, queries, policies) plus handler factories. You compose the fragment into your `buildManifest()` call. Use these to add features the framework binary does not already cover. 2. **Built-in runtime plugins**: a small set compiled into the `pylon` binary and wired automatically from your schema or environment. You do not name them anywhere. They activate when the relevant signal is present (a `tenantId` field, a `.owner()` annotation, an env var). Features that are built into the framework binary already (no package, no plugin needed): * **API keys:** `/api/auth/api-keys` routes for mint / list / revoke. See [Auth → API keys](/auth/api-keys). * **TOTP / 2FA:** `/api/auth/totp/*` routes for enroll / verify / disable + backup codes. See [Auth → TOTP](/auth/totp). * **Transactional email:** `PYLON_EMAIL_PROVIDER` env (sendgrid / resend / stack0 / webhook) + `ctx.email.send()`. See [Integrations → email](/plugins/integrations#email). * **Audit log:** `/api/auth/audit` + `/api/auth/audit/tenant` routes. * **Organizations:** `Org` / `OrgMember` / `OrgInvite` entities + `/api/auth/orgs/*` routes. See [Auth → Organizations](/auth/organizations). ## TS packages Install with `bun add`, then spread the manifest fragment into `buildManifest()`: | Package | Purpose | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | **[@pylonsync/stripe](/plugins/stripe)** | Declarative billing — plans, checkout, portal, cancel/restore, signature-verified webhook, subscription entity. | | **[@pylonsync/feature-flags](/plugins/feature-flags)** | Local-eval flags — boolean + multivariate, percentage rollouts, targeting predicates, per-variant JSON payloads. | | **[@pylonsync/webhooks](/plugins/webhooks)** | Outbound webhook delivery — Svix-compatible HMAC signatures, retry schedule, secret rotation. | Each package exports a factory (e.g. `stripe({...})`, `webhooks({...})`) that returns `{ manifest, handlers, ... }`. Pylon loads function handlers one-per-file, so you also add one-line wrapper files under `functions/`. See each package's page for the exact list. ## Built-in runtime plugins These live in `crates/plugin/src/builtin/`. The runtime registers them at boot. They are auto-wired, not enabled through a manifest list. | Built-in | How it turns on | Reference | | ---------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- | | `tenant_scope` | Automatic on any entity with a `tenantId` field | [Data hygiene](/plugins/data#tenant_scope) | | `owner_stamp` | Automatic on any field declared `field.X().owner()` | [Data hygiene](/plugins/data#owner_stamp) | | `rate_limit` | Always on; tuned via `PYLON_RATE_LIMIT_MAX*` env | [Security](/plugins/security#rate_limit) | | `csrf` | Always on; allowlist from `manifest.auth.trustedOrigins` / env | [Security](/plugins/security#unified-trustedorigins) | | LLM proxy (`ai_proxy`) | Env-configured; powers `/api/llm/complete`, `/api/ai/stream`, `ctx.llm` | [Search & AI](/plugins/search-ai#llm-proxy) | | In-process cache | Internal; backs SSR / ISR response caching | — | ## How built-ins hook in Built-ins observe the request and data path at fixed points. The exact Rust hooks (see [Writing your own](/plugins/writing-your-own)): ``` HTTP request │ ▼ on_request ──── rate_limit (reject if over budget) │ ──── csrf (origin check on cookie-authed writes) │ ▼ entity write (insert / update / delete) │ ──── before_insert / before_update / before_delete │ (tenant_scope injects + validates tenantId; │ owner_stamp stamps ownerId from the session) │ ▼ after_insert / after_update / after_delete ``` A built-in can inspect or reject a request (`on_request`), mutate or reject a write (`before_*`), react after a write lands (`after_*`), add HTTP routes (`routes()`), or contribute manifest entities (`entities()`). ## There is no `plugins: []` array Older drafts of these docs described enabling plugins through a `pylon.manifest.json` `plugins` list with per-plugin `config`. That mechanism does not exist. Built-ins are automatic. You influence them through your schema (add a `tenantId` field, annotate a field `.owner()`) and environment variables. App-specific cross-cutting logic goes in [server functions](/concepts/functions) and [policies](/concepts/policies), not a plugin config block. `pylon plugins list` prints a roadmap catalog of plugin names (validation, slugify, versioning, cascade, MCP, and more). These are planned, not installable today. Treat the list as a preview, not an API. ## Plugins vs functions | Plugins / built-ins | Functions | | -------------------------------------------------------------- | ----------------------------------------- | | Implicit — fire on every relevant op | Explicit — called by name | | Auto-wired from schema/env, or composed from a TS package | Defined in TS files under `functions/` | | Can mutate data in-flight (built-ins) | Run inside a transaction; return a result | | Cross-cutting concerns (tenant scope, owner stamp, rate limit) | Business logic | | Compiled into the binary, or shipped as a package | Hot-reloaded by the dev server | Use a built-in when the behavior applies to every call. Use a function when callers opt in by name. ## Writing your own There is no dynamic plugin loading yet. Add a Rust built-in through a custom runtime; use functions and policies for TypeScript logic. The SDK ships a `definePlugin({ name, entities, hooks })` helper, but the runtime does not consume its output yet. See [Writing your own plugin](/plugins/writing-your-own) for the current extension points. # Search & AI Source: https://docs.pylonsync.com/plugins/search-ai Full-text search (FTS5 + facets), vector search, and the built-in LLM proxy. MCP-as-a-plugin is on the roadmap. Two capabilities ship today: full-text search (configured per-entity) and a built-in LLM proxy (configured by env). Neither is enabled through a `manifest.plugins` list. ## `search` Full-text search backed by SQLite's FTS5 (or Postgres `tsvector` on Postgres). You configure it on the entity, not as a plugin. A `search` config creates FTS5 + facet-bitmap shadow tables on schema push. Pylon maintains them on every write. Declare it with the builder's `.search({...})` (or the `search` key in the entity options): ```ts theme={null} import { e, field } from "@pylonsync/sdk"; export const Post = e.entity("Post", { title: field.string(), body: field.string(), tags: field.string(), authorId: field.string(), viewCount: field.int(), }).search({ text: ["title", "body"], // tokenized into the FTS5 index facets: ["authorId", "tags"], // materialized as facet bitmaps sortable: ["viewCount"], // indexed for stable pagination }); ``` | Key | Purpose | | ---------- | ----------------------------------------------------------- | | `text` | Fields tokenized into the FTS5 index for keyword matching | | `facets` | Fields materialized as bitmaps for instant per-value counts | | `sortable` | Fields indexed so result sets paginate stably | Query via `POST /api/search/<Entity>`, or the React hook: ```tsx theme={null} const { hits, facetCounts, total, loading } = db.useSearch<Post>("Post", { query: input, facets: ["tags"], }); ``` `facetCounts` is the live count per facet value over the current filtered hit set. It is enough to build Algolia-style faceted UIs without Algolia. ## LLM proxy A built-in proxy to LLM providers so your API key stays server-side and clients never see it. Environment variables configure it, not a plugin entry. Two endpoints, both requiring an authenticated session: * `POST /api/llm/complete` — non-streaming completion. * `POST /api/ai/stream` — SSE streaming completion. And, inside a mutation or action, `ctx.llm.complete(...)` / `ctx.llm.stream(...)` (server-only; the key never reaches the client, and neither is available in queries). ### `ctx.llm.stream` `complete` resolves when the model finishes. `stream` calls your handler for each event as the provider emits it, then resolves with the same assembled response. You can push text to the client while it generates and still branch on `stop_reason` afterwards: ```ts theme={null} const res = await ctx.llm.stream( { messages, tools }, (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); }, ); if (res.stop_reason === "tool_use") { /* run the tools, loop */ } ``` Events are `text_delta {text}`, `tool_use_start {id, name}`, `tool_input_delta {partial_json}`, and `done {stop_reason, usage}`. Tool arguments arrive as raw JSON fragments. Concatenate them and parse once at the end. Same auth gate and same model allowlist as `complete`: streaming cannot reach a model `complete` would refuse. Streaming does not extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default), so a long agent run must declare `timeout: <seconds>` on the function. To reach every client watching rather than only the caller, push the same deltas through [`ctx.rooms.broadcast`](/concepts/functions#server-push-to-a-room). Full walkthrough, including an agent tool loop: [Functions → Streaming LLM output](/concepts/functions#streaming-llm-output). ### Env ```bash theme={null} # /api/llm/complete, ctx.llm.complete, ctx.llm.stream PYLON_LLM_PROVIDER=anthropic # anthropic | openai ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY for openai # /api/ai/stream (SSE) PYLON_AI_PROVIDER=anthropic # anthropic | openai | custom PYLON_AI_API_KEY=sk-ant-... PYLON_AI_MODEL=claude-sonnet-4-5 PYLON_AI_BASE_URL=https://... # required for provider "custom" ``` ### Model allowlist Clients may request a specific model on `/api/ai/stream` only if it's allowlisted — otherwise the request is rejected with `MODEL_NOT_ALLOWED`. Set the allowlist via the manifest `llm()` helper or `PYLON_AI_MODELS_ALLOWED`: ```ts theme={null} import { buildManifest, llm } from "@pylonsync/sdk"; buildManifest({ name: "myapp", llm: llm({ allowedModels: ["claude-sonnet-4-5", "claude-haiku-4-5"] }), // ... }); ``` Why proxy: 1. **API keys stay server-side:** browser / native clients never see them. 2. **Auth-gated:** both endpoints require a session. 3. **Model allowlist:** cap which models clients can invoke. ## Vector search Built in as a field type, not a plugin. Declare `embedding: field.vector(1536)` on an entity, fill it with `ctx.llm.embed(texts)` (OpenAI or Voyage), and query with `ctx.db.vectorSearch(entity, { field, vector, limit, filter })` or `POST /api/vector-search/<Entity>`. Exact k-NN, cosine by default, both backends. Full guide: [Vector Search](/concepts/vector-search). ## Not yet built in * **MCP server:** exposing your entities/functions as Model Context Protocol tools as a manifest plugin is planned, not shipped. The CLI's `pylon mcp` (agent tooling) exists today. Do not build against unshipped plugins until they ship. # Security Source: https://docs.pylonsync.com/plugins/security Rate limiting, the unified trustedOrigins gate (CORS/CSRF/OAuth), built-in SSRF defense, and where auth hardening lives. Pylon's security defaults are on. You do not enable them through a `manifest.plugins` list. Rate limiting and CSRF are always-on built-ins tuned by env and your manifest. SSRF protection is built into the runtime. The auth hardening (TOTP, API keys, Argon2, session lifetime) lives in the auth layer. ## `rate_limit` Always-on, tiered per-window request budgets. Buckets authenticated callers by user id and anonymous callers by IP. Returns `429 RATE_LIMITED` when the budget is exhausted. Env tunes it, not a per-plugin config block. | Tier | Default | Override | | ---------------------------------- | ---------- | ----------------------------- | | Authenticated (`user:<id>` bucket) | 1000 / min | `PYLON_RATE_LIMIT_MAX_AUTHED` | | Anonymous (`ip:<addr>` bucket) | 100 / min | `PYLON_RATE_LIMIT_MAX` | The window is 60s. In dev mode (`PYLON_DEV_MODE` truthy) both tiers are effectively off (100k/min). The anonymous cap is deliberately low. Anonymous traffic against `/api/auth/*` is the brute-force surface. Beyond this global limiter, the runtime applies additional budgets automatically: * `/api/ai/stream` has its own per-user (per-IP for anon) rate limiter. * Webhook and function invocations (`/api/webhooks/<name>`, `/api/fn/<name>`) rate-limit unauthenticated callers by peer IP. On Pylon Cloud, the edge also applies per-IP rate limiting. ## Unified `trustedOrigins` Pylon has three browser-facing gates. Each rejects a request when the caller's origin is not trusted: | Gate | What it protects | Error code | | ------------------ | ----------------------------------------------------------------- | ------------------------------ | | **CORS** | Cross-origin XHR/fetch from the browser | `[cors]` warn-log on rejection | | **CSRF** | State-changing requests with cookie auth (POST/PATCH/PUT/DELETE) | `CSRF_REJECTED` | | **OAuth redirect** | `?callback=` / `?error_callback=` on `/api/auth/login/<provider>` | `UNTRUSTED_REDIRECT` | The single declarative source for all three is `manifest.auth.trustedOrigins`: ```ts theme={null} import { auth, buildManifest } from "@pylonsync/sdk"; const manifest = buildManifest({ name: "my-app", version: "0.1.0", entities: [/* ... */], routes: [/* ... */], auth: auth({ trustedOrigins: [ "https://app.example.com", "https://staging.example.com", ], }), }); ``` That one list defaults CORS + CSRF + OAuth-redirect simultaneously. Loopback (`http://localhost`, `127.0.0.1`, `[::1]`, any port, http only) is always auto-trusted at every gate. So `pylon dev` works on whatever port you pick without manifest config. Bearer-authenticated requests (SPAs, native clients) do not trigger the CSRF gate. It only fires when a session cookie is attached. ### Per-gate overrides Ops who need a different allowlist for one gate can split via env vars. Each overrides its gate's default: | Env var | Overrides | Notes | | ----------------------- | ------------------------ | ---------------------------------------------- | | `PYLON_CORS_ORIGIN` | CORS allowlist | Comma-separated. `*` refused in production. | | `PYLON_CSRF_ORIGINS` | CSRF allowlist | Comma-separated. | | `PYLON_TRUSTED_ORIGINS` | OAuth redirect allowlist | Comma-separated. Merged with manifest entries. | If none of the env vars are set, all three read from `manifest.auth.trustedOrigins`. In production with neither manifest entries nor env vars, the CORS gate fails to start with a clear error. There is no implicit wildcard fallback. `PYLON_DEV_MODE=true` relaxes that to `*` for local-only development. ## SSRF defense (built-in) Pylon guards its own server-side HTTP requests (image optimizer, OIDC discovery, and other outbound fetches) against SSRF. It refuses to connect to private / link-local IP ranges (loopback, RFC 1918, `169.254.0.0/16` cloud metadata, and the IPv6 equivalents). This is built into the runtime. There is no `net_guard` plugin to enable and no per-app `allow_hosts` config. When you make outbound calls from your own server functions, validate user-supplied URLs before fetching them. ## Auth hardening lives in the auth layer The following are real framework features, configured through `auth({...})` and the `/api/auth/*` routes, not plugins: * **Password auth:** Argon2 hashing behind `/api/auth/password/*`. See [Auth → Password](/auth/password). * **TOTP / 2FA:** `/api/auth/totp/*` (enroll / verify / disable + backup codes). See [Auth → TOTP](/auth/totp). * **API keys:** long-lived bearer tokens via `/api/auth/api-keys`. See [Auth → API keys](/auth/api-keys). * **Session lifetime:** tune via `auth({ session: { expiresIn } })` (default 30 days): ```ts theme={null} auth({ session: { expiresIn: 60 * 60 * 24 * 7 }, // 7 days trustedOrigins: ["https://app.example.com"], }); ``` Pylon's session model is opaque-token by default. It can also mint short-lived HS256 JWTs from a session (see [Auth → JWT sessions](/auth/jwt)). Accepting JWTs minted by an external IdP (Auth0, Cognito, Clerk) is not built in today. ## Recommended production posture * Set `manifest.auth.trustedOrigins` to your real origins (fails closed in production if unset). * Lower `PYLON_RATE_LIMIT_MAX` / `PYLON_RATE_LIMIT_MAX_AUTHED` if your defaults are too generous for your traffic. * Add `totp` for sensitive accounts and shorten `session.expiresIn` for high-risk apps. * Keep untrusted, user-supplied URLs out of server-side fetches, and validate any you must make. # @pylonsync/stripe Source: https://docs.pylonsync.com/plugins/stripe Configure customer creation, checkout, billing portal, webhooks, and subscription state in one block. `@pylonsync/stripe` replaces the \~400 lines every Pylon app rewrites when it adds Stripe billing: customer creation, checkout session creation, billing portal, webhook signature verification, plan derivation, and subscription state. One `stripe({plans, hooks})` block returns a manifest fragment and handler factories. ## Install ```bash theme={null} bun add @pylonsync/stripe ``` ## Config ```ts theme={null} import { buildManifest, app, entity, field } from "@pylonsync/sdk"; import { stripe } from "@pylonsync/stripe"; export const Org = entity("Org", { name: field.string(), slug: field.string().unique(), createdBy: field.id("User"), createdAt: field.string(), stripeCustomerId: field.string().optional(), }); export const billing = stripe({ referenceType: "org", // or "user" plans: [ { name: "starter", priceId: process.env.STRIPE_PRICE_STARTER!, limits: { recordings: 50 } }, { name: "pro", priceId: process.env.STRIPE_PRICE_PRO!, limits: { recordings: 500 } }, { name: "scale", priceId: process.env.STRIPE_PRICE_SCALE!, limits: { recordings: -1 } }, ], hooks: { onSubscriptionActivate: async (ctx, { referenceId, plan }) => { // analytics, welcome email }, onInvoice: async (ctx, { eventType, invoice }) => { // record audit, slack alert on payment_failed }, }, }); export default app({ name: "myapp", entities: [Org /* + your entities */, ...billing.manifest.entities], actions: [...billing.manifest.actions], queries: [...billing.manifest.queries], policies: [...billing.manifest.policies], }); ``` ## Wrapper files Pylon loads function handlers by file. Create one-line wrappers under `functions/`: ```ts theme={null} // functions/createCheckoutSession.ts export { createCheckoutSession as default } from "../billing"; ``` ```ts theme={null} // functions/createBillingPortalSession.ts export { createBillingPortalSession as default } from "../billing"; ``` Create one more wrapper per handler key exposed by `billing.handlers` (`cancelSubscription`, `restoreSubscription`, `stripeWebhook`, plus the `_pylonStripe*` internals). ## Required env | Variable | Purpose | | ----------------------- | ----------------------------------------------------------------------------- | | `STRIPE_SECRET_KEY` | API requests (set per Pylon Cloud project's Secrets UI). | | `STRIPE_WEBHOOK_SECRET` | Signature verification on `/api/webhooks/stripeWebhook`. | | `PYLON_PUBLIC_URL` | Auto-set by Pylon Cloud. Drives URL allowlist for `success_url`/`cancel_url`. | ## Webhook endpoint Configure in Stripe Dashboard → Developers → Webhooks → Add endpoint: ``` URL: https://api.<your-app>.com/api/webhooks/stripeWebhook Events: customer.subscription.created customer.subscription.updated customer.subscription.deleted invoice.created invoice.finalized invoice.paid invoice.payment_failed invoice.voided ``` Copy the signing secret (`whsec_...`). Set it as `STRIPE_WEBHOOK_SECRET` on your Pylon machine. ## Lifecycle hooks | Hook | Fires | | -------------------------- | --------------------------------------------------------------- | | `onCustomerCreate` | After a Stripe customer is created (first checkout). | | `onSubscriptionActivate` | `customer.subscription.created` event. | | `onSubscriptionUpdate` | `customer.subscription.updated` event. | | `onSubscriptionCancel` | `customer.subscription.deleted` event. | | `onInvoice` | Any invoice event. | | `onEvent` | Catch-all for unhandled Stripe events. | | `getCheckoutSessionParams` | Inject tax/promo/idempotency-key params into checkout creation. | ## RBAC The `authorizeReference` hook gates subscription mutations. Defaults: * `referenceType: "org"`: the caller must be the org's owner or admin. * `referenceType: "user"`: the caller must be the user. * `referenceType: "custom"`: you must supply your own `resolveCustomer` hook, which maps `referenceId` to a Stripe customer ID and creates one if needed. `custom` has no default authorization, so supply `authorizeReference` too. ## Security * **Constant-time signature verification** with a 5-minute replay window. The `Stripe-Signature` header can carry multiple `v1=` signatures (Stripe emits several during a signing-secret rotation). The verifier accepts a match against any of them. * **URL allowlist** derived automatically from `PYLON_PUBLIC_URL` and `PYLON_CORS_ORIGIN`. No hardcoded host strings. * **Three-signal plan resolver**: it matches on lookup\_key, then nickname, then price ID match. Stripe sometimes omits the first two on webhook payloads. * **Double-trial guard**: a prior `Subscription` row for the same reference disables the trial period, regardless of plan config. * **Idempotent webhook upsert** via `stripeSubscriptionId` lookup, so Stripe retries produce the same state. # @pylonsync/webhooks Source: https://docs.pylonsync.com/plugins/webhooks Deliver outbound webhooks with Svix-compatible signatures, retries, and overlapping secret rotation. `@pylonsync/webhooks` sends outbound webhooks to your customers' endpoints when domain events happen, for example `invoice.paid` to a customer's URL. It signs each webhook with Svix-style HMAC-SHA256, so receivers that use Svix's reference verifier work without changes. ## Install ```bash theme={null} bun add @pylonsync/webhooks ``` ## Config ```ts theme={null} import { webhooks, dispatch } from "@pylonsync/webhooks"; export const hooks = webhooks({ retrySchedule: [5, 300, 1800, 7200, 18000, 36000, 50400], // default replayToleranceSecs: 300, }); export default app({ entities: [...hooks.manifest.entities], policies: [...hooks.manifest.policies], }); ``` ## Dispatch an event ```ts theme={null} import { dispatch } from "@pylonsync/webhooks"; import { hooks } from "../webhook-config"; export default action({ args: { invoiceId: v.id("Invoice") }, async handler(ctx, args) { const invoice = await ctx.db.get("Invoice", args.invoiceId); await dispatch(ctx, hooks.config, { type: "invoice.paid", data: invoice, // optional — otherwise resolved via the `getApplicationId` config hook applicationId: invoice.orgId, }); }, }); ``` The plugin enqueues delivery jobs to every matching endpoint through Pylon's `ctx.scheduler.runAfter`. Failed deliveries retry on the configured schedule: 5s, 5m, 30m, 2h, 5h, 10h, 14h, then dead (the default). ## Receiver verification Receivers verify with the same algorithm Svix's reference verifier uses: ```ts theme={null} import { verifyWebhook } from "@pylonsync/webhooks"; const result = await verifyWebhook( endpoint.secret, { id: request.headers["webhook-id"], timestamp: request.headers["webhook-timestamp"], signature: request.headers["webhook-signature"], }, request.rawBody, ); if (result !== true) { throw new Error(`bad signature: ${result}`); } ``` ## Endpoints Customers register webhook URLs by inserting into the `WebhookEndpoint` entity: | Field | | | --------------- | ----------------------------------------------------------------- | | `applicationId` | Tenant scope (usually the customer's org id). | | `url` | Their endpoint URL. | | `secret` | HMAC secret (use `whsec_<base64>` format for Svix compatibility). | | `eventTypes` | JSON array of subscribed event types. Empty = all. | | `headers` | Optional extra headers to attach on each delivery. | | `disabled` | Skip delivery without deleting. | Tenant-scoped policy: `auth.tenantId == data.applicationId or auth.is_admin`. Apps that need a stricter policy (for example, only the owner role) override this in their manifest. ## Delivery audit Every attempt writes a `WebhookAttempt` row: | Status | When | | ----------- | ------------------------------------------------- | | `pending` | Scheduled, not yet delivered. | | `succeeded` | HTTP 2xx response. | | `failed` | HTTP 4xx/5xx or network error. Retry scheduled. | | `dead` | All retries exhausted. Manual reprocess required. | This is a read-only entity. Customers can view their endpoint's delivery history, but they cannot change it. ## Secret rotation ```ts theme={null} await signWebhook({ id, timestamp, body, secrets: [oldSecret, newSecret], // both signed }); ``` Both signatures appear in the `webhook-signature` header (`v1,<sig-old> v1,<sig-new>`). Receivers accept either signature. This gives customers a window to rotate their stored secret without dropping deliveries. # Writing your own plugin Source: https://docs.pylonsync.com/plugins/writing-your-own The Rust Plugin trait, built-in registration, and the current extension model. Pylon's plugin system is a Rust trait that the runtime registers at boot. It wires the built-ins (`rate_limit`, `tenant_scope`, `owner_stamp`, `csrf`). Understand the current state before you use it: * **There is no dynamic plugin loading and no `manifest.plugins` array.** Built-ins are registered in the runtime's Rust code. To ship your own, you build a custom runtime that registers it. * **Most needs do not require a plugin.** App logic belongs in [server functions](/concepts/functions) and [policies](/concepts/policies). Schema-level automation is covered by [tenant\_scope / owner\_stamp / behaviors](/plugins/data). * The SDK exports a `definePlugin({ name, entities, hooks })` helper, but the runtime does not consume its output yet. TS-authored plugins are not wired. Treat it as a placeholder, not a supported path. ## When to write one | Need | Plugin? | | ---------------------------------------------------- | --------------------------------------- | | One-off business logic | No: write a function | | Auth-stamped ownership / tenant scoping | No: use `.owner()` / a `tenantId` field | | Timestamp / soft-delete columns | No: use `behaviors([...])` | | Cross-cutting behavior on every write, in the binary | Yes: write a Rust plugin | | A new server-side HTTP route in the binary | Yes: write a Rust plugin | ## The `Plugin` trait This is the real trait, `pylon_plugin::Plugin`. Every method has a default no-op implementation. Override only what you need: ```rust theme={null} use pylon_plugin::{Plugin, PluginError, PluginContext, PluginRoute, RequestMeta}; use pylon_auth::AuthContext; use serde_json::Value; pub trait Plugin: Send + Sync { /// Unique name for this plugin. fn name(&self) -> &str; /// Called once when the plugin is registered. fn on_init(&self, _ctx: &PluginContext) {} /// Custom API routes this plugin handles. fn routes(&self) -> Vec<PluginRoute> { vec![] } /// Before an entity insert. Mutate `data` in place; return Err to reject. fn before_insert(&self, _entity: &str, _data: &mut Value, _auth: &AuthContext) -> Result<(), PluginError> { Ok(()) } fn after_insert(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {} /// Before an entity update. Mutate `data`; return Err to reject. fn before_update(&self, _entity: &str, _id: &str, _data: &mut Value, _auth: &AuthContext) -> Result<(), PluginError> { Ok(()) } fn after_update(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {} /// Before an entity delete. Return Err to reject. fn before_delete(&self, _entity: &str, _id: &str, _auth: &AuthContext) -> Result<(), PluginError> { Ok(()) } fn after_delete(&self, _entity: &str, _id: &str, _auth: &AuthContext) {} /// On every incoming request (middleware). Return Err to short-circuit. fn on_request(&self, _method: &str, _path: &str, _auth: &AuthContext) -> Result<(), PluginError> { Ok(()) } /// Richer variant with per-request metadata (peer IP today). /// Defaults to delegating to `on_request`. fn on_request_with_meta(&self, method: &str, path: &str, auth: &AuthContext, _meta: &RequestMeta<'_>) -> Result<(), PluginError> { self.on_request(method, path, auth) } /// When a new session is created. fn on_session_create(&self, _user_id: &str, _token: &str) {} /// Additional manifest entities this plugin contributes. fn entities(&self) -> Vec<pylon_kernel::ManifestEntity> { vec![] } } ``` There is no unified `before_write` or `after_write` method with a `WriteOp` enum. Insert, update, and delete are separate hooks. ### `PluginError` Rejections carry a code, message, and HTTP status (it is a struct, not an enum): ```rust theme={null} return Err(PluginError { code: "BLOCKED".into(), message: "inserts to this entity are not allowed".into(), status: 403, }); ``` ## Example: a before-insert plugin This is the pattern the built-ins use: change `data` before it is written. ```rust theme={null} use pylon_plugin::{Plugin, PluginError}; use pylon_auth::AuthContext; use serde_json::Value; pub struct Timestamps; impl Plugin for Timestamps { fn name(&self) -> &str { "timestamps" } fn before_insert( &self, _entity: &str, data: &mut Value, _auth: &AuthContext, ) -> Result<(), PluginError> { if let Some(obj) = data.as_object_mut() { let now = chrono::Utc::now().to_rfc3339(); if !obj.contains_key("createdAt") { obj.insert("createdAt".into(), Value::String(now.clone())); } obj.insert("updatedAt".into(), Value::String(now)); } Ok(()) } } ``` ## Example: a per-request gate `on_request_with_meta` runs before dispatch and receives the peer IP. This is how `rate_limit` buckets anonymous callers: ```rust theme={null} use pylon_plugin::{Plugin, PluginError, RequestMeta}; use pylon_auth::AuthContext; impl Plugin for MyLimiter { fn name(&self) -> &str { "my_limiter" } fn on_request_with_meta( &self, _method: &str, _path: &str, auth: &AuthContext, meta: &RequestMeta<'_>, ) -> Result<(), PluginError> { let key = match auth.user_id.as_ref() { Some(uid) => format!("user:{uid}"), None => format!("ip:{}", meta.peer_ip), }; self.check(&key) // Err(PluginError { status: 429, .. }) if over budget } } ``` ## Example: adding an HTTP route `routes()` returns a list of `PluginRoute`s. The router matches them after its built-in routes, by method and path prefix. The handler receives `(body, path, auth)` and returns `(status, body)`: ```rust theme={null} use pylon_plugin::{Plugin, PluginRoute}; impl Plugin for MyWebhookReceiver { fn name(&self) -> &str { "my_webhook" } fn routes(&self) -> Vec<PluginRoute> { vec![PluginRoute { method: "POST".into(), path: "/api/webhooks/my-service".into(), handler: Box::new(|body, _path, _auth| { // verify signature, parse, dispatch... (200, "{}".into()) }), }] } } ``` ## Registering with the runtime Plugins are added to a `PluginRegistry` at boot. This is the same place the built-ins are registered: ```rust theme={null} use std::sync::Arc; use pylon_plugin::PluginRegistry; let mut reg = PluginRegistry::new(runtime.manifest().clone()); reg.register(Arc::new(Timestamps)); // The default runtime also registers rate_limit, tenant_scope, owner_stamp here. ``` The runtime has no manifest lookup and no dynamically loaded plugin bundle. To ship your own today, fork the runtime (`crates/runtime`), add a `reg.register(...)` call, and build a custom `pylon` binary. Dynamic plugin loading and a wired `definePlugin` remain on the roadmap. ## Testing The trait is plain Rust and needs no wrapper: ```rust theme={null} #[test] fn timestamps_stamps_on_insert() { let plugin = Timestamps; let mut data = serde_json::json!({ "title": "x" }); plugin .before_insert("Post", &mut data, &AuthContext::anonymous()) .unwrap(); assert!(data["createdAt"].is_string()); assert!(data["updatedAt"].is_string()); } ``` ## Reference: the built-ins Read the real built-ins in `crates/plugin/src/builtin/` as templates: * **Before-write data mutation**: `tenant_scope.rs`, `owner_stamp.rs` * **Per-request gate**: `rate_limit.rs`, `csrf.rs` * **External-service integration**: `ai_proxy.rs` Pick the closest match and adapt. # Quickstart Source: https://docs.pylonsync.com/quickstart Run Pylon in five minutes with a schema, policy, server function, and live sync. This walkthrough runs a Pylon server with a schema, one policy, and one server function. You then call the API with `curl` to see it work. For a realtime UI, point the [React SDK](/clients/react), [Swift SDK](/clients/swift), or [React Native SDK](/clients/react-native) at the running server. You can also clone one of the [example apps](https://github.com/pylonsync/pylon/tree/main/examples). <Tip> **Start from a working app:** ```bash theme={null} npm create @pylonsync/pylon@latest my-app -- --template todo cd my-app && npm run dev ``` This scaffolds a full-stack Pylon app with a React 19 SSR frontend and API on one port. The Pylon CLI is a project dependency, so `npm run dev` needs no global install. Run `npm create @pylonsync/pylon@latest` without arguments to choose among the `default` (SaaS), `todo`, `chat`, `shop`, `consumer`, `waitlist`, `ai-chat`, and other templates. Each app includes a `pylon test` setup. Deploy with `npm run deploy` or `pylon deploy`; see [Deploy](/operations/deploy). </Tip> The rest of this page builds a minimal backend by hand so you can see each piece. It uses a global `pylon` binary; if you'd rather start from the scaffold above, skip to [Concepts](/concepts/entities). ## 1. Install the CLI ```bash theme={null} curl -fsSL https://www.pylonsync.com/install.sh | bash ``` Downloads a prebuilt `pylon` binary to `~/.local/bin`. Linux and macOS, x86\_64 and arm64. No Rust toolchain required. (The `npm create` scaffold above doesn't need this. It bundles the CLI. Install globally when you want `pylon` on your PATH for the from-scratch flow below.) Pylon needs Bun ≥ 1.0 at runtime. It runs your server functions and SSR on Bun. Verify: ```bash theme={null} pylon --version ``` Other ways to install: ```bash theme={null} # Cargo (compiles from source) cargo install pylon-cli # Docker docker pull ghcr.io/pylonsync/pylon:latest ``` ## 2. Scaffold a new app ```bash theme={null} pylon init notes cd notes ``` This scaffolds a Bun workspace with the API at `apps/api/`: ``` notes/ apps/ api/ app.ts # schema + manifest entry point sdk.ts # local re-export of @pylonsync/sdk tsconfig.json package.json package.json # workspace root .gitignore README.md ``` `pylon init` scaffolds the backend only, with no frontend framework, because Pylon renders React on the server natively. To also generate a separate frontend, pass `--frontend react` (Vite), `--frontend tanstack`, or `--frontend nextjs`, or pick interactively when stdin is a TTY. For a full-stack app with SSR pages, use `npm create @pylonsync/pylon` (above) instead. You'll add a `functions/` directory under `apps/api/` by hand in step 4. ## 3. Define the schema Replace `apps/api/app.ts` with: ```ts theme={null} import { entity, field, policy, buildManifest } from "@pylonsync/sdk"; const Note = entity( "Note", { title: field.string(), body: field.string(), authorId: field.string(), updatedAt: field.datetime(), }, { indexes: [{ name: "by_author", fields: ["authorId"], unique: false }], }, ); const notePolicy = policy({ name: "note_public", entity: "Note", allowRead: "true", allowInsert: "auth.userId == data.authorId", allowUpdate: "auth.userId == data.authorId", allowDelete: "auth.userId == data.authorId", }); const manifest = buildManifest({ name: "notes", version: "0.1.0", entities: [Note], policies: [notePolicy], queries: [], actions: [], routes: [], }); // Not a debug leftover: the CLI runs `bun run app.ts` and parses stdout as // your manifest (see crates/cli/src/bun.rs). Deleting this line breaks every // pylon command that reads your schema. console.log(JSON.stringify(manifest, null, 2)); ``` The trailing `console.log` is required. `pylon dev` captures stdout as the manifest. ## 4. Add a server function Create `apps/api/functions/createNote.ts`: ```ts theme={null} import { mutation, v } from "@pylonsync/functions"; export default mutation({ // `auth: "guest"` lets the guest session from step 6 call this. The // default is `auth: "user"`, which rejects guest sessions — switch to it // once you have real accounts. Either way `ctx.auth.userId` is a stable // string, so it's safe to use as the author id. auth: "guest", args: { title: v.string(), body: v.string(), }, async handler(ctx, args) { const id = await ctx.db.insert("Note", { title: args.title, body: args.body, authorId: ctx.auth.userId, updatedAt: new Date().toISOString(), }); return { id }; }, }); ``` The filename becomes the RPC name. It is callable at `POST /api/fn/createNote`. `pylon init` already adds `@pylonsync/sdk` + `@pylonsync/functions` to `apps/api/package.json`. If you skipped init or removed the deps, install them manually: ```bash theme={null} cd apps/api && bun add @pylonsync/sdk @pylonsync/functions ``` ## 5. Run the server ```bash theme={null} cd apps/api pylon dev ``` `pylon dev` does four things: * auto-discovers `app.ts` (and `schema.ts` for legacy projects). * watches `app.ts` and `functions/*.ts`. * auto-migrates the database (SQLite at `.pylon/dev.db` by default, Postgres if `DATABASE_URL` is set). * serves the API on `http://localhost:4321`. Expected output: ``` ✓ notes v0.1.0 — 1 entities, 0 queries, 0 actions, 1 policies, 0 routes Server: http://localhost:4321 Database: .pylon/dev.db ``` ## 6. Call the API Create a guest session: ```bash theme={null} curl -X POST http://localhost:4321/api/auth/guest # → { "token": "eyJ…", "user_id": "usr_01H…" } ``` Call the mutation (replace `TOKEN` with the value from above): ```bash theme={null} curl -X POST http://localhost:4321/api/fn/createNote \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"First note","body":"hello from Pylon"}' # → { "id": "not_01H…" } ``` List the rows: ```bash theme={null} curl http://localhost:4321/api/entities/Note # → [{ "id": "not_01H…", "title": "First note", ... }] ``` You now have a working Pylon backend with a typed schema, row-level policy, RPC function, and HTTP API. ## Next steps <CardGroup> <Card title="Entities" icon="table" href="/concepts/entities"> All the field types and index options. </Card> <Card title="Policies" icon="shield" href="/concepts/policies"> How row-level access rules work. </Card> <Card title="Functions" icon="bolt" href="/concepts/functions"> Queries, mutations, actions, validators. </Card> <Card title="Live queries" icon="radio" href="/concepts/live-queries"> How `db.useQuery` stays in sync. </Card> <Card title="Testing" icon="vial" href="/testing"> `pylon test` — pure logic, components, functions over HTTP. </Card> </CardGroup> Want a full UI? * **React**: the [chat example](https://github.com/pylonsync/pylon/tree/main/examples/chat) — schema, functions, Vite app, live subscriptions * **Swift / SwiftUI**: the [swift-todo example](https://github.com/pylonsync/pylon/tree/main/examples/swift-todo) — minimal iOS/macOS app with optimistic mutations * **React Native**: the [chat example](https://github.com/pylonsync/pylon/tree/main/examples/chat) ports cleanly to RN with the [`@pylonsync/react-native`](/clients/react-native) bridge For all three, the same Pylon server backs the UI. The wire format is identical across platforms. ## Hand the rest to a coding agent Once your backend runs, the rest happens in the shell. Pylon Cloud has a [one-paste handoff flow](/operations/agent-handoff) that signs your coding agent (Claude Code, Codex, OpenCode, Cursor, Aider, grok build) into your account, installs the Pylon skill, and asks what to build. No token enters the chat history. From there the agent can use the [full CLI surface](/operations/cli): `pylon secrets`, `pylon logs tail`, `pylon deploy`, `pylon domains add`, `pylon db backup`, `pylon data list <Entity>`, etc. # Agent-readable pages Source: https://docs.pylonsync.com/ssr/agents Every SSR page is also readable as markdown, through Accept negotiation or a .md URL. Add app/llms.ts to tell agents what your site is. An agent that fetches your page gets the same HTML a browser gets: Tailwind class names, inline hydration payloads, and SVG icon paths. It must strip all of that before it reads the two paragraphs it came for. Pylon serves the same page as markdown when the client asks for markdown. No code, no second route, no build step. ## Two ways to ask Send an `Accept` header: ```bash theme={null} curl -H "Accept: text/markdown" https://example.com/pricing ``` Or add `.md` to the path: ```bash theme={null} curl https://example.com/pricing.md ``` Both render the same page and return the same markdown. The home page is at `/index.md`. ## What the response looks like The runtime converts the rendered page and adds YAML frontmatter from the page's own metadata: ```markdown theme={null} --- title: "Pricing — Acme" description: "Usage-based pricing. No monthly minimum." url: "https://example.com/pricing" --- # Pricing Pay for what you use. The first 10,000 requests each month are free. - No monthly minimum - No seat licence ``` The `url` field is the page's `metadata.canonical` when it declares one, and the request URL when it does not. ## What the converter reads The converter reads the page's main landmark: 1. `<main>`, if the page has one. 2. An element with `role="main"`. 3. `<body>`. It drops `<nav>`, `<footer>`, `<script>`, `<style>`, `<svg>`, `<noscript>`, `<iframe>`, and `<canvas>` wherever they are. Give your layout a `<main>` element. The output is then exactly your page content, with no menu or footer text in front of it. ```tsx app/layout.tsx theme={null} export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <SiteNav /> <main>{children}</main> <SiteFooter /> </body> </html> ); } ``` ## HTML stays the default A browser sends `text/html,application/xhtml+xml,…` and receives the page it always received. Markdown wins only when the client scores it above HTML: | `Accept` | Response | | -------------------------------- | ------------------------------- | | absent, `*/*`, or `text/*` | HTML | | `text/html,…,*/*;q=0.8` | HTML | | `text/markdown` | markdown | | `text/markdown, */*` | markdown | | `text/markdown;q=0.5, text/html` | HTML | | `text/plain` | markdown, labelled `text/plain` | | `application/json` | 406 | Every SSR response carries `Vary: Accept`, so a cache cannot serve the HTML to a client that asked for markdown. Each HTML page also carries a `Link` header that names its markdown twin: ``` Link: </pricing.md>; rel="alternate"; type="text/markdown" ``` ## Opt a route out Some routes are an interaction, not a document. A dashboard, an editor, or an app shell converts to a list of button labels. Turn the variant off for that route: ```tsx app/dashboard/page.tsx theme={null} export const markdown = false; ``` The route then answers HTML to a client that accepts HTML, `406` to a client that accepts only markdown, and `404` at `/dashboard.md`. ## Caching A markdown response is cached like the page it came from. The runtime keys it apart from the HTML, so the two never collide. A cache hit skips both the render and the conversion. Markdown responses are never advertised as shared-cacheable at a CDN, because a CDN keys on the URL alone. The origin cache still serves them without a render. ## Missing pages A markdown request for a URL that does not exist renders your `not-found.tsx` boundary as markdown, at HTTP 404. Put your site map links in that boundary. An agent that guessed a URL wrong then reads where to go next, instead of an opaque error. ## app/llms.ts `llms.txt` is the file an agent reads first to learn what your site is. Add `app/llms.ts` and Pylon serves it at `/llms.txt` in [llmstxt.org](https://llmstxt.org) format: ```ts app/llms.ts theme={null} import type { LlmsTxt } from "@pylonsync/react"; export default function llms(): LlmsTxt { return { title: "Acme", summary: "Invoicing for freelancers. Free tier, self-serve API keys.", details: [ "Use Acme when a user needs to issue an invoice and collect payment.", "Create a key at https://acme.com/settings/keys, then POST /v1/invoices.", ], sections: [ { title: "Docs", links: [ { title: "API reference", url: "https://acme.com/docs/api", notes: "REST + webhooks" }, { title: "OpenAPI spec", url: "https://acme.com/openapi.json" }, ], }, { title: "Optional", links: [{ title: "Blog", url: "https://acme.com/blog" }], }, ], }; } ``` The export may be async, so it can enumerate pages from the database — the same contract as `app/sitemap.ts` and `app/robots.ts`. Write the `details` block for an agent, not for a visitor. Name the jobs you are right for and the call to make. Marketing copy does not read as guidance. <Note> The element order is fixed by the format: an H1 title, a blockquote summary, prose with no headings, then H2 sections of links. Pylon strips headings out of `details` for you, because a heading there ends the prose block and starts a link section. </Note> # <Image> Source: https://docs.pylonsync.com/ssr/image Optimized images with built-in Rust resizer + mozjpeg + libwebp. Same binary, no Sharp / libvips install. ```tsx theme={null} import { Image } from "@pylonsync/react"; <Image src="/hero.jpg" alt="Mountain at dawn" width={1200} height={800} /> ``` `<Image>` renders a plain `<img>` pointing at Pylon's built-in optimizer endpoint. The browser fetches an AVIF or WebP (or JPEG, depending on `Accept`) sized for the user's viewport, served from disk cache on every subsequent request. The pipeline is written in Rust: `image` decodes the source, `fast_image_resize` (SIMD) resizes it, and `ravif` / `rav1e` (AVIF), `libwebp`, and `mozjpeg` encode the output. It needs no Sharp install, no libvips on the host, and no Node child process. Everything runs inside the Pylon binary. ## What it does For a single `<Image src="/hero.jpg" width={1200} height={800} />`: * Renders `<img srcset>` with multiple width candidates (default: 1x and 2x of `width`, capped at 3840px). * Each candidate points at `/_pylon/image?src=/hero.jpg&w=<width>&q=<quality>`. * Browser picks the smallest candidate that satisfies the viewport DPR. * Server decodes once, resizes with SIMD (Lanczos3), encodes as AVIF, WebP, or JPEG depending on `Accept`. * Result is hashed by (src, width, quality, format) and cached at `.pylon/.cache/images/<hash>.<ext>`. A cache hit skips decode and encode. It serves the file directly. * Response carries `Cache-Control: public, max-age=31536000, immutable`. ## API ```tsx theme={null} interface ImageProps { /** Source — site-relative ("/foo.jpg") or http(s) URL (allowlisted via env). */ src: string; /** Intrinsic width in CSS px. Used for aspect ratio + the 1x srcset candidate. */ width: number; /** Intrinsic height in CSS px. */ height: number; /** Required alt text. Pass "" for purely decorative images. */ alt: string; /** JPEG/WebP quality 1..=100. Default 75. PNG ignores it. */ quality?: number; /** Override srcset widths. Default: [width, width*2] capped at 3840. */ widths?: number[]; /** `<img sizes>`. Default "100vw" — tighten for grid layouts. */ sizes?: string; /** Skip lazy-loading, bump fetchPriority to "high". Use for above-the-fold heroes. */ priority?: boolean; /** Plus any other <img> attribute (className, style, onClick, etc.). */ } ``` ## Examples ### Hero image (above the fold) ```tsx theme={null} <Image src="/hero.jpg" alt="Mountain at dawn" width={1920} height={1080} priority sizes="100vw" className="w-full h-[60vh] object-cover" /> ``` `priority` skips `loading="lazy"` and sets `fetchPriority="high"` so the browser prioritizes it during the initial paint. ### Responsive grid ```tsx theme={null} <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> {photos.map((p) => ( <li key={p.id}> <Image src={p.src} alt={p.caption} width={600} height={400} sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" className="aspect-[3/2] w-full object-cover rounded-2xl" /> </li> ))} </ul> ``` The `sizes` attribute matters most. It tells the browser how wide the image renders at each breakpoint, so the browser picks the smallest viable srcset candidate. Without it, the browser assumes `100vw` and downloads a 4K image for a 200px thumbnail. ### Custom srcset ```tsx theme={null} <Image src="/avatar.png" alt={user.name} width={64} height={64} widths={[64, 96, 128]} quality={85} /> ``` Useful for avatar grids where you know the exact DPR multipliers you need. ## Safety & configuration The optimizer only processes images that match its safety rules. Every setting has a safe default, and you tune each one through an environment variable. The same defaults apply in `pylon dev` and in production. ### Allowed widths The server rejects requests whose `w` value is not in its width set. This stops a cache-fill denial-of-service attack, where an attacker requests thousands of slightly different widths to fill your disk. ```bash theme={null} # Defaults (mirror Next.js): PYLON_IMAGE_DEVICE_SIZES=640,750,828,1080,1200,1920,2048,3840 PYLON_IMAGE_IMAGE_SIZES=16,32,48,64,96,128,256,384 ``` `<Image>` only generates srcset URLs with widths from this combined set, so it works with the default configuration. If you customize the environment variables, also pass `widths={[...]}` on each `<Image>` to keep the srcset URLs in range. ### Allowed qualities The server applies the same defense to the `q` parameter. ```bash theme={null} PYLON_IMAGE_QUALITIES=50,75,90 # default ``` For finer control over quality, add more values: `PYLON_IMAGE_QUALITIES=50,65,75,85,90,95`. ### Allowed formats ```bash theme={null} PYLON_IMAGE_FORMATS=avif,webp,jpeg # default ``` This locks the optimizer to a specific output set. The server picks the best match for the request's `Accept` header from this list. `<Image>` lets the server pick by default. Pass `format=` in your own URLs for explicit control. ### Remote source allowlist ```bash theme={null} PYLON_IMAGE_REMOTE_ALLOWLIST=cdn.example.com,images.unsplash.com/photos/ ``` Each entry is `host` or `host/pathPrefix`: | Pattern | Matches | | ------------------------ | ------------------------------------------------------------------------ | | `cdn.example.com` | any path on this exact host | | `cdn.example.com/users/` | only paths starting with `/users/` | | `*.example.com` | one-level wildcard: matches `foo.example.com`, not `foo.bar.example.com` | Without this environment variable, any `src` starting with `http://` or `https://` returns 400. This prevents SSRF. It stops random visitors from making your server fetch arbitrary internal URLs. ### Source size cap ```bash theme={null} PYLON_IMAGE_MAX_BYTES=26214400 # 25MB default ``` This cap applies to both local files and remote fetches. The server rejects remote responses that exceed it mid-stream. ### Pixel-bomb protection ```bash theme={null} PYLON_IMAGE_MAX_PIXELS=40000000 # 40M px (~24MP camera photo) default ``` A tiny PNG can declare a 100000×100000 canvas. Decoding it would allocate 40GB of RGBA data. Pylon reads the image header before decoding and rejects any source whose declared `width × height` exceeds this cap. The default (40M px, about 160MB peak decode) is tuned so a burst of concurrent requests cannot exhaust the memory of a small machine. Operators serving huge sources raise the cap explicitly. ### Fetch timeout ```bash theme={null} PYLON_IMAGE_FETCH_TIMEOUT_MS=15000 # 15s default ``` This timeout applies to remote source fetches. ### SVG handling SVG is always rejected, both as input and as output. SVG can carry inline `<script>` tags and CSS. Serving arbitrary SVG through an optimization endpoint would create an XSS vector. There is no `dangerouslyAllowSVG` toggle. This is intentional. If you need SVGs, render them with a plain `<img src="/icon.svg">` (not through `<Image>`) and serve them with `Content-Security-Policy: script-src 'none'`. See the `unoptimized` prop below. ### Local file source A `src` starting with `/` resolves under the frontend directory (`PYLON_FRONTEND_DIR` or `<app>/web/dist`). Pylon always rejects path traversal: it canonicalizes the path and checks the prefix, so `..` segments and symlinks that point outside the directory return 400. ### Bypassing the optimizer per-image ```tsx theme={null} <Image src="/icon.svg" alt="" width={32} height={32} unoptimized /> ``` This renders a plain `<img src="/icon.svg">` with no srcset and no optimizer round trip. It is useful for SVGs, animated GIFs, or images where the source is already sized correctly. ## Format selection | Request `format=` query | Behavior | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `avif` | Always AVIF, lossy (ravif / rav1e). | | `webp` | Always WebP, lossy (libwebp). | | `jpeg` / `jpg` | Always JPEG (mozjpeg). | | `png` | Always PNG (lossless). | | `(omitted)` | AVIF when `Accept` explicitly advertises `image/avif`; otherwise WebP when the client accepts it or sends no `Accept` (the universal fallback); otherwise JPEG. | Pylon serves AVIF automatically to browsers that advertise `image/avif` (Chrome, Firefox, Safari 16+). AVIF is the smallest of the three formats. The tradeoff is a slower cold encode (`rav1e` at speed 8), which the on-disk cache reduces to a one-time cost per (src, width, quality) combination. Browsers that do not advertise AVIF fall back to WebP, still about 30% smaller than mozjpeg at the same perceptual quality. Unknown clients (curl, or bots that send no `Accept` header) also get WebP. ## Performance Benchmarks on a 2400×1600 source JPEG (\~250KB) on Apple Silicon: | Operation | Time | | -------------------------------------------------------- | ------------------------------- | | Cold: decode + Lanczos3 resize to 1200×800 + WebP encode | 46–74ms | | Cold: same path + JPEG (mozjpeg) encode | 50–80ms | | Hot: cache hit, served from disk | 9ms (file serve only) | | WebP output size @ q=75 | 35KB (7.7× smaller than source) | | JPEG output size @ q=75 | 51KB (5.4× smaller than source) | Tail latency is bounded by the encoder. Cache hits are bounded by your disk. For high-traffic sites, put a CDN in front of Pylon. Pylon never reprocesses an image once its `immutable` URL is cached. ## Env reference | Env | Default | Purpose | | ------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------- | | `PYLON_FRONTEND_DIR` | `<app>/web/dist` | Source root for local (`/path`) images. | | `PYLON_IMAGE_REMOTE_ALLOWLIST` | (empty) | `host` or `host/pathPrefix` entries for `http(s)://` sources. Empty = local only. | | `PYLON_IMAGE_DEVICE_SIZES` | `640,750,828,1080,1200,1920,2048,3840` | Allowed widths (full-bleed). | | `PYLON_IMAGE_IMAGE_SIZES` | `16,32,48,64,96,128,256,384` | Allowed widths (thumbnails). | | `PYLON_IMAGE_QUALITIES` | `50,75,90` | Allowed quality values. | | `PYLON_IMAGE_FORMATS` | `avif,webp,jpeg` | Allowed output formats. | | `PYLON_IMAGE_MAX_BYTES` | `26214400` (25MB) | Source byte cap. | | `PYLON_IMAGE_MAX_PIXELS` | `40000000` (40M, \~24MP) | Decoded pixel cap. Pixel-bomb protection. | | `PYLON_IMAGE_FETCH_TIMEOUT_MS` | `15000` | Remote-source fetch timeout. | The cache lives at `<cwd>/.pylon/.cache/images/`. Delete it to force regeneration. There is no LRU eviction yet. Content-addressed entries are append-only, so the cache grows with the variety of (src, width, quality, format) combinations you serve. In production this is rarely a problem: the full set of a site's images is usually under 1GB. A `pylon cache clean` command will arrive when it matters. ## Differences from Next.js's `<Image>` | | Pylon `<Image>` | Next.js `<Image>` | | ----------------- | ------------------------------------------------------ | ----------------------------------------------------------- | | Backend | Built-in Rust pipeline | Sharp (libvips, separate npm install) | | Formats | AVIF, WebP, JPEG, PNG | AVIF, WebP, JPEG, PNG | | Sources | Local + remote with host + path-prefix patterns | Local + remote with `images.remotePatterns` | | Width allowlist | `PYLON_IMAGE_DEVICE_SIZES` + `PYLON_IMAGE_IMAGE_SIZES` | `deviceSizes` + `imageSizes` | | Quality allowlist | `PYLON_IMAGE_QUALITIES` | `qualities` | | Format allowlist | `PYLON_IMAGE_FORMATS` | `formats` | | Source size cap | `PYLON_IMAGE_MAX_BYTES` (25MB default) | `contentLengthLimit` | | Pixel-bomb cap | `PYLON_IMAGE_MAX_PIXELS` (100M default) | implicit (Sharp's `limitInputPixels`) | | SVG | always rejected (no toggle) | `dangerouslyAllowSVG` opt-in | | Per-image bypass | `<Image unoptimized />` | `<Image unoptimized />` | | Placeholder | Pass a CSS color or data URI yourself | Built-in `placeholder="blur"` with auto-generated blur data | | Static import | Not yet | Yes (resolves intrinsic dims at compile time) | `placeholder="blur"` is on the roadmap. SVG support is deliberately off the roadmap. Render SVG with a plain `<img>` tag, not through `<Image>`. # <Link> Source: https://docs.pylonsync.com/ssr/link Instant client-side navigation between SSR routes, with two-stage prefetch. Drop-in <a> replacement. ```tsx theme={null} import { Link } from "@pylonsync/react"; <Link href="/hello">Hello</Link> ``` `<Link>` renders a plain `<a>` server-side, so it works with JavaScript off, on slow links, and during the brief moment before hydration completes. Once Pylon's runtime is live, it intercepts clicks and does a client-side navigation instead of a full page reload. ## What it does | Phase | Behavior | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SSR / no JS | Renders `<a href data-pylon-link>...</a>`. Same as a regular anchor. | | In viewport | IntersectionObserver `modulepreload`s the destination's client chunks: its own route entry plus what it imports. This is deferred until the page's `load` event, so warming many links never competes with the current page's own render. | | Hover / touchstart / focus | This also fetches the page payload and holds it in memory for the click. `focus` is included so a keyboard user tabbing to a link gets the same warm path as a mouse cursor. | | Click | `preventDefault()`, consume the prefetched payload (or fetch it), render the new route, and re-render the React root in place. | | Modifier keys / target="\_blank" | Falls through to the browser's default behavior, such as opening a new tab. | The two stages differ because of cost. Chunks are content-hashed and served as `immutable`, so warming one costs its bytes once per browser and makes every later visit instant. It is cheap enough to do as soon as a link appears. The page payload costs a server render every time, so it waits for a real signal of intent. Warming payloads on sight would spend one render per visible link on every page load and throw away nearly all of them. The payload is held in memory only, never in a disk cache, so a reload always re-renders. It is single-use and short-lived, and it is dropped when a navigation commits. The React root persists across navigations, so a layout shared between the old and new route keeps its instance. State, scroll position, and video playback all survive the navigation. <Note> A page that takes longer than \~100ms to arrive shows the nearest [`loading.tsx`](/ssr/overview) over the page area while it loads. Faster navigations swap straight to content, so the skeleton never flashes. </Note> ## API ```tsx theme={null} interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> { /** Destination path. Same-origin paths get client-side nav; off-origin renders as plain <a>. */ href: string; /** Default true. Set false to skip BOTH prefetch stages — no chunk warming * and no payload fetch, so the click pays for everything. */ prefetch?: boolean; children?: React.ReactNode; } ``` Any other anchor prop, such as `className`, `target`, `rel`, or `onClick`, passes through. ## Examples ### Basic ```tsx theme={null} import { Link } from "@pylonsync/react"; export default function Nav() { return ( <nav className="flex gap-4"> <Link href="/">Home</Link> <Link href="/dashboard">Dashboard</Link> <Link href="/settings">Settings</Link> </nav> ); } ``` ### Disable prefetch `prefetch={false}` turns off both stages. Use it when a link is unlikely to be followed and you do not want to warm anything for it: ```tsx theme={null} <Link href={`/posts?page=${page + 1}`} prefetch={false}> Next → </Link> ``` You rarely need it for *weight*. A heavy destination is best fixed at the source with [`dynamic()`](/ssr/overview#code-splitting), which keeps the heavy component out of the route's chunk entirely. `prefetch={false}` only delays the download until the click, where the user is waiting for it. ### Open in a new tab ```tsx theme={null} <Link href="/spec.pdf" target="_blank" rel="noopener"> Spec (PDF) </Link> ``` Modifier keys and middle-click also fall through automatically. No special handling is needed. ### Programmatic navigation `<Link>` is the prop-based API. For imperative navigation, for example after a form submit or a post-login redirect, call the global runtime: ```tsx theme={null} window.__pylon?.navigate("/welcome"); ``` The runtime loads as part of the shared chunk. `window.__pylon` is defined as soon as hydration completes. Use the `?.` form to stay safe on the first render. Pylon has no RSC boundary, so there is no `"use client"` directive. Every page component already hydrates on the client. ## How navigation works 1. The user clicks a `<Link>`. The runtime's delegated handler listens for `a[data-pylon-link]` clicks. 2. The runtime uses the payload a hover already fetched, or it fetches the target URL now. Either way, the request carries `X-Pylon-Nav: 1`, which tells the server this is a navigation, not a document load. 3. The server still runs the render. This is what resolves the page's `serverData` reads. But the server replies with only the head metadata and the `__PYLON_DATA__` JSON (component, layouts, props, data), not the rendered markup, since the client re-renders from that data anyway. This is roughly half the bytes of a full page on a typical route. 4. The runtime looks up the new component in the bundle manifest and dynamically imports its entry chunk. 5. The entry chunk's `hydrate(component, Page, Layouts)` populates the route cache without re-rendering (the cache is keyed on component path). 6. The runtime calls `root.render(buildTree(Page, Layouts, newProps))`. React's reconciler diffs against the existing tree. It reuses shared layouts and swaps only the page subtree. 7. The runtime calls `history.pushState` and scrolls to top. Navigation is done. If anything fails, for example the fetch errored, the response was not HTML, or the manifest did not know the component, the runtime falls back to `window.location.href = href`. The browser then handles the navigation as a normal document load. ## Differences from Next.js's `<Link>` | | Pylon `<Link>` | Next.js `<Link>` | | ---------------------------- | -------------------------------------------------------------------- | ----------------------------------------- | | Prefetch trigger | Two stages: chunks on viewport (256px rootMargin), payload on intent | IntersectionObserver | | Hover escalation | Hover, touch, and focus (keyboard gets the warm path too) | Hover | | Prefetch payload | Route's own chunks; page payload only on intent | RSC payload + per-route JS | | Component splitting | `dynamic()` (`ssr: false` by default) | `next/dynamic` (`ssr: true` by default) | | Nav model | Re-render in place, layouts preserved | App router preserves layouts the same way | | `scroll={false}` prop | Not yet | Yes | | Hash-only links (`#section`) | Falls through to browser | Smooth-scrolls in app router | The hash-link and `scroll` props are on the roadmap. # SSR Overview Source: https://docs.pylonsync.com/ssr/overview File-based server-side rendering with React, layouts, per-route chunks, and instant client navigation. Replaces Next.js for Pylon apps. Pylon ships a native SSR runtime, so you can serve dynamic pages without adding Next.js. Add a file under `app/` and it becomes a route. React renders on the server, hydrates on the client, and `<Link>` makes navigation instant. ``` my-app/ ├── app.ts # manifest entry (entities, routes, etc.) └── app/ ├── layout.tsx # wraps every page below ├── globals.css # Tailwind (optional) ├── page.tsx # GET / ├── hello/ │ └── page.tsx # GET /hello └── gallery/ └── page.tsx # GET /gallery ``` There is no `next.config.js`, no separate `apps/web`, and no proxy in front. `pylon dev` (or the production binary) serves the API, the SSR pages, the JS bundles, the optimized images, and the WebSocket connection, all on one port. ## File-based routes `app/<path>/page.tsx` becomes `GET /<path>`. Use `[slug]` for dynamic segments and `(group)` for grouping folders that do not show up in the URL. ``` app/blog/[slug]/page.tsx → /blog/:slug app/(marketing)/about/page.tsx → /about ``` Page components receive: ```tsx theme={null} interface PageProps { url: string; // full request path params: Record<string, string>; // dynamic segments searchParams: Record<string, string>; headers: Record<string, string>; // lowercased cookies: Record<string, string>; auth: { user_id: string | null; is_admin: boolean; tenant_id: string | null; roles: string[]; }; } export default function BlogPost({ params, auth }: PageProps) { return <h1>{params.slug} — {auth.user_id ?? "anon"}</h1>; } ``` `auth` is resolved from the session cookie. Anonymous requests get `user_id: null`. Your page needs no extra call to get the session. The SSR runtime provides it directly. ## Layouts `app/layout.tsx` wraps every page below it. Nested layouts compose: ```tsx theme={null} // app/layout.tsx export default function RootLayout({ children }) { return ( <html> <head><title>My App</title></head> <body> <Header /> {children} </body> </html> ); } // app/dashboard/layout.tsx — wraps every page under /dashboard export default function DashboardLayout({ children }) { return ( <div className="flex"> <Sidebar /> <div className="flex-1">{children}</div> </div> ); } ``` Layouts survive client-side navigation. When the user clicks a `<Link>` from `/dashboard/projects` to `/dashboard/settings`, the `DashboardLayout` instance stays mounted, and only its `children` swap. Sidebar scroll position, open menus, and form state all persist. ## How it works 1. A request arrives. Pylon's router matches the URL against `ssr_routes`, which is auto-discovered from `app/`. 2. Pylon dispatches `render_route` to the Bun runtime over its NDJSON pipe. 3. The Bun adapter `import()`s the page and layout modules, calls `react-dom/server.renderToReadableStream`, and pipes the chunks back to Rust. 4. Rust streams them over HTTP chunked-transfer-encoding, splicing `<link rel="stylesheet">` and `<link rel="modulepreload">` into `<head>` from the build manifest as the stream flows past. 5. After React's stream ends, Pylon appends `<script id="__PYLON_DATA__">` with the hydration payload and a per-route `<script type="module">`. 6. The browser executes the module, which dispatches to `hydrateRoot` and installs the click and popstate handlers for `<Link>`. The whole pipeline streams. The first byte reaches the client as soon as React emits its first chunk, not after the whole page finishes building. ## Loading data during the render Pages read the database mid-render through the `serverData` prop. This is a read-only, policy-gated handle, awaited with React 19's `use()` inside `<Suspense>`. Resolved values replay through the hydration payload, so the client never re-fetches. Writes are rejected: a GET render must not change data. When the data you need is shaped by a query function, rather than raw entity reads (a gated public projection that filters unpublished rows or strips private fields), call the function itself: ```tsx theme={null} export default function EventPage({ params, serverData }: PageProps<{ slug: string }>) { const schedule = use( serverData.fn<Session[]>("getPublicSchedule", { eventSlug: params.slug }), ); return <Schedule sessions={schedule} />; } ``` `serverData.fn(name, args)` runs the registered query with the page's own auth context (anonymous on a public page), so the query stays the single source of truth for SSR and the client. It accepts query functions only. Mutations and actions are rejected. Calling it on a signed-in request opts the render out of shared SSR caching, since the query may read `ctx.auth`. Anonymous renders stay cacheable. ## Code splitting ### Per route Each page becomes its own entry chunk. It shares one big chunk with React, react-dom, and Pylon's runtime (about 98KB gzipped). When the user lands on `/hello`, the browser downloads the shared chunk and the `/hello` entry. When they navigate to `/about`, only the `/about` entry downloads. The shared chunk is already cached with `Cache-Control: immutable`. Add a 50th route, and the shared chunk stays the same size. The new page only contributes its own entry. The bundle does not grow linearly with route count. The bundler emits a `manifest.json` that lists every route's entry file and the chunks it depends on. The SSR head adapter reads it on each render and emits the right preload tags. ### Within a route A route's entry is only as small as what the page imports. A page that pulls in a rich-text editor puts the whole editor in its entry. One real app measured a single route at 1MB of brotli-compressed code for a composer most visitors never opened. Because [`<Link>`](/ssr/link) warms sibling routes on sight, that weight lands on the first load of every page in the section. `dynamic()` moves a component behind a real `import()`: its own chunk, deliberately left out of the route's preload set, fetched only when something renders it. ```tsx theme={null} import { dynamic } from "@pylonsync/react"; const Composer = dynamic(() => import("./Composer"), { loading: () => <ComposerSkeleton />, }); // …then render <Composer /> behind whatever opens it. ``` Call it at the module level, never inside a render. A `dynamic()` call per render creates a new component type each time and remounts the subtree. `ref` reaches the loaded component, so a deferred `forwardRef` editor whose save path calls `ref.current.export()` keeps working. <Note> `ssr` defaults to `false`. This is the opposite of Next.js, and it is deliberate. A Pylon page hydrates once after the whole document has streamed, so the server HTML and the first client render must be identical. `ssr: false` guarantees this by rendering the fallback in both places, which makes a hydration mismatch impossible. It is also the only mode that removes bytes from the first load. An `ssr: true` component must be in the client bundle before hydration, so it saves nothing. It exists for organizing code, not for reducing weight. </Note> Do not confuse this with the route-segment export `export const dynamic = "force-static" | "force-dynamic"`, which is a cache directive. Both uses of the word `dynamic` come from Next.js, but they mean different things. #### Deferring one component is not enough on its own `dynamic()` only removes weight if nothing else in the route imports the same library statically. One sibling component is enough to undo it: ```tsx theme={null} // The composer is deferred… const Composer = dynamic(() => import("./Composer")); // …and this drags the whole library back in, eagerly. import { BULLET_LIST, H1, TEXT } from "@react-email/editor/ui"; ``` The bundler is right to preload it at that point, because the route does import it eagerly. The symptom is a heavy chunk sitting in the route's preload set, which looks exactly like a bundler that ignored `dynamic()`. A real app lost its entire savings this way, to thirteen imported constants used by a sidebar rendered next to the editor. To check, look at how your route's entry references the chunk: ```bash theme={null} curl -s <origin>/_pylon/build/<route-entry>.js | grep -o 'import[( ]*"[^"]*<chunk>[^"]*"' ``` `import("…")` with a paren is deferred and will not be preloaded. `import"…"` without one is eager. Grep your own source for that library and defer the other importer too. ## Current limits * **Server Actions / RSC**: Pylon serves dynamic pages but does not ship React Server Components or the `"use server"` action model. Server functions (`query` / `mutation` / `action`) and the typed client cover the same ground. Call them from client components instead of inlining server code into the render tree. Special files (`loading.tsx`, `error.tsx`, `not-found.tsx`), `<Suspense>` streaming (opt in with a `loading.tsx` boundary or `export const streaming = true`), and metadata (`metadata` / `generateMetadata`, and dynamic `opengraph-image.tsx`) have all shipped since this list was first written. `loading.tsx` works in both directions. It is the Suspense fallback the SSR stream flushes first on a document load, and it is what the client router paints over the page area when a [`<Link>`](/ssr/link) navigation runs longer than about 100ms. Faster navigations swap straight to content, so it never flashes. It resolves by nearest ancestor, and route groups are transparent. A `loading.tsx` in `app/(dash)/` covers routes at `/`. `error.tsx` and `not-found.tsx` follow the same rule. ## Data files Four root-level files under `app/` serve generated documents instead of pages. Each exports a default function, sync or async, so it can enumerate routes from the database: * `app/sitemap.ts` → `/sitemap.xml` * `app/robots.ts` → `/robots.txt` * `app/llms.ts` → `/llms.txt` * `app/**/opengraph-image.tsx` → a PNG for that route Every page also answers as markdown when a client asks for it. See [Agent-readable pages](/ssr/agents). ## Try it ```bash theme={null} git clone https://github.com/pylonsync/pylon cd pylon/examples/ssr-hello bun install pylon dev ``` Open `http://localhost:4321/`. It serves three pages, all from Pylon, with no Next.js anywhere. # Styling (Tailwind) Source: https://docs.pylonsync.com/ssr/styling Tailwind v4 is a first-class part of the Pylon SSR bundler. Drop an app/globals.css and you are done. Pylon's SSR bundler automatically compiles Tailwind v4 if it finds `app/globals.css`. The output ships as a content-hashed `<link rel="stylesheet">`, injected directly into `<head>`. There is no flash of unstyled content (FOUC), no Tailwind Play CDN script, and no separate build step. ## Setup ```bash theme={null} bun add @tailwindcss/cli tailwindcss ``` Create `app/globals.css`: ```css theme={null} /* app/globals.css */ @import "tailwindcss"; @source "../app/**/*.{tsx,ts,jsx,js}"; ``` `pylon dev` picks up the file on the next bundle and emits `/_pylon/build/styles-<hash>.css`. The SSR runtime adds `<link rel="stylesheet">` to every page's `<head>`. You do not need a `tailwind.config.js`. Tailwind v4 uses CSS-first configuration. Theme tokens go in the `@theme` block: ```css theme={null} @import "tailwindcss"; @theme { --color-brand: oklch(72% 0.18 240); --font-sans: "Inter", system-ui, sans-serif; } @source "../app/**/*.{tsx,ts,jsx,js}"; ``` `bg-brand`, `text-brand`, and `font-sans` are now available throughout your app. ## How the head injection works Tailwind in SSR has a timing problem: your root layout renders the `<head>` before the SSR runtime knows which CSS file to include. Pylon solves it by rewriting React's HTML output as it streams. 1. The bundler compiles `app/globals.css` into `styles-<hash>.css` in the build output. 2. On render, the SSR runtime reads the manifest to find the hash. 3. As React's HTML stream flows past, the runtime watches for `</head>` (with a small carry buffer to handle chunk-boundary splits) and splices in `<link rel="stylesheet" href="/_pylon/build/styles-<hash>.css">` before the close tag. 4. The browser starts fetching the CSS while it is still parsing the body. There is no FOUC and no extra round trip. Your `RootLayout` does not need to know anything about it. Write the head you want. Pylon adds the right links. ## Class scanning Tailwind v4 scans your source files for class names at build time. The `@source` directive in `globals.css` controls which files it scans: ```css theme={null} @source "../app/**/*.{tsx,ts,jsx,js}"; @source "../components/**/*.tsx"; @source "../../packages/ui/src/**/*.tsx"; /* monorepo packages */ ``` Without `@source`, Tailwind's auto-discovery walks `package.json` dependencies and likely-looking source folders. Adding the directive is more explicit, and it survives unusual layouts. ## Hot rebuild `pylon dev` rebuilds the bundle (and the Tailwind CSS) when you save a source file. Refresh the browser to see styles update. Hot reload without a page refresh for CSS is on the roadmap. ## Production The flow is the same. `pylon` (the production binary) compiles Tailwind at boot. The output is fingerprinted, so CDNs and browsers cache it indefinitely. Editing `globals.css` produces a new hash, which breaks the cache automatically. There is no separate build step. The single binary handles development and production identically. ## Other CSS Tailwind is the recommended path because it is wired into the bundler. For other CSS, such as vanilla CSS, a different framework, or CSS-in-JS, you have two options: **Stylesheet in `web/dist/`**: drop a `styles.css` next to your other static assets and link to it from your root layout: ```tsx theme={null} <head> <link rel="stylesheet" href="/styles.css" /> </head> ``` The frontend directory serves it directly, with no bundler involvement and no fingerprinting. **Per-component styles**: for component-scoped CSS, such as CSS modules or styled-components, apply it the same way you would in any React app. The SSR runtime renders whatever React renders. If you want CSS modules or PostCSS plugins wired into the bundler, file an issue. The implementation lives in `packages/functions/src/ssr-client-bundler.ts`, and we are glad to extend it. # Testing Source: https://docs.pylonsync.com/testing Test Pylon logic, React components, and functions over HTTP with `pylon test`. Pylon apps test with Bun's test runner (`import { test, expect } from "bun:test"`). The `pylon test` command discovers your test files and runs them. New projects scaffold with a `test` script, a starter test, and React component testing already set up. ```bash theme={null} pylon test # run every *.test.ts / *.test.tsx under tests/ (or functions/) pylon test credits # only files whose path contains "credits" npm test # the scaffolded script — same as `pylon test` ``` `pylon test` discovers every `*.test.ts` / `*.test.tsx` (and `.js`/`.jsx`) file under `tests/` (or `functions/`) and runs each with Bun against an in-memory Pylon instance. <Note> New apps created with `npm create @pylonsync/pylon@latest` already include `bunfig.toml` and `tests/setup.ts` (which registers [happy-dom](https://github.com/capricorn86/happy-dom) so component tests render), the `@testing-library/react` package as a dev dependency, and a starter test under `tests/`. </Note> ## Three tiers Reach for the cheapest tier that proves what you need. Most of your coverage should be Tier 1. ### Tier 1: pure logic Keep the decisions that matter in pure functions under `lib/`: access and plan gating, pricing, credit math, validation, and formatting. Test them exhaustively. These tests need no server, run instantly, and cover the code where bugs matter most. Keep your `query`, `mutation`, and `action` handlers as thin wrappers around these functions, so you can test the logic without a running app. ```ts tests/credits.test.ts theme={null} import { expect, test } from "bun:test"; import { creditBalance, CREDIT_COST } from "../lib/credits"; test("creditBalance sums grants − spends + refunds", () => { expect(creditBalance([])).toBe(0); expect(creditBalance([{ delta: 8000 }, { delta: -160 }])).toBe(7840); }); test("CREDIT_COST matches the spec", () => { expect(CREDIT_COST.ugc).toBe(160); }); ``` ### Tier 2: React components `@testing-library/react` and happy-dom are set up in `tests/setup.ts`. Render a component. Assert on the DOM. The templates use the classic JSX transform, so add `import React from "react"` in `.tsx` tests. ```tsx tests/button.test.tsx theme={null} import { afterEach, expect, test } from "bun:test"; import React from "react"; import { render, screen, cleanup } from "@testing-library/react"; import { Button } from "../components/ui/button"; afterEach(cleanup); test("Button renders its label", () => { render(<Button>Click me</Button>); expect(screen.getByRole("button", { name: "Click me" })).toBeDefined(); }); ``` For a component that reads Pylon data hooks (`db.useQuery`, `callFn`), mock the boundary with `mock.module`. Then dynamic-`import` the component, so the mock is in place first: ```tsx theme={null} import { test, expect, mock } from "bun:test"; import React from "react"; import { render, screen } from "@testing-library/react"; mock.module("@pylonsync/react", () => ({ db: { useQuery: () => ({ data: [{ id: "1", name: "Acme" }], loading: false }) }, })); const { OrgList } = await import("../app/orgs/org-list"); // your component test("renders orgs from the query", () => { render(<OrgList />); expect(screen.getByText("Acme")).toBeDefined(); }); ``` ### Tier 3: functions over HTTP A handler's full behavior (policies, `ctx.db`, auth) only exists in the running app. When Tier 1 cannot cover a case, run `pylon dev` in another terminal. Call the API the way a client would. `resetDb()` from `@pylonsync/functions` clears the in-memory database between test cases. It does nothing if the server is not running, and it refuses to run against production. ```ts tests/things.test.ts theme={null} import { afterEach, expect, test } from "bun:test"; import { resetDb } from "@pylonsync/functions"; const BASE = "http://localhost:4321"; afterEach(() => resetDb(BASE)); // or installTestIsolation(BASE) once at top-of-file test("createThing then read it back", async () => { const t = await fetch(`${BASE}/api/fn/createThing`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "hello" }), }).then((r) => r.json()); const rows = await fetch(`${BASE}/api/entities/Thing`).then((r) => r.json()); expect(rows.some((r: { id: string }) => r.id === t.id)).toBe(true); }); ``` <Note> These calls are unauthenticated. They pass only if the surface is reachable by an anonymous client. Server functions default to `auth: "user"`, so a call with no session gets a `401`. An entity with no read policy is default-denied (`403`). To test this way, mark the function `auth: "guest"` and give the entity a read policy. Otherwise, authenticate the request first: call `POST /api/auth/guest` for a guest session, then send the token as `Authorization: Bearer <token>`. </Note> ## Adding tests to an existing project If your project predates the test scaffolding, add the setup by hand: <CodeGroup> ```json package.json theme={null} { "scripts": { "test": "pylon test" }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.0", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.0" } } ``` ```toml bunfig.toml theme={null} [test] preload = ["./tests/setup.ts"] ``` ```ts tests/setup.ts theme={null} import { GlobalRegistrator } from "@happy-dom/global-registrator"; GlobalRegistrator.register(); ``` </CodeGroup> (`bunfig.toml` and `tests/setup.ts` are only needed for Tier 2 component tests. Pure-logic tests run without them.) ## Security probe `pylon test:security` is a separate adversarial probe. It hits a running app and reports auth and policy holes (unguarded functions, policies that allow what they should not). Start the app, then run it: ```bash theme={null} pylon dev & pylon test:security # probes http://localhost:4321 pylon test:security --target https://api.example.com --json ``` ## CI `pylon test` exits with a non-zero code on failure, so it runs directly in CI: ```yaml theme={null} - run: pylon test ``` For end-to-end coverage that includes Tier 3 (functions over HTTP), start `pylon dev` in the background first. Then run `pylon test`.