@pylonsync/create-pylon 0.3.266 → 0.3.268

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/bin/create-pylon.js +77 -14
  2. package/package.json +1 -1
  3. package/templates/b2b/AGENTS.md +61 -0
  4. package/templates/b2b/README.md +62 -0
  5. package/templates/b2b/app/auth-form.tsx +142 -0
  6. package/templates/b2b/app/dashboard/dashboard-client.tsx +192 -0
  7. package/templates/b2b/app/dashboard/page.tsx +63 -0
  8. package/templates/b2b/app/error.tsx +43 -0
  9. package/templates/b2b/app/globals.css +139 -0
  10. package/templates/b2b/app/layout.tsx +71 -0
  11. package/templates/b2b/app/login/page.tsx +47 -0
  12. package/templates/b2b/app/not-found.tsx +29 -0
  13. package/templates/b2b/app/page.tsx +114 -0
  14. package/templates/b2b/app/robots.ts +12 -0
  15. package/templates/b2b/app/signup/page.tsx +44 -0
  16. package/templates/b2b/app/sitemap.ts +27 -0
  17. package/templates/b2b/app.ts +179 -0
  18. package/templates/b2b/components/ui/button.tsx +56 -0
  19. package/templates/b2b/components/ui/card.tsx +90 -0
  20. package/templates/b2b/components.json +20 -0
  21. package/templates/b2b/functions/_keep.ts +13 -0
  22. package/templates/b2b/gitignore +10 -0
  23. package/templates/b2b/lib/utils.ts +10 -0
  24. package/templates/b2b/package.json +33 -0
  25. package/templates/b2b/tsconfig.json +18 -0
  26. package/templates/barebones/AGENTS.md +61 -0
  27. package/templates/barebones/README.md +45 -0
  28. package/templates/barebones/app/error.tsx +43 -0
  29. package/templates/barebones/app/globals.css +139 -0
  30. package/templates/barebones/app/items-client.tsx +96 -0
  31. package/templates/barebones/app/layout.tsx +27 -0
  32. package/templates/barebones/app/not-found.tsx +29 -0
  33. package/templates/barebones/app/page.tsx +28 -0
  34. package/templates/barebones/app/robots.ts +12 -0
  35. package/templates/barebones/app/sitemap.ts +27 -0
  36. package/templates/barebones/app.ts +55 -0
  37. package/templates/barebones/components/ui/button.tsx +56 -0
  38. package/templates/barebones/components/ui/card.tsx +90 -0
  39. package/templates/barebones/components.json +20 -0
  40. package/templates/barebones/functions/_keep.ts +13 -0
  41. package/templates/barebones/gitignore +10 -0
  42. package/templates/barebones/lib/utils.ts +10 -0
  43. package/templates/barebones/package.json +33 -0
  44. package/templates/barebones/tsconfig.json +18 -0
  45. package/templates/chat/AGENTS.md +61 -0
  46. package/templates/chat/README.md +51 -0
  47. package/templates/chat/app/chat-client.tsx +113 -0
  48. package/templates/chat/app/error.tsx +43 -0
  49. package/templates/chat/app/globals.css +139 -0
  50. package/templates/chat/app/layout.tsx +25 -0
  51. package/templates/chat/app/not-found.tsx +29 -0
  52. package/templates/chat/app/page.tsx +26 -0
  53. package/templates/chat/app/robots.ts +12 -0
  54. package/templates/chat/app/sitemap.ts +27 -0
  55. package/templates/chat/app.ts +59 -0
  56. package/templates/chat/components/ui/button.tsx +56 -0
  57. package/templates/chat/components/ui/card.tsx +90 -0
  58. package/templates/chat/components.json +20 -0
  59. package/templates/chat/functions/_keep.ts +13 -0
  60. package/templates/chat/gitignore +10 -0
  61. package/templates/chat/lib/utils.ts +10 -0
  62. package/templates/chat/package.json +33 -0
  63. package/templates/chat/tsconfig.json +18 -0
  64. package/templates/consumer/AGENTS.md +61 -0
  65. package/templates/consumer/README.md +52 -0
  66. package/templates/consumer/app/error.tsx +43 -0
  67. package/templates/consumer/app/feed-client.tsx +154 -0
  68. package/templates/consumer/app/globals.css +139 -0
  69. package/templates/consumer/app/layout.tsx +27 -0
  70. package/templates/consumer/app/not-found.tsx +29 -0
  71. package/templates/consumer/app/page.tsx +27 -0
  72. package/templates/consumer/app/robots.ts +12 -0
  73. package/templates/consumer/app/sitemap.ts +27 -0
  74. package/templates/consumer/app.ts +89 -0
  75. package/templates/consumer/components/ui/button.tsx +56 -0
  76. package/templates/consumer/components/ui/card.tsx +90 -0
  77. package/templates/consumer/components.json +20 -0
  78. package/templates/consumer/functions/_keep.ts +13 -0
  79. package/templates/consumer/gitignore +10 -0
  80. package/templates/consumer/lib/utils.ts +10 -0
  81. package/templates/consumer/package.json +33 -0
  82. package/templates/consumer/tsconfig.json +18 -0
  83. package/templates/ssr/app.ts +3 -0
  84. package/templates/todo/AGENTS.md +61 -0
  85. package/templates/todo/README.md +59 -0
  86. package/templates/todo/app/error.tsx +43 -0
  87. package/templates/todo/app/globals.css +139 -0
  88. package/templates/todo/app/layout.tsx +31 -0
  89. package/templates/todo/app/not-found.tsx +29 -0
  90. package/templates/todo/app/page.tsx +37 -0
  91. package/templates/todo/app/robots.ts +12 -0
  92. package/templates/todo/app/sitemap.ts +27 -0
  93. package/templates/todo/app/todo-app.tsx +133 -0
  94. package/templates/todo/app.ts +72 -0
  95. package/templates/todo/components/ui/button.tsx +56 -0
  96. package/templates/todo/components/ui/card.tsx +90 -0
  97. package/templates/todo/components.json +20 -0
  98. package/templates/todo/functions/_keep.ts +13 -0
  99. package/templates/todo/gitignore +10 -0
  100. package/templates/todo/lib/utils.ts +10 -0
  101. package/templates/todo/package.json +33 -0
  102. package/templates/todo/tsconfig.json +18 -0
