@pylonsync/create-pylon 0.3.279 → 0.3.281
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 +6 -13
- package/package.json +1 -1
- package/templates/backend/chat/apps/api/schema.ts +1 -1
- package/templates/default/app/auth-form.tsx +36 -13
- package/templates/default/app/dashboard/billing/page.tsx +6 -6
- package/templates/default/app/dashboard/dashboard-client.tsx +4 -4
- package/templates/default/app/dashboard/members/page.tsx +5 -3
- package/templates/default/app/dashboard/page.tsx +9 -6
- package/templates/default/app/dashboard/projects/page.tsx +5 -3
- package/templates/default/app/dashboard/provision-workspace.tsx +74 -0
- package/templates/default/app/dashboard/settings/page.tsx +5 -3
- package/templates/default/app/layout.tsx +1 -1
- package/templates/default/components/dashboard-shell.tsx +1 -0
- package/templates/b2b/AGENTS.md +0 -61
- package/templates/b2b/README.md +0 -62
- package/templates/b2b/app/auth-form.tsx +0 -142
- package/templates/b2b/app/dashboard/dashboard-client.tsx +0 -192
- package/templates/b2b/app/dashboard/page.tsx +0 -63
- package/templates/b2b/app/error.tsx +0 -43
- package/templates/b2b/app/globals.css +0 -139
- package/templates/b2b/app/layout.tsx +0 -71
- package/templates/b2b/app/login/page.tsx +0 -47
- package/templates/b2b/app/not-found.tsx +0 -29
- package/templates/b2b/app/page.tsx +0 -114
- package/templates/b2b/app/robots.ts +0 -12
- package/templates/b2b/app/signup/page.tsx +0 -44
- package/templates/b2b/app/sitemap.ts +0 -27
- package/templates/b2b/app.ts +0 -179
- package/templates/b2b/components/ui/button.tsx +0 -56
- package/templates/b2b/components/ui/card.tsx +0 -90
- package/templates/b2b/components.json +0 -20
- package/templates/b2b/functions/_keep.ts +0 -13
- package/templates/b2b/gitignore +0 -10
- package/templates/b2b/lib/utils.ts +0 -10
- package/templates/b2b/package.json +0 -33
- package/templates/b2b/tsconfig.json +0 -18
- package/templates/default/app/onboarding/onboarding-client.tsx +0 -261
- package/templates/default/app/onboarding/page.tsx +0 -29
package/bin/create-pylon.js
CHANGED
|
@@ -63,10 +63,10 @@ const PYLON_VERSION = JSON.parse(
|
|
|
63
63
|
// ---------------------------------------------------------------------------
|
|
64
64
|
// Templates + platforms registry
|
|
65
65
|
//
|
|
66
|
-
// Each template declares which platforms it supports
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
66
|
+
// Each template declares which platforms it supports. Unified templates
|
|
67
|
+
// (the default and every archetype) are a single full-stack SSR app and
|
|
68
|
+
// take no platforms at all. The few non-unified monorepo templates list
|
|
69
|
+
// the platforms whose demo flow they actually ship.
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
|
|
72
72
|
// `web` and `vite` both render into apps/web — they're mutually
|
|
@@ -98,12 +98,6 @@ const TEMPLATE_REGISTRY = {
|
|
|
98
98
|
platforms: [],
|
|
99
99
|
unified: true,
|
|
100
100
|
},
|
|
101
|
-
b2b: {
|
|
102
|
-
blurb:
|
|
103
|
-
"Multi-tenant SaaS — orgs, members, roles, tenant-scoped data. One SSR app.",
|
|
104
|
-
platforms: [],
|
|
105
|
-
unified: true,
|
|
106
|
-
},
|
|
107
101
|
consumer: {
|
|
108
102
|
blurb:
|
|
109
103
|
"Social feed — live posts + likes, public-read, owner-write. One SSR app.",
|
|
@@ -245,7 +239,6 @@ ${tmplLines.join("\n")}
|
|
|
245
239
|
Examples:
|
|
246
240
|
npm create @pylonsync/pylon my-app # default — SaaS landing + multi-tenant dashboard
|
|
247
241
|
npm create @pylonsync/pylon my-app --template todo # live, optimistic todo (SSR, one port)
|
|
248
|
-
npm create @pylonsync/pylon my-app --template b2b # minimal multi-tenant (orgs, members, RBAC)
|
|
249
242
|
npm create @pylonsync/pylon my-app --template chat # realtime live chat room
|
|
250
243
|
npm create @pylonsync/pylon my-app --template waitlist # coming-soon landing + live signup counter
|
|
251
244
|
npm create @pylonsync/pylon my-app --template local-service # appointment business + live booking availability
|
|
@@ -383,8 +376,8 @@ if (!TEMPLATES_AVAILABLE.includes(flags.template)) {
|
|
|
383
376
|
}
|
|
384
377
|
|
|
385
378
|
// Reject combos a template doesn't yet support — better to fail loud
|
|
386
|
-
// than to scaffold an incomplete tree (e.g.
|
|
387
|
-
// frontend entirely and leave
|
|
379
|
+
// than to scaffold an incomplete tree (e.g. a web-only template + expo
|
|
380
|
+
// would skip frontend entirely and leave a half-empty workspace).
|
|
388
381
|
const supportedPlatforms = TEMPLATE_REGISTRY[flags.template].platforms;
|
|
389
382
|
const invalidForTemplate = platforms.filter(
|
|
390
383
|
(p) => !supportedPlatforms.includes(p),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.281",
|
|
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"
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
//
|
|
16
16
|
// Public reads inside a room (anyone can browse). Writes require a
|
|
17
17
|
// signed-in user. For private rooms in production, add a Membership
|
|
18
|
-
// entity and gate Room reads on it (
|
|
18
|
+
// entity and gate Room reads on it (the standard multi-tenant pattern).
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
const Room = entity("Room", {
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import {
|
|
6
|
+
passwordLogin,
|
|
7
|
+
passwordRegister,
|
|
8
|
+
persistSession,
|
|
9
|
+
createOrg,
|
|
10
|
+
ApiError,
|
|
11
|
+
} from "@pylonsync/client";
|
|
5
12
|
|
|
6
13
|
// The email/password form, shared by /login and /signup. It calls the built-in
|
|
7
14
|
// auth API directly — `passwordLogin` / `passwordRegister` (from
|
|
8
15
|
// @pylonsync/client) POST to `/api/auth/password/*`.
|
|
9
16
|
//
|
|
10
|
-
// On success the server sets
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
+
// On success the server sets a session cookie (used by the SSR runtime to
|
|
18
|
+
// resolve auth on the next full navigation) AND returns the session token. We
|
|
19
|
+
// call `persistSession` with that token so the sync engine adopts the new
|
|
20
|
+
// identity: it writes the token to storage (overwriting any earlier guest
|
|
21
|
+
// token a demo flow may have left behind) and re-fetches `/api/auth/me`.
|
|
22
|
+
//
|
|
23
|
+
// Skipping this was a real footgun: a stale anonymous guest token left in
|
|
24
|
+
// localStorage would be sent as a `Bearer` on the engine's auth calls, where
|
|
25
|
+
// the server prefers it over the cookie — so `selectOrg` would 401 with
|
|
26
|
+
// "anonymous session" even though the cookie was a valid user.
|
|
17
27
|
export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
18
28
|
const [email, setEmail] = useState("");
|
|
19
29
|
const [password, setPassword] = useState("");
|
|
@@ -26,14 +36,27 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
26
36
|
setPending(true);
|
|
27
37
|
try {
|
|
28
38
|
if (mode === "login") {
|
|
29
|
-
await passwordLogin({ email, password });
|
|
39
|
+
const session = await passwordLogin({ email, password });
|
|
40
|
+
// Adopt the real identity locally before navigating — otherwise a
|
|
41
|
+
// leftover guest token shadows this session on the sync engine's calls.
|
|
42
|
+
persistSession(session);
|
|
30
43
|
// Full navigation: the SSR dashboard re-renders with the new cookie.
|
|
31
44
|
window.location.assign("/dashboard");
|
|
32
45
|
} else {
|
|
33
|
-
await passwordRegister({ email, password });
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
46
|
+
const session = await passwordRegister({ email, password });
|
|
47
|
+
persistSession(session);
|
|
48
|
+
// Auto-provision a first workspace so new accounts land in a ready
|
|
49
|
+
// dashboard instead of an empty first-run screen. Named "My Workspace"
|
|
50
|
+
// (renamable in Settings) and made the active tenant. Either way we land
|
|
51
|
+
// on /dashboard — if provisioning failed here, the dashboard's org-less
|
|
52
|
+
// safety net retries it.
|
|
53
|
+
try {
|
|
54
|
+
const org = await createOrg("My Workspace");
|
|
55
|
+
await db.sync.selectOrg(org.id);
|
|
56
|
+
} catch {
|
|
57
|
+
// Swallowed — the dashboard provisions on load if there's still no org.
|
|
58
|
+
}
|
|
59
|
+
window.location.assign("/dashboard");
|
|
37
60
|
}
|
|
38
61
|
} catch (err) {
|
|
39
62
|
setError(messageFor(err));
|
|
@@ -19,13 +19,13 @@ export default function BillingPage({ auth, response, serverData }: PageProps) {
|
|
|
19
19
|
response.redirect("/login");
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
|
+
if (!auth.tenant_id) {
|
|
23
|
+
response.redirect("/dashboard");
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
22
26
|
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
23
|
-
const org = auth.tenant_id
|
|
24
|
-
|
|
25
|
-
: null;
|
|
26
|
-
const subs = auth.tenant_id
|
|
27
|
-
? use(serverData.list<Subscription>("StripeSubscription"))
|
|
28
|
-
: [];
|
|
27
|
+
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
28
|
+
const subs = use(serverData.list<Subscription>("StripeSubscription"));
|
|
29
29
|
const subscription =
|
|
30
30
|
subs.find(
|
|
31
31
|
(s) => s.referenceId === auth.tenant_id && ACTIVE.includes(s.status),
|
|
@@ -46,7 +46,7 @@ function NoOrg() {
|
|
|
46
46
|
projects and members are private to it.
|
|
47
47
|
</p>
|
|
48
48
|
<a
|
|
49
|
-
href="/
|
|
49
|
+
href="/dashboard"
|
|
50
50
|
className="mt-4 inline-flex h-9 items-center rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
51
51
|
>
|
|
52
52
|
Set up your workspace
|
|
@@ -570,8 +570,8 @@ function SettingsView({
|
|
|
570
570
|
}
|
|
571
571
|
|
|
572
572
|
// Real, irreversible delete: type the workspace name to confirm, then call the
|
|
573
|
-
// framework's owner-gated DELETE endpoint, drop the active org, and bounce
|
|
574
|
-
//
|
|
573
|
+
// framework's owner-gated DELETE endpoint, drop the active org, and bounce to
|
|
574
|
+
// the dashboard — which selects another workspace or provisions a fresh one.
|
|
575
575
|
function DeleteOrg({
|
|
576
576
|
org,
|
|
577
577
|
onDeleted,
|
|
@@ -591,7 +591,7 @@ function DeleteOrg({
|
|
|
591
591
|
try {
|
|
592
592
|
await deleteOrg(org.id);
|
|
593
593
|
await onDeleted();
|
|
594
|
-
window.location.assign("/
|
|
594
|
+
window.location.assign("/dashboard");
|
|
595
595
|
} catch {
|
|
596
596
|
setError("Delete failed. Try again.");
|
|
597
597
|
setDeleting(false);
|
|
@@ -16,10 +16,12 @@ export default function MembersPage({ auth, response, serverData }: PageProps) {
|
|
|
16
16
|
response.redirect("/login");
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
|
+
if (!auth.tenant_id) {
|
|
20
|
+
response.redirect("/dashboard");
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
19
23
|
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
20
|
-
const org = auth.tenant_id
|
|
21
|
-
? use(serverData.get<{ name?: string }>("Org", auth.tenant_id))
|
|
22
|
-
: null;
|
|
24
|
+
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
23
25
|
return (
|
|
24
26
|
<DashboardShell
|
|
25
27
|
active="members"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { use } from "react";
|
|
2
2
|
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
3
|
import { DashboardShell } from "@/components/dashboard-shell";
|
|
4
|
+
import { ProvisionWorkspace } from "./provision-workspace";
|
|
4
5
|
import {
|
|
5
6
|
Overview,
|
|
6
7
|
type Project,
|
|
@@ -28,10 +29,14 @@ export default function DashboardPage({
|
|
|
28
29
|
response.redirect("/login");
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
32
|
+
// No active workspace (signup's auto-provision failed, or the user left/
|
|
33
|
+
// deleted their last org). Every read below is tenant-scoped, so instead of
|
|
34
|
+
// an empty shell, provision one client-side and reload into a ready dashboard.
|
|
35
|
+
if (!auth.tenant_id) {
|
|
36
|
+
return <ProvisionWorkspace />;
|
|
37
|
+
}
|
|
31
38
|
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
32
|
-
const org = auth.tenant_id
|
|
33
|
-
? use(serverData.get<{ name?: string }>("Org", auth.tenant_id))
|
|
34
|
-
: null;
|
|
39
|
+
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
35
40
|
const projects = use(serverData.list<Project>("Project"));
|
|
36
41
|
const members = use(serverData.list<OrgMemberRow>("OrgMember"));
|
|
37
42
|
// The OrgMember read policy returns this user's memberships across every org,
|
|
@@ -39,9 +44,7 @@ export default function DashboardPage({
|
|
|
39
44
|
const memberCount = members.filter((m) => m.orgId === auth.tenant_id).length;
|
|
40
45
|
// Active-plan badge from the workspace's Stripe subscription (Free until one
|
|
41
46
|
// exists). Scoped to the active tenant by the plugin's read policy.
|
|
42
|
-
const subs =
|
|
43
|
-
? use(serverData.list<Subscription>("StripeSubscription"))
|
|
44
|
-
: [];
|
|
47
|
+
const subs = use(serverData.list<Subscription>("StripeSubscription"));
|
|
45
48
|
const active = subs.find((s) =>
|
|
46
49
|
["active", "trialing", "past_due"].includes(s.status),
|
|
47
50
|
);
|
|
@@ -19,10 +19,12 @@ export default function ProjectsPage({
|
|
|
19
19
|
response.redirect("/login");
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
|
+
if (!auth.tenant_id) {
|
|
23
|
+
response.redirect("/dashboard");
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
22
26
|
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
23
|
-
const org = auth.tenant_id
|
|
24
|
-
? use(serverData.get<{ name?: string }>("Org", auth.tenant_id))
|
|
25
|
-
: null;
|
|
27
|
+
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
26
28
|
const projects = use(serverData.list<Project>("Project"));
|
|
27
29
|
return (
|
|
28
30
|
<DashboardShell
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { createOrg, listOrgs } from "@pylonsync/client";
|
|
5
|
+
import { db } from "@pylonsync/react";
|
|
6
|
+
|
|
7
|
+
// Org-less safety net. New signups get "My Workspace" auto-created in the signup
|
|
8
|
+
// flow, so this only renders in edge cases: that creation failed, or a user left
|
|
9
|
+
// or deleted their last workspace. Pick an existing org if one turned up,
|
|
10
|
+
// otherwise create "My Workspace", make it active, and reload into the ready
|
|
11
|
+
// dashboard. No multi-step onboarding — there's nothing for the user to decide.
|
|
12
|
+
export function ProvisionWorkspace() {
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
void (async () => {
|
|
18
|
+
try {
|
|
19
|
+
const orgs = await listOrgs();
|
|
20
|
+
const target = orgs[0] ?? (await createOrg("My Workspace"));
|
|
21
|
+
await db.sync.selectOrg(target.id);
|
|
22
|
+
if (!cancelled) window.location.reload();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
setError(
|
|
26
|
+
err instanceof Error
|
|
27
|
+
? err.message
|
|
28
|
+
: "Couldn't set up your workspace.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
async function signOut() {
|
|
39
|
+
await db.sync.signOut();
|
|
40
|
+
window.location.assign("/login");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-zinc-50 px-6 text-center">
|
|
45
|
+
{error ? (
|
|
46
|
+
<>
|
|
47
|
+
<p className="max-w-sm text-sm text-red-700">{error}</p>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => window.location.reload()}
|
|
51
|
+
className="inline-flex h-9 items-center rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
52
|
+
>
|
|
53
|
+
Try again
|
|
54
|
+
</button>
|
|
55
|
+
</>
|
|
56
|
+
) : (
|
|
57
|
+
<>
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden
|
|
60
|
+
className="size-5 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900"
|
|
61
|
+
/>
|
|
62
|
+
<p className="text-sm text-zinc-500">Setting up your workspace…</p>
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={signOut}
|
|
68
|
+
className="mt-1 text-[13px] text-zinc-400 underline underline-offset-2 transition-colors hover:text-zinc-600"
|
|
69
|
+
>
|
|
70
|
+
Wrong account? Sign out
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -20,10 +20,12 @@ export default function SettingsPage({ auth, response, serverData }: PageProps)
|
|
|
20
20
|
response.redirect("/login");
|
|
21
21
|
return null;
|
|
22
22
|
}
|
|
23
|
+
if (!auth.tenant_id) {
|
|
24
|
+
response.redirect("/dashboard");
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
23
27
|
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
24
|
-
const org = auth.tenant_id
|
|
25
|
-
? use(serverData.get<OrgInfo>("Org", auth.tenant_id))
|
|
26
|
-
: null;
|
|
28
|
+
const org = use(serverData.get<OrgInfo>("Org", auth.tenant_id));
|
|
27
29
|
const members = use(serverData.list<OrgMemberRow>("OrgMember"));
|
|
28
30
|
const memberCount = members.filter(
|
|
29
31
|
(m) => m.orgId === auth.tenant_id,
|
|
@@ -182,7 +182,7 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
|
182
182
|
// (not a substring) so a future marketing slug that happens to contain one
|
|
183
183
|
// of these words — e.g. /products/dashboard-tools — keeps its chrome.
|
|
184
184
|
const path = (url ?? "").split("?")[0];
|
|
185
|
-
const BARE_PREFIXES = ["/login", "/signup", "/
|
|
185
|
+
const BARE_PREFIXES = ["/login", "/signup", "/dashboard"];
|
|
186
186
|
const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
|
|
187
187
|
return (
|
|
188
188
|
<html
|
|
@@ -63,6 +63,7 @@ export function DashboardShell({
|
|
|
63
63
|
A soft client navigation (router.push) re-fetches the SSR page —
|
|
64
64
|
all data updates — without the full-reload white flash. */}
|
|
65
65
|
<OrganizationSwitcher
|
|
66
|
+
hidePersonal
|
|
66
67
|
initialActiveName={orgName}
|
|
67
68
|
onSwitched={() => router.push("/dashboard")}
|
|
68
69
|
/>
|
package/templates/b2b/AGENTS.md
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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**.
|
package/templates/b2b/README.md
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
|
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
}
|