@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.
- package/bin/create-pylon.js +77 -14
- package/package.json +1 -1
- package/templates/b2b/AGENTS.md +61 -0
- package/templates/b2b/README.md +62 -0
- package/templates/b2b/app/auth-form.tsx +142 -0
- package/templates/b2b/app/dashboard/dashboard-client.tsx +192 -0
- package/templates/b2b/app/dashboard/page.tsx +63 -0
- package/templates/b2b/app/error.tsx +43 -0
- package/templates/b2b/app/globals.css +139 -0
- package/templates/b2b/app/layout.tsx +71 -0
- package/templates/b2b/app/login/page.tsx +47 -0
- package/templates/b2b/app/not-found.tsx +29 -0
- package/templates/b2b/app/page.tsx +114 -0
- package/templates/b2b/app/robots.ts +12 -0
- package/templates/b2b/app/signup/page.tsx +44 -0
- package/templates/b2b/app/sitemap.ts +27 -0
- package/templates/b2b/app.ts +179 -0
- package/templates/b2b/components/ui/button.tsx +56 -0
- package/templates/b2b/components/ui/card.tsx +90 -0
- package/templates/b2b/components.json +20 -0
- package/templates/b2b/functions/_keep.ts +13 -0
- package/templates/b2b/gitignore +10 -0
- package/templates/b2b/lib/utils.ts +10 -0
- package/templates/b2b/package.json +33 -0
- package/templates/b2b/tsconfig.json +18 -0
- package/templates/barebones/AGENTS.md +61 -0
- package/templates/barebones/README.md +45 -0
- package/templates/barebones/app/error.tsx +43 -0
- package/templates/barebones/app/globals.css +139 -0
- package/templates/barebones/app/items-client.tsx +96 -0
- package/templates/barebones/app/layout.tsx +27 -0
- package/templates/barebones/app/not-found.tsx +29 -0
- package/templates/barebones/app/page.tsx +28 -0
- package/templates/barebones/app/robots.ts +12 -0
- package/templates/barebones/app/sitemap.ts +27 -0
- package/templates/barebones/app.ts +55 -0
- package/templates/barebones/components/ui/button.tsx +56 -0
- package/templates/barebones/components/ui/card.tsx +90 -0
- package/templates/barebones/components.json +20 -0
- package/templates/barebones/functions/_keep.ts +13 -0
- package/templates/barebones/gitignore +10 -0
- package/templates/barebones/lib/utils.ts +10 -0
- package/templates/barebones/package.json +33 -0
- package/templates/barebones/tsconfig.json +18 -0
- package/templates/chat/AGENTS.md +61 -0
- package/templates/chat/README.md +51 -0
- package/templates/chat/app/chat-client.tsx +113 -0
- package/templates/chat/app/error.tsx +43 -0
- package/templates/chat/app/globals.css +139 -0
- package/templates/chat/app/layout.tsx +25 -0
- package/templates/chat/app/not-found.tsx +29 -0
- package/templates/chat/app/page.tsx +26 -0
- package/templates/chat/app/robots.ts +12 -0
- package/templates/chat/app/sitemap.ts +27 -0
- package/templates/chat/app.ts +59 -0
- package/templates/chat/components/ui/button.tsx +56 -0
- package/templates/chat/components/ui/card.tsx +90 -0
- package/templates/chat/components.json +20 -0
- package/templates/chat/functions/_keep.ts +13 -0
- package/templates/chat/gitignore +10 -0
- package/templates/chat/lib/utils.ts +10 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/consumer/AGENTS.md +61 -0
- package/templates/consumer/README.md +52 -0
- package/templates/consumer/app/error.tsx +43 -0
- package/templates/consumer/app/feed-client.tsx +154 -0
- package/templates/consumer/app/globals.css +139 -0
- package/templates/consumer/app/layout.tsx +27 -0
- package/templates/consumer/app/not-found.tsx +29 -0
- package/templates/consumer/app/page.tsx +27 -0
- package/templates/consumer/app/robots.ts +12 -0
- package/templates/consumer/app/sitemap.ts +27 -0
- package/templates/consumer/app.ts +89 -0
- package/templates/consumer/components/ui/button.tsx +56 -0
- package/templates/consumer/components/ui/card.tsx +90 -0
- package/templates/consumer/components.json +20 -0
- package/templates/consumer/functions/_keep.ts +13 -0
- package/templates/consumer/gitignore +10 -0
- package/templates/consumer/lib/utils.ts +10 -0
- package/templates/consumer/package.json +33 -0
- package/templates/consumer/tsconfig.json +18 -0
- package/templates/ssr/app.ts +3 -0
- package/templates/todo/AGENTS.md +61 -0
- package/templates/todo/README.md +59 -0
- package/templates/todo/app/error.tsx +43 -0
- package/templates/todo/app/globals.css +139 -0
- package/templates/todo/app/layout.tsx +31 -0
- package/templates/todo/app/not-found.tsx +29 -0
- package/templates/todo/app/page.tsx +37 -0
- package/templates/todo/app/robots.ts +12 -0
- package/templates/todo/app/sitemap.ts +27 -0
- package/templates/todo/app/todo-app.tsx +133 -0
- package/templates/todo/app.ts +72 -0
- package/templates/todo/components/ui/button.tsx +56 -0
- package/templates/todo/components/ui/card.tsx +90 -0
- package/templates/todo/components.json +20 -0
- package/templates/todo/functions/_keep.ts +13 -0
- package/templates/todo/gitignore +10 -0
- package/templates/todo/lib/utils.ts +10 -0
- package/templates/todo/package.json +33 -0
- package/templates/todo/tsconfig.json +18 -0
package/bin/create-pylon.js
CHANGED
|
@@ -82,24 +82,34 @@ const TEMPLATE_REGISTRY = {
|
|
|
82
82
|
unified: true,
|
|
83
83
|
},
|
|
84
84
|
barebones: {
|
|
85
|
-
blurb:
|
|
86
|
-
|
|
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:
|
|
90
|
-
|
|
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:
|
|
94
|
-
|
|
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:
|
|
98
|
-
|
|
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:
|
|
102
|
-
|
|
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
|
|
159
|
-
npm create @pylonsync/pylon my-app --template
|
|
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.
|
|
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'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
|
+
}
|