@@ -82,24 +82,34 @@ const TEMPLATE_REGISTRY = {
82
82
  unified: true,
83
83
  },
84
84
  barebones: {
85
- blurb: "Single entity, list + create. The smallest working app.",
86
- platforms: ["web", "ios", "mac", "expo"],
85
+ blurb:
86
+ "Single entity, live list + optimistic create. The smallest SSR app, one port.",
87
+ platforms: [],
88
+ unified: true,
87
89
  },
88
90
  todo: {
89
- blurb: "CRUD + drag-reorder + optimistic mutations.",
90
- platforms: ["web", "vite", "ios", "mac", "expo"],
91
+ blurb:
92
+ "Live, optimistic todo list — guest auth, owner-scoped, one server, no Next.js.",
93
+ platforms: [],
94
+ unified: true,
91
95
  },
92
96
  b2b: {
93
- blurb: "Multi-tenant SaaS: orgs, members, roles, RBAC policies.",
94
- platforms: ["web", "mac"],
97
+ blurb:
98
+ "Multi-tenant SaaS — orgs, members, roles, tenant-scoped data. One SSR app.",
99
+ platforms: [],
100
+ unified: true,
95
101
  },
96
102
  consumer: {
97
- blurb: "Social feed: profiles, posts, likes, follows.",
98
- platforms: ["web", "ios", "mac", "expo"],
103
+ blurb:
104
+ "Social feed — live posts + likes, public-read, owner-write. One SSR app.",
105
+ platforms: [],
106
+ unified: true,
99
107
  },
100
108
  chat: {
101
- blurb: "Realtime messaging: rooms, presence, live message feed.",
102
- platforms: ["web", "ios", "mac", "expo"],
109
+ blurb:
110
+ "Realtime chat — a live shared room, optimistic send. One SSR app, one port.",
111
+ platforms: [],
112
+ unified: true,
103
113
  },
104
114
  };
105
115
  const TEMPLATES_AVAILABLE = Object.keys(TEMPLATE_REGISTRY);
