@pylonsync/create-pylon 0.3.268 → 0.3.269

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.268",
3
+ "version": "0.3.269",
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"
@@ -1,8 +1,10 @@
1
1
  # __APP_NAME__
2
2
 
3
- A full-stack [Pylon](https://pylonsync.com) app — a server-rendered homepage,
4
- email/password auth, and a live client dashboard over a synced database, all
5
- served from one binary on one port. No Next.js, no separate API server.
3
+ A full-stack, multi-tenant SaaS starter on [Pylon](https://pylonsync.com),
4
+ branded as a fictional product called **Acme**: a server-rendered marketing
5
+ landing page, email/password auth, organizations with members + roles, and
6
+ tenant-scoped projects — all from one binary on one port. No Next.js, no
7
+ separate API server, no realtime sidecar.
6
8
 
7
9
  ## Develop
8
10
 
@@ -10,42 +12,55 @@ served from one binary on one port. No Next.js, no separate API server.
10
12
  __RUN_DEV__
11
13
  ```
12
14
 
13
- Open http://localhost:4321. Sign up, and your notes dashboard updates live
14
- (open a second tab to watch writes sync). Edit any file under `app/` and save —
15
- the page reloads instantly.
15
+ Open http://localhost:4321. You get the **Acme landing page**. Sign up, create
16
+ an organization, and you land in a **workspace** with tenant-scoped projects and
17
+ a members panel. Create a second org and switch between them — each org's data
18
+ is private to it. Edit any file under `app/` and save — the page reloads.
16
19
 
17
20
  ## Layout
18
21
 
19
22
  ```
20
- app.ts data model + manifest (entities, policies, auth, routes)
21
- app/page.tsx "/" — the server-rendered, auth-aware homepage
22
- app/login,signup/ email/password forms (POST /api/auth/password/*)
23
- app/dashboard/ "/dashboard" authed; server-gated, live notes + sign out
24
- app/auth-form.tsx shared client island for the login/signup forms
25
- app/layout.tsx root layout wrapping every page (auth-aware nav)
26
- app/globals.css Tailwind entrypoint (compiled by Pylon)
27
- functions/ server functions (query/mutation/action) — typed RPC
23
+ app.ts User + Org/OrgMember/OrgInvite + tenant-scoped Project
24
+ app/page.tsx "/" — the server-rendered Acme landing page (auth-aware)
25
+ app/layout.tsx marketing nav + footer (rebrand "Acme")
26
+ app/login,signup/ email/password (POST /api/auth/password/*)
27
+ app/dashboard/ "/dashboard" authed; org switcher + projects + members
28
+ app/dashboard/dashboard-client.tsx the workspace client island
29
+ app/globals.css Tailwind v4 + shadcn tokens (compiled by Pylon)
30
+ components/ui/ shadcn primitives (Button, Card)
28
31
  ```
29
32
 
30
- ## How auth works
33
+ ## How it works
31
34
 
32
- Email/password is built in. `/login` and `/signup` call
33
- `/api/auth/password/*`; on success the server sets an **HttpOnly session
34
- cookie** (no token in JS-readable storage). `/dashboard` reads `auth` during
35
- the server render and redirects anonymous visitors to `/login` a real 3xx
36
- before any HTML, so there's no flash and it works with JS off. The sync engine
37
- authenticates with the same cookie.
35
+ **The landing page** (`app/page.tsx`) is server-rendered React view source and
36
+ the copy + SEO `<head>` are in the HTML, so it's fully indexable. It reads the
37
+ session during the render, so the call-to-action is "Get started" for visitors
38
+ and "Open dashboard" once you're signed in no flash, no client fetch.
38
39
 
39
- ## Add a route
40
+ **Auth** is built in: `/login` + `/signup` POST to `/api/auth/password/*`, the
41
+ server sets an HttpOnly session cookie, and `/dashboard` redirects anonymous
42
+ visitors with a real 3xx before any HTML (works with JS off).
40
43
 
41
- Drop a file at `app/about/page.tsx` and visit `/about`. Pages receive
42
- `{ url, params, searchParams, auth, response, serverData }` from the SSR
43
- runtime — all typed via `PageProps` from `@pylonsync/react`.
44
+ **Multi-tenancy** is a framework primitive. Declaring `Org` / `OrgMember` /
45
+ `OrgInvite` lights up `/api/auth/orgs/*` + `/api/auth/select-org`, driven by
46
+ `<OrganizationSwitcher>` from `@pylonsync/client`. Your data lives in
47
+ tenant-scoped entities (`Project`), gated by policy:
44
48
 
45
- ## Add data
49
+ ```ts
50
+ allowRead: "auth.tenantId == data.orgId"
51
+ allowInsert: "auth.tenantId == data.orgId"
52
+ ```
53
+
54
+ So `db.useQuery("Project")` returns only your **active org's** projects — switch
55
+ orgs and the list changes, and a client literally cannot read or write another
56
+ tenant's rows. `db.useQuery` is live; `db.insert` is optimistic.
57
+
58
+ ## Make it yours
46
59
 
47
- Edit `app.ts`. Every `entity()` becomes a synced table with a REST +
48
- realtime API and a typed client no migrations, no resolvers.
60
+ - **Rebrand:** replace "Acme" in `app/page.tsx` + `app/layout.tsx`.
61
+ - **Add tenant data:** new `entity()` with an `orgId` + the same two policy
62
+ lines — a new tenant-scoped table, typed client and REST/realtime API included.
63
+ - **Add a route:** drop `app/about/page.tsx` and visit `/about`.
49
64
 
50
65
  ## Deploy
51
66
 
@@ -1,109 +1,107 @@
1
1
  "use client";
2
2
 
3
- import React, { useState } from "react";
3
+ import React, { useCallback, useEffect, useState } from "react";
4
4
  import { db } from "@pylonsync/react";
5
- import { useAuth } from "@pylonsync/client";
5
+ import {
6
+ useAuth,
7
+ OrganizationSwitcher,
8
+ listOrgMembers,
9
+ createInvite,
10
+ type OrgMember,
11
+ } from "@pylonsync/client";
6
12
  import { Button } from "@/components/ui/button";
7
13
 
8
- export interface Note {
14
+ export interface Project {
9
15
  id: string;
10
- body: string;
11
- done: boolean;
16
+ orgId: string;
17
+ name: string;
18
+ createdAt: string;
12
19
  }
13
20
 
14
- // The interactive dashboard. `db.useQuery` is a LIVE subscription — it
15
- // re-renders the instant a Note is added or toggled, in this tab or another.
16
- // `db.insert` / `db.update` / `db.delete` are OPTIMISTIC: they apply to the
17
- // local store immediately (zero-latency UI) and sync in the background,
18
- // rolling back automatically if a policy rejects the write.
19
- //
20
- // `initial` are the rows the server rendered into the HTML (see page.tsx).
21
- // We show them on the first paint — before the local store has hydrated — so
22
- // there's no empty flash, then hand off to the live data. Server-rendered for
23
- // the first byte, local-first realtime after.
24
- export function Dashboard({ initial }: { initial: Note[] }) {
25
- const { signOut } = useAuth();
26
- const [body, setBody] = useState("");
27
- const { data: live, loading } = db.useQuery<Note>("Note");
28
- const notes = !loading || live.length > 0 ? live : initial;
29
-
30
- async function addNote(e: React.FormEvent) {
31
- e.preventDefault();
32
- const text = body.trim();
33
- if (!text) return;
34
- setBody("");
35
- // We don't send ownerId — `field.owner()` stamps it from the session
36
- // server-side and rejects any forged value, so this optimistic insert is
37
- // safe.
38
- await db.insert("Note", { body: text, done: false });
39
- }
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();
40
27
 
41
28
  async function onSignOut() {
42
- // Clears the server session (DELETE /api/auth/session → the cookie is
43
- // cleared), then we land back on the public homepage.
44
29
  await signOut();
45
30
  window.location.assign("/");
46
31
  }
47
32
 
48
33
  return (
49
- <div className="space-y-5">
50
- <div className="flex items-center justify-end">
34
+ <div className="space-y-6">
35
+ <div className="flex items-center justify-between gap-3">
36
+ <OrganizationSwitcher />
51
37
  <Button variant="ghost" size="sm" onClick={onSignOut}>
52
38
  Sign out
53
39
  </Button>
54
40
  </div>
55
41
 
56
- <form onSubmit={addNote} className="flex items-center gap-2">
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">
57
79
  <input
58
- value={body}
59
- onChange={(e) => setBody(e.target.value)}
60
- placeholder="Write a note…"
61
- aria-label="Note"
80
+ value={name}
81
+ onChange={(e) => setName(e.target.value)}
82
+ placeholder="New project…"
83
+ aria-label="Project name"
62
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"
63
85
  />
64
- <Button type="submit">Add</Button>
86
+ <Button type="submit" size="sm">
87
+ Add
88
+ </Button>
65
89
  </form>
66
-
67
- {notes.length === 0 ? (
68
- <p className="text-sm text-muted-foreground">
69
- No notes yet — add one above. It appears instantly (optimistic) and
70
- syncs; open this page in a second tab to watch it arrive live.
71
- </p>
90
+ {projects.length === 0 ? (
91
+ <p className="text-sm text-muted-foreground">No projects yet.</p>
72
92
  ) : (
73
- <ul className="space-y-2">
74
- {notes.map((note) => (
93
+ <ul className="space-y-1.5">
94
+ {projects.map((p) => (
75
95
  <li
76
- key={note.id}
77
- className="flex items-center gap-3 rounded-md border px-3 py-2 text-sm"
96
+ key={p.id}
97
+ className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
78
98
  >
99
+ <span className="truncate">{p.name}</span>
79
100
  <button
80
101
  type="button"
81
- aria-label={note.done ? "Mark not done" : "Mark done"}
82
- onClick={() =>
83
- db.update("Note", note.id, { done: !note.done })
84
- }
85
- className={
86
- note.done
87
- ? "text-emerald-600"
88
- : "text-muted-foreground/50 hover:text-muted-foreground"
89
- }
90
- >
91
- {note.done ? "✓" : "○"}
92
- </button>
93
- <span
94
- className={
95
- note.done
96
- ? "flex-1 line-through text-muted-foreground"
97
- : "flex-1"
98
- }
99
- >
100
- {note.body}
101
- </span>
102
- <button
103
- type="button"
104
- aria-label="Delete note"
105
- onClick={() => db.delete("Note", note.id)}
106
- className="text-muted-foreground/40 hover:text-red-600"
102
+ aria-label="Delete project"
103
+ onClick={() => db.delete("Project", p.id)}
104
+ className="text-muted-foreground/40 transition-colors hover:text-red-600"
107
105
  >
108
106
 
109
107
  </button>
@@ -111,6 +109,84 @@ export function Dashboard({ initial }: { initial: Note[] }) {
111
109
  ))}
112
110
  </ul>
113
111
  )}
114
- </div>
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>
115
187
  );
116
188
  }
189
+
190
+ function shortId(id: string) {
191
+ return id.replace(/^user_/, "").slice(0, 10);
192
+ }
@@ -1,70 +1,26 @@
1
- import React, { Suspense, use } from "react";
2
- import {
3
- type Metadata,
4
- type PageProps,
5
- type ServerData,
6
- } from "@pylonsync/react";
7
- import { Dashboard, type Note } from "./dashboard-client";
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { Workspace } from "./dashboard-client";
8
4
 
9
5
  export const metadata: Metadata = {
10
- title: "Dashboard — __APP_NAME__",
6
+ title: "Dashboard — Acme",
11
7
  robots: "noindex",
12
8
  };
13
9
 
14
- interface User {
15
- id: string;
16
- email: string;
17
- displayName?: string;
18
- }
19
-
20
- // Reads the signed-in user + their notes DURING the server render. The reads
21
- // run through the same policy gate as a client query, so they're owner-scoped
22
- // (User: only your row; Note: only your notes) — see the policies in app.ts.
23
- // React 19 `use()` suspends until they resolve on the server, so the HTML
24
- // arrives with your notes already in it (no empty flash); then the <Dashboard>
25
- // island hydrates and takes over live.
26
- function DashboardBody({
27
- serverData,
28
- userId,
29
- }: {
30
- serverData: ServerData;
31
- userId: string;
32
- }) {
33
- const user = use(serverData.get<User>("User", userId));
34
- const notes = use(serverData.list<Note>("Note"));
35
- return (
36
- <>
37
- <p className="text-sm text-muted-foreground">
38
- Signed in as{" "}
39
- <span className="font-medium text-foreground">
40
- {user?.displayName || user?.email || "you"}
41
- </span>
42
- .
43
- </p>
44
- <Dashboard initial={notes} />
45
- </>
46
- );
47
- }
48
-
49
- // `app/dashboard/page.tsx` → `/dashboard`.
50
- export default function DashboardPage({
51
- auth,
52
- response,
53
- serverData,
54
- }: PageProps) {
55
- // Server-side auth gate: anonymous requests get a 307 to /login before any
56
- // HTML. The redirect MUST fire here in the synchronous shell render — not
57
- // inside the <Suspense> below — or React swallows it. No flash of the
58
- // dashboard, works with JS disabled.
59
- if (!auth.user_id) response.redirect("/login");
10
+ // `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate: anonymous
11
+ // requests get a 307 to /login before any HTML is sent (works with JS off, no
12
+ // flash of the dashboard). The redirect fires — and we return — in the
13
+ // synchronous shell render. The workspace itself is a client island that reads
14
+ // your active org from the session.
15
+ export default function DashboardPage({ auth, response }: PageProps) {
16
+ if (!auth.user_id) {
17
+ response.redirect("/login");
18
+ return null;
19
+ }
60
20
  return (
61
21
  <div className="space-y-6">
62
- <h1 className="text-2xl font-semibold tracking-tight">Your notes</h1>
63
- <Suspense
64
- fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
65
- >
66
- <DashboardBody serverData={serverData} userId={auth.user_id!} />
67
- </Suspense>
22
+ <h1 className="text-2xl font-semibold tracking-tight">Workspace</h1>
23
+ <Workspace />
68
24
  </div>
69
25
  );
70
26
  }
@@ -1,69 +1,76 @@
1
1
  import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
+ import { Button } from "@/components/ui/button";
3
4
 
4
- // A layout receives the page props plus `children`. `auth.user_id` is null
5
- // for anonymous visitors and the signed-in user's id otherwise — resolved
6
- // server-side from the session cookie, before any HTML is sent, so the nav
7
- // renders the right links on the first byte (no flash, no client fetch). The
8
- // `PageAuth` type is exported from @pylonsync/react so you never hand-roll it.
5
+ // A layout receives the page props plus `children`. `auth.user_id` is null for
6
+ // anonymous visitors and the signed-in user's id otherwise — resolved
7
+ // server-side from the session cookie before any HTML is sent, so the nav
8
+ // renders the right links on the first byte (no flash, no client fetch).
9
9
  interface LayoutProps {
10
10
  children: React.ReactNode;
11
11
  url: string;
12
12
  auth: PageAuth;
13
13
  }
14
14
 
15
- // The root layout wraps every page.
15
+ // The root layout wraps every page: a marketing nav up top, a footer below.
16
+ // Rebrand "Acme" to your product.
16
17
  export default function RootLayout({ children, auth }: LayoutProps) {
17
18
  const signedIn = Boolean(auth?.user_id);
18
- // Add `className="dark"` to this <html> to flip every shadcn token to its
19
- // dark value. The classes below use semantic tokens (bg-background,
20
- // text-foreground, …) so the whole UI re-themes from app/globals.css.
21
19
  return (
22
20
  <html lang="en">
23
21
  <head>
24
22
  <meta charSet="utf-8" />
25
23
  <meta name="viewport" content="width=device-width, initial-scale=1" />
26
- <title>__APP_NAME__</title>
24
+ <title>Acme</title>
27
25
  {/* Tailwind is compiled by Pylon from app/globals.css and the
28
- stylesheet link is injected here automatically — nothing to
29
- wire up. */}
26
+ stylesheet link is injected here automatically. */}
30
27
  </head>
31
- <body className="min-h-screen bg-background text-foreground antialiased">
32
- <header className="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
33
- <div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
34
- <Link
35
- href="/"
36
- className="text-sm font-semibold tracking-tight hover:text-muted-foreground"
37
- >
38
- __APP_NAME__
28
+ <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
29
+ <header className="sticky top-0 z-20 border-b bg-background/70 backdrop-blur">
30
+ <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
31
+ <Link href="/" className="flex items-center gap-2">
32
+ <span className="flex size-7 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
33
+ A
34
+ </span>
35
+ <span className="text-sm font-semibold tracking-tight">Acme</span>
39
36
  </Link>
40
- <nav className="flex items-center gap-4 text-sm text-muted-foreground">
41
- <Link href="/" className="hover:text-foreground">
42
- Home
43
- </Link>
37
+ <nav className="flex items-center gap-2 text-sm">
44
38
  {signedIn ? (
45
- <Link href="/dashboard" className="hover:text-foreground">
46
- Dashboard
47
- </Link>
39
+ <Button asChild size="sm">
40
+ <Link href="/dashboard">Dashboard</Link>
41
+ </Button>
48
42
  ) : (
49
43
  <>
50
- <Link href="/login" className="hover:text-foreground">
51
- Sign in
52
- </Link>
53
- <Link
54
- href="/signup"
55
- className="rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground hover:opacity-90"
56
- >
57
- Sign up
58
- </Link>
44
+ <Button asChild size="sm" variant="ghost">
45
+ <Link href="/login">Sign in</Link>
46
+ </Button>
47
+ <Button asChild size="sm">
48
+ <Link href="/signup">Get started</Link>
49
+ </Button>
59
50
  </>
60
51
  )}
61
52
  </nav>
62
53
  </div>
63
54
  </header>
64
- <main className="mx-auto max-w-3xl px-4 py-10">{children}</main>
65
- <footer className="border-t py-6 text-center text-xs text-muted-foreground">
66
- Rendered by Pylon · one server, one port
55
+
56
+ <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10">
57
+ {children}
58
+ </main>
59
+
60
+ <footer className="border-t">
61
+ <div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-2 px-4 py-6 text-xs text-muted-foreground sm:flex-row">
62
+ <span>© Acme, Inc.</span>
63
+ <span>
64
+ Built with{" "}
65
+ <a
66
+ href="https://pylonsync.com"
67
+ className="font-medium text-foreground hover:underline"
68
+ >
69
+ Pylon
70
+ </a>{" "}
71
+ · one binary, one port
72
+ </span>
73
+ </div>
67
74
  </footer>
68
75
  </body>
69
76
  </html>
@@ -10,7 +10,7 @@ import {
10
10
  import { AuthForm } from "../auth-form";
11
11
 
12
12
  export const metadata: Metadata = {
13
- title: "Sign in — __APP_NAME__",
13
+ title: "Sign in — Acme",
14
14
  // Auth pages shouldn't be indexed.
15
15
  robots: "noindex",
16
16
  };
@@ -1,114 +1,212 @@
1
1
  import React from "react";
2
2
  import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
3
  import { Button } from "@/components/ui/button";
4
- import {
5
- Card,
6
- CardContent,
7
- CardDescription,
8
- CardHeader,
9
- CardTitle,
10
- } from "@/components/ui/card";
11
4
 
12
- // SEO metadata. Export `metadata` (static) or `generateMetadata(props)`
13
- // (dynamic) from any page or layoutPylon renders the <title>/<meta>
14
- // into <head> server-side. The `Metadata` type is exported from
15
- // @pylonsync/react.
5
+ // SEO metadata. Exported `metadata` is rendered into <head> on the server, so
6
+ // this marketing page is fully indexable view source and the copy is in the
7
+ // HTML. Swap "Acme" for your product throughout.
16
8
  export const metadata: Metadata = {
17
- title: "__APP_NAME__full-stack Pylon app",
9
+ title: "Acmethe workspace your team actually wants",
18
10
  description:
19
- "A server-rendered homepage, email/password auth, and a live client dashboard over one synced backend one binary, one port.",
11
+ "Acme is a collaborative workspace for fast-moving teams. Organize projects by team, collaborate in real time, and keep every tenant's data private. Built on Pylon.",
20
12
  };
21
13
 
22
- // `app/page.tsx` → `/`. This page is server-rendered: view source and the copy
23
- // is in the HTML, not fetched later good for SEO and first paint. It reads
24
- // `auth` (resolved from the session cookie during the render) to show the
25
- // right call to action. Every page receives `PageProps` from the SSR runtime:
26
- // `{ url, params, searchParams, auth, response, serverData }` typed, no
27
- // hand-rolled interface.
28
- export default function IndexPage({ auth }: PageProps) {
14
+ // `app/page.tsx` → `/`. A server-rendered marketing landing page. It reads
15
+ // `auth` (resolved from the session cookie during the render) so the call to
16
+ // action is right on the first byte "Get started" for visitors, "Open
17
+ // dashboard" once you're signed in. No client fetch, no flash.
18
+ export default function LandingPage({ auth }: PageProps) {
29
19
  const signedIn = Boolean(auth.user_id);
20
+
30
21
  return (
31
- <div className="space-y-12">
32
- <section className="space-y-5">
33
- <span className="inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium text-muted-foreground">
34
- Server-rendered · authenticated · synced · one port
35
- </span>
36
- <h1 className="text-4xl font-semibold tracking-tight">
37
- Full-stack apps, one binary.
38
- </h1>
39
- <p className="max-w-xl text-lg text-muted-foreground">
40
- This homepage is server-rendered React. Sign in and your dashboard
41
- becomes a live, local-first view over the same Pylon backend — writes
42
- appear instantly and sync across tabs. No Next.js, no separate API
43
- server, no realtime sidecar.
44
- </p>
45
- <div className="flex flex-wrap items-center gap-3">
46
- {signedIn ? (
47
- <Button asChild>
48
- <Link href="/dashboard">Go to your dashboard →</Link>
49
- </Button>
50
- ) : (
51
- <>
52
- <Button asChild>
53
- <Link href="/signup">Get started</Link>
22
+ <div className="space-y-24 pb-24">
23
+ {/* Hero */}
24
+ <section className="relative overflow-hidden">
25
+ {/* Soft gradient wash behind the hero. */}
26
+ <div
27
+ aria-hidden
28
+ className="pointer-events-none absolute -top-40 left-1/2 h-[32rem] w-[60rem] -translate-x-1/2 rounded-full bg-gradient-to-tr from-primary/15 via-primary/5 to-transparent blur-3xl"
29
+ />
30
+ <div className="relative mx-auto max-w-3xl px-2 pt-16 text-center sm:pt-24">
31
+ <span className="inline-flex items-center gap-2 rounded-full border bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
32
+ <span className="size-1.5 rounded-full bg-emerald-500" />
33
+ New · Real-time projects for every team
34
+ </span>
35
+ <h1 className="mt-6 text-balance text-5xl font-semibold tracking-tight sm:text-6xl">
36
+ The workspace your team{" "}
37
+ <span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
38
+ actually wants
39
+ </span>
40
+ .
41
+ </h1>
42
+ <p className="mx-auto mt-5 max-w-xl text-balance text-lg text-muted-foreground">
43
+ Acme keeps your team&apos;s projects organized, in sync, and private
44
+ to your organization. Invite your teammates, switch between orgs, and
45
+ watch every change land live.
46
+ </p>
47
+ <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
48
+ {signedIn ? (
49
+ <Button asChild size="lg">
50
+ <Link href="/dashboard">Open dashboard →</Link>
54
51
  </Button>
55
- <Button asChild variant="outline">
56
- <Link href="/login">Sign in</Link>
57
- </Button>
58
- </>
59
- )}
52
+ ) : (
53
+ <>
54
+ <Button asChild size="lg">
55
+ <Link href="/signup">Get started — it&apos;s free</Link>
56
+ </Button>
57
+ <Button asChild size="lg" variant="outline">
58
+ <Link href="/login">Sign in</Link>
59
+ </Button>
60
+ </>
61
+ )}
62
+ </div>
63
+ <p className="mt-4 text-xs text-muted-foreground">
64
+ No credit card required · Set up your first org in seconds.
65
+ </p>
66
+ </div>
67
+
68
+ {/* Product peek — a stylized dashboard mock so the hero has a subject. */}
69
+ <div className="relative mx-auto mt-14 max-w-4xl px-4">
70
+ <div className="rounded-xl border bg-card shadow-2xl shadow-primary/5">
71
+ <div className="flex items-center gap-1.5 border-b px-4 py-3">
72
+ <span className="size-3 rounded-full bg-red-400/70" />
73
+ <span className="size-3 rounded-full bg-yellow-400/70" />
74
+ <span className="size-3 rounded-full bg-green-400/70" />
75
+ <span className="ml-3 text-xs text-muted-foreground">
76
+ acme.app/dashboard
77
+ </span>
78
+ </div>
79
+ <div className="grid gap-4 p-5 sm:grid-cols-[1fr_1.4fr]">
80
+ <div className="space-y-2">
81
+ <div className="text-xs font-medium text-muted-foreground">
82
+ Organization
83
+ </div>
84
+ <div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
85
+ <span className="flex size-6 items-center justify-center rounded bg-primary/10 text-xs font-semibold text-primary">
86
+ A
87
+ </span>
88
+ Acme Inc
89
+ </div>
90
+ <div className="pt-2 text-xs font-medium text-muted-foreground">
91
+ Members
92
+ </div>
93
+ {["you · owner", "jordan · admin", "sam · member"].map((m) => (
94
+ <div
95
+ key={m}
96
+ className="rounded-md border px-3 py-1.5 font-mono text-xs text-muted-foreground"
97
+ >
98
+ {m}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ <div className="space-y-2">
103
+ <div className="text-xs font-medium text-muted-foreground">
104
+ Projects
105
+ </div>
106
+ {["Website redesign", "Mobile app", "Q3 launch"].map((p, i) => (
107
+ <div
108
+ key={p}
109
+ className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
110
+ >
111
+ {p}
112
+ {i === 0 && (
113
+ <span className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
114
+ live
115
+ </span>
116
+ )}
117
+ </div>
118
+ ))}
119
+ <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">
120
+ + New project
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
60
125
  </div>
61
126
  </section>
62
127
 
63
- <section className="grid gap-4 sm:grid-cols-3">
64
- <Feature title="Server-rendered">
65
- File-based routes under <Code>app/</Code>. Pages render to HTML on the
66
- server with <Code>metadata</Code> in <Code>{"<head>"}</Code>, then
67
- hydrate. Drop <Code>app/about/page.tsx</Code> to add{" "}
68
- <Code>/about</Code>.
69
- </Feature>
70
- <Feature title="Auth included">
71
- Email/password is built in. <Code>/login</Code> and{" "}
72
- <Code>/signup</Code> hit <Code>/api/auth/password/*</Code>; the server
73
- sets an HttpOnly session cookie. <Code>/dashboard</Code> gates on it
74
- server-side.
75
- </Feature>
76
- <Feature title="Synced database">
77
- Every <Code>entity()</Code> in <Code>app.ts</Code> gets a REST +
78
- realtime API and a typed client. <Code>db.useQuery</Code> is live;{" "}
79
- <Code>db.insert</Code> is optimistic.
80
- </Feature>
128
+ {/* Logos / social proof */}
129
+ <section className="mx-auto max-w-3xl px-4 text-center">
130
+ <p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
131
+ Trusted by teams at
132
+ </p>
133
+ <div className="mt-4 flex flex-wrap items-center justify-center gap-x-10 gap-y-3 text-lg font-semibold text-muted-foreground/60">
134
+ <span>Globex</span>
135
+ <span>Initech</span>
136
+ <span>Hooli</span>
137
+ <span>Soylent</span>
138
+ <span>Stark</span>
139
+ </div>
81
140
  </section>
82
141
 
83
- <p className="text-xs text-muted-foreground">
84
- Edit <Code>app/page.tsx</Code> and save — the page reloads instantly.
85
- The data model and access policies live in <Code>app.ts</Code>.
86
- </p>
142
+ {/* Features */}
143
+ <section className="mx-auto max-w-4xl px-4">
144
+ <div className="mx-auto max-w-2xl text-center">
145
+ <h2 className="text-3xl font-semibold tracking-tight">
146
+ Everything a growing team needs
147
+ </h2>
148
+ <p className="mt-3 text-muted-foreground">
149
+ Acme is multi-tenant from day one — every org is an isolated,
150
+ real-time workspace.
151
+ </p>
152
+ </div>
153
+ <div className="mt-12 grid gap-6 sm:grid-cols-3">
154
+ <Feature title="Organize by org" icon="▦">
155
+ Spin up an organization, invite your team, and switch between them in
156
+ a click. Each org&apos;s projects and members are completely private.
157
+ </Feature>
158
+ <Feature title="Real-time by default" icon="✦">
159
+ Changes sync instantly across everyone&apos;s screens — no refresh,
160
+ no stale data. Open two tabs and watch it happen.
161
+ </Feature>
162
+ <Feature title="Secure tenant isolation" icon="◆">
163
+ Access is enforced at the data layer. A member of one org can&apos;t
164
+ read or write another&apos;s rows — not by convention, by policy.
165
+ </Feature>
166
+ </div>
167
+ </section>
168
+
169
+ {/* CTA */}
170
+ <section className="mx-auto max-w-3xl px-4">
171
+ <div className="rounded-2xl border bg-gradient-to-br from-primary/10 to-transparent px-8 py-12 text-center">
172
+ <h2 className="text-3xl font-semibold tracking-tight">
173
+ Ready to get your team in sync?
174
+ </h2>
175
+ <p className="mx-auto mt-3 max-w-md text-muted-foreground">
176
+ Create your organization and invite your first teammate in under a
177
+ minute.
178
+ </p>
179
+ <div className="mt-6">
180
+ <Button asChild size="lg">
181
+ <Link href={signedIn ? "/dashboard" : "/signup"}>
182
+ {signedIn ? "Open dashboard →" : "Start for free →"}
183
+ </Link>
184
+ </Button>
185
+ </div>
186
+ </div>
187
+ </section>
87
188
  </div>
88
189
  );
89
190
  }
90
191
 
91
192
  function Feature({
92
193
  title,
194
+ icon,
93
195
  children,
94
196
  }: {
95
197
  title: string;
198
+ icon: string;
96
199
  children: React.ReactNode;
97
200
  }) {
98
201
  return (
99
- <Card>
100
- <CardHeader>
101
- <CardTitle className="text-base">{title}</CardTitle>
102
- </CardHeader>
103
- <CardContent>
104
- <CardDescription className="text-sm leading-relaxed">
105
- {children}
106
- </CardDescription>
107
- </CardContent>
108
- </Card>
202
+ <div className="rounded-xl border bg-card p-5">
203
+ <div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
204
+ {icon}
205
+ </div>
206
+ <h3 className="mt-4 text-base font-semibold tracking-tight">{title}</h3>
207
+ <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
208
+ {children}
209
+ </p>
210
+ </div>
109
211
  );
110
212
  }
111
-
112
- function Code({ children }: { children: React.ReactNode }) {
113
- return <code className="rounded bg-muted px-1 text-xs">{children}</code>;
114
- }
@@ -10,7 +10,7 @@ import {
10
10
  import { AuthForm } from "../auth-form";
11
11
 
12
12
  export const metadata: Metadata = {
13
- title: "Create your account — __APP_NAME__",
13
+ title: "Create your account — Acme",
14
14
  robots: "noindex",
15
15
  };
16
16
 
@@ -7,11 +7,9 @@ import {
7
7
  discoverAppRoutes,
8
8
  } from "@pylonsync/sdk";
9
9
 
10
- // Accounts. Email/password auth is built in: POST /api/auth/password/register
11
- // hashes the password and writes this row; /api/auth/password/login mints a
12
- // session and sets an HttpOnly cookie. The framework treats the entity named
13
- // "User" as the account table — `passwordHash` is server-only and never
14
- // serialized to a client.
10
+ // Accounts — email/password is built in (the entity named "User" is the
11
+ // account table; passwordHash is server-only). Each user can belong to many
12
+ // organizations.
15
13
  const User = entity(
16
14
  "User",
17
15
  {
@@ -26,40 +24,87 @@ const User = entity(
26
24
  { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
27
25
  );
28
26
 
29
- // A note that belongs to one user. `ownerId: field.owner()` is the key move:
30
- // the framework stamps the signed-in user's id server-side on insert and
31
- // rejects any forged value so the dashboard can do a plain, optimistic
32
- // `db.insert("Note", { body })` (the row shows instantly, no round-trip) while
33
- // the owner stays unspoofable. No createNote function to write.
34
- const Note = entity(
35
- "Note",
27
+ // ---------------------------------------------------------------------------
28
+ // Organizations multi-tenancy is a framework primitive. Declaring these
29
+ // three entities with the names + fields below lights up the built-in
30
+ // `/api/auth/orgs/*` routes (create/list orgs, members, invites) and
31
+ // `/api/auth/select-org` (switch your active tenant). The framework writes
32
+ // only the fields it manages; add your own (logo, plan, billingEmail…) freely.
33
+ // The `@pylonsync/client` `<OrganizationSwitcher>` drives all of this for you.
34
+ // ---------------------------------------------------------------------------
35
+ const Org = entity(
36
+ "Org",
36
37
  {
37
- ownerId: field.string().owner(),
38
- body: field.string(),
39
- done: field.boolean().default(false),
40
- createdAt: field.datetime().defaultNow(),
38
+ name: field.string(),
39
+ createdBy: field.id("User"),
40
+ createdAt: field.datetime(),
41
41
  },
42
- { indexes: [{ name: "by_owner", fields: ["ownerId"], unique: false }] },
42
+ { indexes: [{ name: "by_created_by", fields: ["createdBy"], unique: false }] },
43
43
  );
44
44
 
45
- // Notes are private every read and write is gated to the owner. An entity
46
- // with NO policy is denied to clients by default, so this is exactly what
47
- // makes the dashboard's live query + optimistic writes work, and only for
48
- // your own rows. `auth.userId` is the session user; `data.ownerId` is the row.
49
- const notePolicy = policy({
50
- name: "note_access",
51
- entity: "Note",
52
- allowRead: "auth.userId == data.ownerId",
53
- allowInsert: "auth.userId != null",
54
- allowUpdate: "auth.userId == data.ownerId",
55
- allowDelete: "auth.userId == data.ownerId",
56
- });
45
+ // User Org edge with a role. `select-org` checks this table before letting
46
+ // you switch tenants, so a client can't impersonate an org it doesn't belong
47
+ // to. role "owner" | "admin" | "member".
48
+ const OrgMember = entity(
49
+ "OrgMember",
50
+ {
51
+ orgId: field.id("Org"),
52
+ userId: field.id("User"),
53
+ role: field.string(),
54
+ joinedAt: field.datetime(),
55
+ },
56
+ {
57
+ indexes: [
58
+ { name: "by_org_user", fields: ["orgId", "userId"], unique: true },
59
+ { name: "by_user", fields: ["userId"], unique: false },
60
+ ],
61
+ },
62
+ );
63
+
64
+ // Pending invite. The framework's /api/auth/orgs/:id/invites endpoints write
65
+ // these (tokenHash is server-only — the raw token only ever goes to the
66
+ // invitee). accepted* are filled in when the invite is redeemed.
67
+ const OrgInvite = entity(
68
+ "OrgInvite",
69
+ {
70
+ orgId: field.id("Org"),
71
+ email: field.string(),
72
+ role: field.string(),
73
+ invitedBy: field.id("User"),
74
+ tokenHash: field.string().serverOnly(),
75
+ tokenPrefix: field.string(),
76
+ createdAt: field.datetime(),
77
+ expiresAt: field.datetime(),
78
+ acceptedAt: field.datetime().optional(),
79
+ acceptedByUserId: field.id("User").optional(),
80
+ },
81
+ {
82
+ indexes: [
83
+ { name: "by_org", fields: ["orgId"], unique: false },
84
+ { name: "by_email_org", fields: ["email", "orgId"], unique: false },
85
+ ],
86
+ },
87
+ );
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Your app's data — one tenant-scoped resource. `orgId` carries the tenant,
91
+ // and the policy scopes every read AND write to your ACTIVE org
92
+ // (`auth.tenantId`, set by select-org). Switch orgs in the UI and the project
93
+ // list changes — clients literally cannot read or write another tenant's rows.
94
+ // ---------------------------------------------------------------------------
95
+ const Project = entity(
96
+ "Project",
97
+ {
98
+ orgId: field.id("Org"),
99
+ name: field.string(),
100
+ createdAt: field.datetime().defaultNow(),
101
+ },
102
+ { indexes: [{ name: "by_org", fields: ["orgId"], unique: false }] },
103
+ );
57
104
 
58
- // User rows are read-only to clients, and only your own (so the dashboard can
59
- // read your display name). The auth subsystem owns writes — registration and
60
- // login go through /api/auth/password/*, never the entity API.
105
+ // User rows: read your own; the auth subsystem owns writes.
61
106
  const userPolicy = policy({
62
- name: "user_access",
107
+ name: "user_self",
63
108
  entity: "User",
64
109
  allowRead: "auth.userId == data.id",
65
110
  allowInsert: "false",
@@ -67,28 +112,68 @@ const userPolicy = policy({
67
112
  allowDelete: "false",
68
113
  });
69
114
 
70
- // The manifest is your whole app in one object: data, policies, and the
71
- // file-based routes under `app/`. `pylon dev` reads this, serves the SSR
72
- // frontend and the API from one port, and regenerates a typed client on
73
- // every change.
115
+ // Org / OrgMember / OrgInvite are managed by the framework's /api/auth/orgs
116
+ // routes (which bypass these policies via the OrgStore). Clients reach them
117
+ // through the `@pylonsync/client` org helpers, not the entity API so deny
118
+ // direct writes, and scope reads to your own membership / active org.
119
+ const orgPolicy = policy({
120
+ name: "org_access",
121
+ entity: "Org",
122
+ allowRead: "auth.tenantId == data.id",
123
+ allowInsert: "false",
124
+ allowUpdate: "false",
125
+ allowDelete: "false",
126
+ });
127
+ const orgMemberPolicy = policy({
128
+ name: "org_member_access",
129
+ entity: "OrgMember",
130
+ allowRead: "auth.userId == data.userId || auth.tenantId == data.orgId",
131
+ allowInsert: "false",
132
+ allowUpdate: "false",
133
+ allowDelete: "false",
134
+ });
135
+ const orgInvitePolicy = policy({
136
+ name: "org_invite_access",
137
+ entity: "OrgInvite",
138
+ allowRead: "auth.tenantId == data.orgId",
139
+ allowInsert: "false",
140
+ allowUpdate: "false",
141
+ allowDelete: "false",
142
+ });
143
+
144
+ // Projects are scoped to your ACTIVE tenant. `auth.tenantId == data.orgId`
145
+ // gates read AND write — and because orgId is client-supplied at insert time
146
+ // (not stamped later), checking it here means you can only create a project in
147
+ // the org you've selected. Switch orgs → a different project list.
148
+ const projectPolicy = policy({
149
+ name: "project_tenant",
150
+ entity: "Project",
151
+ allowRead: "auth.tenantId == data.orgId",
152
+ allowInsert: "auth.tenantId == data.orgId",
153
+ allowUpdate: "auth.tenantId == data.orgId",
154
+ allowDelete: "auth.tenantId == data.orgId",
155
+ });
156
+
74
157
  const manifest = buildManifest({
75
158
  name: "__APP_NAME__",
76
159
  version: "0.1.0",
77
- entities: [User, Note],
160
+ entities: [User, Org, OrgMember, OrgInvite, Project],
78
161
  queries: [],
79
162
  actions: [],
80
- policies: [userPolicy, notePolicy],
81
- // Email/password is on by default against the User entity above. `auth()`
82
- // is the knob for session lifetime, exposed fields, orgs, and trusted
83
- // origins — `auth({ session: { expiresIn: 60 * 60 * 24 * 7 } })` for a
84
- // 7-day session, etc.
163
+ policies: [
164
+ userPolicy,
165
+ orgPolicy,
166
+ orgMemberPolicy,
167
+ orgInvitePolicy,
168
+ projectPolicy,
169
+ ],
170
+ // Email/password is on by default against the User entity. The org entities
171
+ // above are named with the framework defaults (Org / OrgMember / OrgInvite),
172
+ // so `/api/auth/orgs/*` + `/api/auth/select-org` work with no extra config.
85
173
  auth: auth(),
86
- // File-based routing: `discoverAppRoutes()` walks `app/**/page.tsx` and
87
- // emits one route per page. Drop `app/about/page.tsx` to add `/about`.
88
174
  routes: await discoverAppRoutes(),
89
175
  });
90
176
 
91
- // Emit canonical manifest JSON to stdout for `pylon codegen`.
92
177
  console.log(JSON.stringify(manifest, null, 2));
93
178
 
94
179
  export default manifest;