@@ -116,6 +126,15 @@ const flags = {
116
126
  template: takeValue(args, "--template"),
117
127
  platforms: takeValue(args, "--platforms"),
118
128
  skipInstall: args.includes("--skip-install"),
129
+ // --skill / --no-skill: install the Pylon skill into the new project via
130
+ // `npx skills add pylonsync/pylon` — the skills.sh CLI detects the coding
131
+ // agent (Claude Code / Codex / Cursor) and drops the always-current skill
132
+ // from this repo's skills/pylon/SKILL.md. undefined => prompt (default yes).
133
+ skill: args.includes("--skill")
134
+ ? true
135
+ : args.includes("--no-skill")
136
+ ? false
137
+ : undefined,
119
138
  help: args.includes("--help") || args.includes("-h"),
120
139
  };
121
140
 
@@ -154,10 +173,10 @@ ${tmplLines.join("\n")}
154
173
 
155
174
  Examples:
156
175
  npm create @pylonsync/pylon my-app --template ssr # full-stack SSR, no Next.js
176
+ npm create @pylonsync/pylon my-app --template todo # live, optimistic todo (SSR, one port)
157
177
  npm create @pylonsync/pylon my-app
158
- npm create @pylonsync/pylon my-app --template todo --platforms web,ios
159
- npm create @pylonsync/pylon my-app --template b2b --platforms web,mac
160
- npm create @pylonsync/pylon my-app --template chat --platforms ios,mac,expo
178
+ npm create @pylonsync/pylon my-app --template b2b # multi-tenant SaaS (orgs, members, RBAC)
179
+ npm create @pylonsync/pylon my-app --template chat # realtime live chat room
161
180
  `);
162
181
  exit(0);
163
182
  }
@@ -202,6 +221,16 @@ if (!flags.pm) {
202
221
  .toLowerCase();
203
222
  flags.pm = ["bun", "pnpm", "yarn", "npm"].includes(choice) ? choice : def;
204
223
  }
224
+ if (flags.skill === undefined) {
225
+ const ans = (
226
+ await rl.question(
227
+ "Add the Pylon skill to your coding agent (Claude Code / Codex / Cursor)? [Y/n]: ",
228
+ )
229
+ )
230
+ .trim()
231
+ .toLowerCase();
232
+ flags.skill = ans !== "n" && ans !== "no";
233
+ }
205
234
  rl.close();
206
235
 
207
236
  // Unified templates own no platforms — they ARE the whole app. For
@@ -467,6 +496,34 @@ if (!flags.skipInstall) {
467
496
  }
468
497
  }
469
498
 
499
+ // ---------------------------------------------------------------------------
500
+ // Optional: install the Pylon skill into the project's coding agent.
501
+ //
502
+ // `npx skills add pylonsync/pylon` (skills.sh) detects the installed agent
503
+ // (Claude Code / Codex / Cursor) and drops the canonical skill from this
504
+ // repo's skills/pylon/SKILL.md — always current, no stale bundled copy. We
505
+ // only auto-run it when stdin is a TTY: the skills CLI prompts for scope and
506
+ // agent, which would hang a non-interactive scaffold (CI, piped input). When
507
+ // we can't run it, the footer prints the one-liner so the user can opt in.
508
+ // ---------------------------------------------------------------------------
509
+
510
+ const SKILL_INSTALL_CMD = "npx skills add pylonsync/pylon";
511
+ let skillInstalled = false;
512
+ if (flags.skill && stdin.isTTY) {
513
+ console.log("\nAdding the Pylon skill to your coding agent...");
514
+ const { spawnSync } = await import("node:child_process");
515
+ const result = spawnSync("npx", ["-y", "skills", "add", "pylonsync/pylon"], {
516
+ cwd: root,
517
+ stdio: "inherit",
518
+ });
519
+ skillInstalled = result.status === 0;
520
+ if (!skillInstalled) {
521
+ console.warn(
522
+ `\nCouldn't add the skill automatically. Run it yourself anytime:\n ${SKILL_INSTALL_CMD}\n`,
523
+ );
524
+ }
525
+ }
526
+
470
527
  // ---------------------------------------------------------------------------
471
528
  // Final instructions
472
529
  // ---------------------------------------------------------------------------
@@ -512,6 +569,12 @@ if (platforms.includes("mac"))
512
569
  if (platforms.includes("expo"))
513
570
  layoutLines.push(" apps/expo Expo + React Native");
514
571
 
572
+ const skillLine = skillInstalled
573
+ ? "\nPylon skill added to your coding agent (Claude Code / Codex / Cursor).\n"
574
+ : flags.skill
575
+ ? `\nAdd the Pylon skill to your coding agent:\n ${SKILL_INSTALL_CMD}\n`
576
+ : "";
577
+
515
578
  console.log(`
516
579
  ✓ Created ${projectName}
517
580
 
@@ -522,7 +585,7 @@ ${platformLines.join("\n")}
522
585
 
523
586
  Layout:
524
587
  ${layoutLines.join("\n")}
525
-
588
+ ${skillLine}
526
589
  Docs: https://docs.pylonsync.com
527
590
  `);
528
591
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.266",
3
+ "version": "0.3.268",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,61 @@
1
+ # AGENTS.md — working in a Pylon project
2
+
3
+ Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like framework for realtime apps: you declare entities, policies, and server functions in TypeScript, and a single Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR — one process, one port. The full API reference is at **/llms-full.txt** (served at `/llms-full.txt`; in the repo at `apps/web/public/llms-full.txt`). Read it before guessing an API name.
4
+
5
+ ## Directory conventions
6
+
7
+ **Unified SSR app:**
8
+ - `app.ts` — data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
+ - `app/` — file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
+ - `app/globals.css` — Tailwind v4 entrypoint (auto-compiled and injected).
11
+ - `functions/` — server functions, one per file, `default`-exported.
12
+ - `.pylon/` — local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
13
+
14
+ **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated — do not hand-edit.
15
+
16
+ ## The core authoring loop
17
+
18
+ 1. **Define an entity** — `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update — use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
+ 2. **Write a policy** — `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
20
+ 3. **Author a function** in `functions/<name>.ts` — `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
+ 4. **Read it on the client** — `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
22
+
23
+ ## Key gotchas
24
+
25
+ - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access — enforce trust with `ctx.auth` checks inside the handler, not policies.
26
+ - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` — they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
+ - **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) — but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL — skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` — works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model — gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet — use urlencoded forms + `/api/files`.
29
+ - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends — a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
+ - **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED — the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable — don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render — a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
+ - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) — `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
+ - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive — during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
+ - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` — `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
+ - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
+ - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready — status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
+ - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` — use `ctx.runQuery` / `ctx.runMutation`.
37
+ - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
+ - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
39
+
40
+ ## Use the CLI — don't guess
41
+
42
+ | Need | Command |
43
+ |---|---|
44
+ | Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
45
+ | Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
46
+ | Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
47
+ | Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
48
+ | Lint policies (PYL001–PYL004) | `pylon lint --strict` |
49
+ | Tests | `pylon test` |
50
+ | Adversarial security probe | `pylon test:security` |
51
+ | Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
52
+ | Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
53
+ | Call a function | `pylon fn <name> key=value` |
54
+ | Health snapshot | `pylon status` |
55
+ | Build for prod | `pylon build` |
56
+ | Deploy (Pylon Cloud by default) | `pylon deploy` |
57
+ | Look up an error code | `pylon explain <CODE>` |
58
+
59
+ `--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
60
+
61
+ For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **/llms-full.txt**.
@@ -0,0 +1,62 @@
1
+ # __APP_NAME__
2
+
3
+ A multi-tenant SaaS starter on [Pylon](https://pylonsync.com) — email/password
4
+ accounts, organizations with members + roles, and tenant-scoped data, all
5
+ server-rendered from one binary on one port. No Next.js, no separate API server.
6
+
7
+ ## Develop
8
+
9
+ ```bash
10
+ __RUN_DEV__
11
+ ```
12
+
13
+ Open http://localhost:4321. Sign up, create an organization, and you land in a
14
+ workspace with **tenant-scoped projects** and a **members** panel. Create a
15
+ second org and switch between them — each org's projects are private to it.
16
+ Edit any file under `app/` and save — the page reloads instantly.
17
+
18
+ ## Layout
19
+
20
+ ```
21
+ app.ts User + Org/OrgMember/OrgInvite + tenant-scoped Project
22
+ app/page.tsx "/" — server-rendered, auth-aware homepage
23
+ app/login,signup/ email/password (POST /api/auth/password/*)
24
+ app/dashboard/ "/dashboard" — authed; org switcher + projects + members
25
+ app/dashboard/dashboard-client.tsx the workspace client island
26
+ ```
27
+
28
+ ## How multi-tenancy works
29
+
30
+ Organizations are a **framework primitive**. Declaring `Org` / `OrgMember` /
31
+ `OrgInvite` with the framework's field names lights up `/api/auth/orgs/*`
32
+ (create/list orgs, members, invites) and `/api/auth/select-org` (switch your
33
+ active tenant) — driven by `<OrganizationSwitcher>` from `@pylonsync/client`.
34
+
35
+ `select-org` checks your `OrgMember` row before committing, then sets the
36
+ session's `tenantId`. Your data lives in tenant-scoped entities:
37
+
38
+ ```ts
39
+ allowRead: "auth.tenantId == data.orgId"
40
+ allowInsert: "auth.tenantId == data.orgId"
41
+ ```
42
+
43
+ So `db.useQuery("Project")` returns only your **active org's** projects, and a
44
+ client literally cannot read or write another tenant's rows — switch orgs and
45
+ the list changes. **RBAC** is built in too: the framework gates invites/member
46
+ management to org admins, so a `member` calling `createInvite` gets a 403.
47
+
48
+ ## Grow it
49
+
50
+ - **Add tenant data:** new `entity()` with an `orgId` + the same two policy
51
+ lines. That's a new tenant-scoped table.
52
+ - **Custom roles:** read `OrgMember.role` in a server function and gate writes
53
+ with `ctx.elevate({ admin })` / `ctx.db.unsafe.*`.
54
+ - **SSO / SAML:** per-org SSO is built in at `/api/auth/orgs/:id/sso/*`.
55
+
56
+ ## Deploy
57
+
58
+ ```bash
59
+ pylon deploy
60
+ ```
61
+
62
+ Docs: https://docs.pylonsync.com
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { passwordLogin, passwordRegister, ApiError } from "@pylonsync/client";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ // The email/password form, shared by /login and /signup. It calls the built-in
8
+ // auth API directly — `passwordLogin` / `passwordRegister` (from
9
+ // @pylonsync/client) POST to `/api/auth/password/*`.
10
+ //
11
+ // On success the server sets an HttpOnly session cookie on the response. We do
12
+ // a full navigation to /dashboard rather than a client transition: the fresh
13
+ // page load hands that cookie to the SSR runtime (which resolves auth and
14
+ // renders the dashboard server-side) and to the sync engine (which
15
+ // authenticates with the same cookie via `credentials: include`). Because the
16
+ // cookie is HttpOnly it can never be read by JavaScript, so there is no
17
+ // session token sitting in `localStorage` for an XSS to lift. (Cross-origin or
18
+ // native clients, which can't rely on the cookie, use the token-based path via
19
+ // `persistSession` instead — not needed here, same origin.)
20
+ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
21
+ const [email, setEmail] = useState("");
22
+ const [password, setPassword] = useState("");
23
+ const [displayName, setDisplayName] = useState("");
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [pending, setPending] = useState(false);
26
+
27
+ async function onSubmit(e: React.FormEvent) {
28
+ e.preventDefault();
29
+ setError(null);
30
+ setPending(true);
31
+ try {
32
+ if (mode === "login") {
33
+ await passwordLogin({ email, password });
34
+ } else {
35
+ await passwordRegister({
36
+ email,
37
+ password,
38
+ displayName: displayName.trim() || undefined,
39
+ });
40
+ }
41
+ // Full navigation: the SSR dashboard re-renders with the new cookie.
42
+ window.location.assign("/dashboard");
43
+ } catch (err) {
44
+ setError(messageFor(err));
45
+ setPending(false); // keep the form up to retry (success navigates away)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <form onSubmit={onSubmit} className="space-y-4">
51
+ {mode === "signup" ? (
52
+ <Field
53
+ label="Name"
54
+ value={displayName}
55
+ onChange={setDisplayName}
56
+ autoComplete="name"
57
+ placeholder="optional"
58
+ />
59
+ ) : null}
60
+ <Field
61
+ label="Email"
62
+ type="email"
63
+ value={email}
64
+ onChange={setEmail}
65
+ required
66
+ autoComplete="email"
67
+ placeholder="you@example.com"
68
+ />
69
+ <Field
70
+ label="Password"
71
+ type="password"
72
+ value={password}
73
+ onChange={setPassword}
74
+ required
75
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
76
+ placeholder={mode === "signup" ? "at least 8 characters" : undefined}
77
+ />
78
+ {error ? (
79
+ <p className="rounded-md border border-red-600/30 bg-red-600/10 px-3 py-2 text-sm text-red-700">
80
+ {error}
81
+ </p>
82
+ ) : null}
83
+ <Button type="submit" disabled={pending} className="w-full">
84
+ {pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
85
+ </Button>
86
+ </form>
87
+ );
88
+ }
89
+
90
+ function Field({
91
+ label,
92
+ value,
93
+ onChange,
94
+ type = "text",
95
+ required,
96
+ autoComplete,
97
+ placeholder,
98
+ }: {
99
+ label: string;
100
+ value: string;
101
+ onChange: (v: string) => void;
102
+ type?: string;
103
+ required?: boolean;
104
+ autoComplete?: string;
105
+ placeholder?: string;
106
+ }) {
107
+ return (
108
+ <label className="block space-y-1.5">
109
+ <span className="text-sm font-medium">{label}</span>
110
+ <input
111
+ type={type}
112
+ value={value}
113
+ onChange={(e) => onChange(e.target.value)}
114
+ required={required}
115
+ autoComplete={autoComplete}
116
+ placeholder={placeholder}
117
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
118
+ />
119
+ </label>
120
+ );
121
+ }
122
+
123
+ // Map the framework's auth error codes to friendly copy. `ApiError` carries a
124
+ // stable `.code` (and `.status`) so you branch on the code, not the message.
125
+ function messageFor(err: unknown): string {
126
+ if (err instanceof ApiError) {
127
+ switch (err.code) {
128
+ case "INVALID_CREDENTIALS":
129
+ return "Wrong email or password.";
130
+ case "USER_EXISTS":
131
+ return "That email is already in use — sign in instead.";
132
+ case "WEAK_PASSWORD":
133
+ return "Pick a stronger password (at least 8 characters).";
134
+ case "RATE_LIMITED":
135
+ return "Too many attempts — try again in a minute.";
136
+ default:
137
+ return err.message;
138
+ }
139
+ }
140
+ if (err instanceof Error) return err.message;
141
+ return "Something went wrong. Try again.";
142
+ }
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useEffect, useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import {
6
+ useAuth,
7
+ OrganizationSwitcher,
8
+ listOrgMembers,
9
+ createInvite,
10
+ type OrgMember,
11
+ } from "@pylonsync/client";
12
+ import { Button } from "@/components/ui/button";
13
+
14
+ export interface Project {
15
+ id: string;
16
+ orgId: string;
17
+ name: string;
18
+ createdAt: string;
19
+ }
20
+
21
+ // The workspace. `<OrganizationSwitcher>` (from @pylonsync/client) lists your
22
+ // orgs, creates new ones, and switches your active tenant via
23
+ // /api/auth/select-org — all against the framework's built-in org system. The
24
+ // rest of the page keys off `tenantId` (your active org).
25
+ export function Workspace() {
26
+ const { tenantId, signOut } = useAuth();
27
+
28
+ async function onSignOut() {
29
+ await signOut();
30
+ window.location.assign("/");
31
+ }
32
+
33
+ return (
34
+ <div className="space-y-6">
35
+ <div className="flex items-center justify-between gap-3">
36
+ <OrganizationSwitcher />
37
+ <Button variant="ghost" size="sm" onClick={onSignOut}>
38
+ Sign out
39
+ </Button>
40
+ </div>
41
+
42
+ {tenantId ? (
43
+ <div className="grid gap-6 sm:grid-cols-2">
44
+ <Projects orgId={tenantId} />
45
+ <Members orgId={tenantId} />
46
+ </div>
47
+ ) : (
48
+ <div className="rounded-lg border border-dashed px-6 py-10 text-center text-sm text-muted-foreground">
49
+ Create or select an organization above to get started. Each org is an
50
+ isolated tenant — its projects and members are private to it.
51
+ </div>
52
+ )}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ // Tenant-scoped data. `db.useQuery("Project")` returns only your active org's
58
+ // projects (the policy gates on `auth.tenantId == data.orgId`), and switching
59
+ // orgs re-syncs the list. `db.insert` is optimistic; we pass `orgId` = the
60
+ // active tenant so the row lands in this org — the policy rejects any other.
61
+ function Projects({ orgId }: { orgId: string }) {
62
+ const [name, setName] = useState("");
63
+ const { data: all } = db.useQuery<Project>("Project");
64
+ // Defensive filter while a tenant switch re-syncs the local replica.
65
+ const projects = all.filter((p) => p.orgId === orgId);
66
+
67
+ async function add(e: React.FormEvent) {
68
+ e.preventDefault();
69
+ const value = name.trim();
70
+ if (!value) return;
71
+ setName("");
72
+ await db.insert("Project", { orgId, name: value });
73
+ }
74
+
75
+ return (
76
+ <section className="space-y-3">
77
+ <h2 className="text-sm font-semibold">Projects</h2>
78
+ <form onSubmit={add} className="flex items-center gap-2">
79
+ <input
80
+ value={name}
81
+ onChange={(e) => setName(e.target.value)}
82
+ placeholder="New project…"
83
+ aria-label="Project name"
84
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
85
+ />
86
+ <Button type="submit" size="sm">
87
+ Add
88
+ </Button>
89
+ </form>
90
+ {projects.length === 0 ? (
91
+ <p className="text-sm text-muted-foreground">No projects yet.</p>
92
+ ) : (
93
+ <ul className="space-y-1.5">
94
+ {projects.map((p) => (
95
+ <li
96
+ key={p.id}
97
+ className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
98
+ >
99
+ <span className="truncate">{p.name}</span>
100
+ <button
101
+ type="button"
102
+ aria-label="Delete project"
103
+ onClick={() => db.delete("Project", p.id)}
104
+ className="text-muted-foreground/40 transition-colors hover:text-red-600"
105
+ >
106
+
107
+ </button>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ )}
112
+ <p className="text-xs text-muted-foreground">
113
+ Tenant-scoped: only this org&apos;s projects, enforced by policy — switch
114
+ orgs and the list changes.
115
+ </p>
116
+ </section>
117
+ );
118
+ }
119
+
120
+ // Membership + invites go through the framework's /api/auth/orgs/:id endpoints
121
+ // (the @pylonsync/client helpers). The framework gates invites to org admins,
122
+ // so a member calling createInvite gets a 403 — real RBAC, no extra code.
123
+ function Members({ orgId }: { orgId: string }) {
124
+ const [members, setMembers] = useState<OrgMember[] | null>(null);
125
+ const [email, setEmail] = useState("");
126
+ const [note, setNote] = useState("");
127
+
128
+ const refresh = useCallback(() => {
129
+ void listOrgMembers(orgId).then(setMembers);
130
+ }, [orgId]);
131
+
132
+ useEffect(() => {
133
+ setMembers(null);
134
+ refresh();
135
+ }, [refresh]);
136
+
137
+ async function invite(e: React.FormEvent) {
138
+ e.preventDefault();
139
+ const value = email.trim();
140
+ if (!value) return;
141
+ setEmail("");
142
+ setNote("");
143
+ try {
144
+ await createInvite(orgId, value, "member");
145
+ setNote(`Invited ${value}.`);
146
+ refresh();
147
+ } catch {
148
+ setNote("Only org admins can invite members.");
149
+ }
150
+ }
151
+
152
+ return (
153
+ <section className="space-y-3">
154
+ <h2 className="text-sm font-semibold">Members</h2>
155
+ {members === null ? (
156
+ <p className="text-sm text-muted-foreground">Loading…</p>
157
+ ) : (
158
+ <ul className="space-y-1.5">
159
+ {members.map((m) => (
160
+ <li
161
+ key={m.user_id}
162
+ className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
163
+ >
164
+ <span className="truncate font-mono text-xs">
165
+ {shortId(m.user_id)}
166
+ </span>
167
+ <span className="text-xs text-muted-foreground">{m.role}</span>
168
+ </li>
169
+ ))}
170
+ </ul>
171
+ )}
172
+ <form onSubmit={invite} className="flex items-center gap-2">
173
+ <input
174
+ type="email"
175
+ value={email}
176
+ onChange={(e) => setEmail(e.target.value)}
177
+ placeholder="invite by email…"
178
+ aria-label="Invite email"
179
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
180
+ />
181
+ <Button type="submit" size="sm" variant="outline">
182
+ Invite
183
+ </Button>
184
+ </form>
185
+ {note && <p className="text-xs text-muted-foreground">{note}</p>}
186
+ </section>
187
+ );
188
+ }
189
+
190
+ function shortId(id: string) {
191
+ return id.replace(/^user_/, "").slice(0, 10);
192
+ }