@pylonsync/create-pylon 0.3.264 → 0.3.265

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.264",
3
+ "version": "0.3.265",
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"
@@ -26,7 +26,7 @@ Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like fram
26
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
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
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 `/notes`) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
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
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
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
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.
@@ -1,8 +1,8 @@
1
1
  # __APP_NAME__
2
2
 
3
- A full-stack [Pylon](https://pylonsync.com) app — server-rendered React,
4
- file-based routes, a synced database, and a typed client, served from one
5
- binary on one port. No Next.js, no separate API server.
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.
6
6
 
7
7
  ## Develop
8
8
 
@@ -10,23 +10,37 @@ binary on one port. No Next.js, no separate API server.
10
10
  __RUN_DEV__
11
11
  ```
12
12
 
13
- Open http://localhost:4321. Edit any file under `app/` and save — the page
14
- reloads instantly.
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
16
 
16
17
  ## Layout
17
18
 
18
19
  ```
19
- app.ts your data model + manifest (entities, functions, policies, routes)
20
- app/ file-based SSR routes — app/page.tsx is "/", app/counter/page.tsx is "/counter"
21
- app/layout.tsx the root layout wrapping every page (receives url + auth)
22
- app/globals.css Tailwind entrypoint (compiled by Pylon)
23
- functions/ server functions (query/action) typed RPC, auto-exposed
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
24
28
  ```
25
29
 
30
+ ## How auth works
31
+
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.
38
+
26
39
  ## Add a route
27
40
 
28
41
  Drop a file at `app/about/page.tsx` and visit `/about`. Pages receive
29
- `{ url, auth, searchParams }` from the SSR runtime.
42
+ `{ url, params, searchParams, auth, response, serverData }` from the SSR
43
+ runtime — all typed via `PageProps` from `@pylonsync/react`.
30
44
 
31
45
  ## Add data
32
46
 
@@ -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,116 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ export interface Note {
9
+ id: string;
10
+ body: string;
11
+ done: boolean;
12
+ }
13
+
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
+ }
40
+
41
+ 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
+ await signOut();
45
+ window.location.assign("/");
46
+ }
47
+
48
+ return (
49
+ <div className="space-y-5">
50
+ <div className="flex items-center justify-end">
51
+ <Button variant="ghost" size="sm" onClick={onSignOut}>
52
+ Sign out
53
+ </Button>
54
+ </div>
55
+
56
+ <form onSubmit={addNote} className="flex items-center gap-2">
57
+ <input
58
+ value={body}
59
+ onChange={(e) => setBody(e.target.value)}
60
+ placeholder="Write a note…"
61
+ aria-label="Note"
62
+ 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
+ />
64
+ <Button type="submit">Add</Button>
65
+ </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>
72
+ ) : (
73
+ <ul className="space-y-2">
74
+ {notes.map((note) => (
75
+ <li
76
+ key={note.id}
77
+ className="flex items-center gap-3 rounded-md border px-3 py-2 text-sm"
78
+ >
79
+ <button
80
+ 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"
107
+ >
108
+
109
+ </button>
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,70 @@
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";
8
+
9
+ export const metadata: Metadata = {
10
+ title: "Dashboard — __APP_NAME__",
11
+ robots: "noindex",
12
+ };
13
+
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");
60
+ return (
61
+ <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>
68
+ </div>
69
+ );
70
+ }
@@ -2,18 +2,18 @@ import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
3
 
4
4
  // A layout receives the page props plus `children`. `auth.user_id` is null
5
- // for anonymous visitors wire a sign-in flow with @pylonsync/client when
6
- // you're ready; for now this just shows the session state. The `PageAuth`
7
- // type is exported from @pylonsync/react so you never hand-roll it.
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.
8
9
  interface LayoutProps {
9
10
  children: React.ReactNode;
10
11
  url: string;
11
12
  auth: PageAuth;
12
13
  }
13
14
 
14
- // The root layout wraps every page. It receives `url` and `auth` from the
15
- // SSR runtime on every render — server-side, before the HTML is sent.
16
- export default function RootLayout({ children, url, auth }: LayoutProps) {
15
+ // The root layout wraps every page.
16
+ export default function RootLayout({ children, auth }: LayoutProps) {
17
17
  const signedIn = Boolean(auth?.user_id);
18
18
  // Add `className="dark"` to this <html> to flip every shadcn token to its
19
19
  // dark value. The classes below use semantic tokens (bg-background,
@@ -41,18 +41,23 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
41
41
  <Link href="/" className="hover:text-foreground">
42
42
  Home
43
43
  </Link>
44
- <Link href="/counter" className="hover:text-foreground">
45
- Counter
46
- </Link>
47
- <Link href="/notes" className="hover:text-foreground">
48
- Notes
49
- </Link>
50
- <span
51
- className={signedIn ? "text-emerald-600" : "text-muted-foreground/60"}
52
- title={url}
53
- >
54
- {signedIn ? `· ${auth.user_id}` : "· anon"}
55
- </span>
44
+ {signedIn ? (
45
+ <Link href="/dashboard" className="hover:text-foreground">
46
+ Dashboard
47
+ </Link>
48
+ ) : (
49
+ <>
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>
59
+ </>
60
+ )}
56
61
  </nav>
57
62
  </div>
58
63
  </header>
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@/components/ui/card";
10
+ import { AuthForm } from "../auth-form";
11
+
12
+ export const metadata: Metadata = {
13
+ title: "Sign in — __APP_NAME__",
14
+ // Auth pages shouldn't be indexed.
15
+ robots: "noindex",
16
+ };
17
+
18
+ // `app/login/page.tsx` → `/login`. A server-rendered shell around the
19
+ // client-side <AuthForm> island.
20
+ export default function LoginPage({ auth, response }: PageProps) {
21
+ // Already signed in? Skip the form. `response.redirect` runs in the
22
+ // synchronous shell render, so it's a real 307 before any HTML is sent
23
+ // (no flash, works with JS disabled).
24
+ if (auth.user_id) response.redirect("/dashboard");
25
+ return (
26
+ <div className="mx-auto max-w-sm">
27
+ <Card>
28
+ <CardHeader>
29
+ <CardTitle>Sign in</CardTitle>
30
+ <CardDescription>Welcome back.</CardDescription>
31
+ </CardHeader>
32
+ <CardContent className="space-y-4">
33
+ <AuthForm mode="login" />
34
+ <p className="text-center text-sm text-muted-foreground">
35
+ No account?{" "}
36
+ <Link
37
+ href="/signup"
38
+ className="font-medium text-foreground hover:underline"
39
+ >
40
+ Create one
41
+ </Link>
42
+ </p>
43
+ </CardContent>
44
+ </Card>
45
+ </div>
46
+ );
47
+ }
@@ -16,83 +16,99 @@ import {
16
16
  export const metadata: Metadata = {
17
17
  title: "__APP_NAME__ — full-stack Pylon app",
18
18
  description:
19
- "Server-rendered React, file-based routes, a synced database, and a typed client — one binary, one port.",
19
+ "A server-rendered homepage, email/password auth, and a live client dashboard over one synced backend — one binary, one port.",
20
20
  };
21
21
 
22
- // `app/page.tsx` → `/`. Every page receives `PageProps` from the SSR
23
- // runtime: `{ url, params, searchParams, auth, response, serverData }`
24
- // the type is exported from @pylonsync/react, no hand-rolled interface.
25
- // This renders to HTML on the server; the per-route chunk hydrates it in
26
- // the browser so interactive pages (see /counter) just work. shadcn/ui is
27
- // pre-wired — `Button`/`Card` resolve through the `@/` alias; add more with
28
- // `npx shadcn@latest add <component>`.
29
- export default function IndexPage({ url }: PageProps) {
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) {
29
+ const signedIn = Boolean(auth.user_id);
30
30
  return (
31
- <div className="space-y-8">
32
- <section>
33
- <h1 className="text-3xl font-semibold tracking-tight">__APP_NAME__</h1>
34
- <p className="mt-2 text-muted-foreground">
35
- A full-stack Pylon app. Server-rendered React, file-based routes,
36
- a synced database, and a typed client — served from one binary on
37
- one port. No Next.js, no separate API server. Styled with Tailwind
38
- v4 and shadcn/ui out of the box.
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.
39
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>
54
+ </Button>
55
+ <Button asChild variant="outline">
56
+ <Link href="/login">Sign in</Link>
57
+ </Button>
58
+ </>
59
+ )}
60
+ </div>
40
61
  </section>
41
62
 
42
- <Card>
43
- <CardHeader>
44
- <CardTitle>Next steps</CardTitle>
45
- <CardDescription>
46
- Everything below is already wired — just edit the files.
47
- </CardDescription>
48
- </CardHeader>
49
- <CardContent>
50
- <ul className="space-y-2 text-sm text-foreground/80">
51
- <li>
52
- Add a route: drop a file at{" "}
53
- <code className="rounded bg-muted px-1">app/about/page.tsx</code>{" "}
54
- and visit <code className="rounded bg-muted px-1">/about</code>.
55
- </li>
56
- <li>
57
- Add data: edit{" "}
58
- <code className="rounded bg-muted px-1">app.ts</code> every{" "}
59
- <code className="rounded bg-muted px-1">entity()</code> gets a
60
- REST + realtime API and a typed client automatically.
61
- </li>
62
- <li>
63
- Add a component:{" "}
64
- <code className="rounded bg-muted px-1">
65
- npx shadcn@latest add dialog
66
- </code>{" "}
67
- drops it into{" "}
68
- <code className="rounded bg-muted px-1">components/ui</code>.
69
- </li>
70
- </ul>
71
- </CardContent>
72
- </Card>
73
-
74
- <div className="flex flex-wrap items-center gap-3">
75
- <Button asChild>
76
- <Link href="/counter">See hydration in action →</Link>
77
- </Button>
78
- <Button asChild variant="outline">
79
- <Link href="/notes">Server data in the render →</Link>
80
- </Button>
81
- <Button asChild variant="ghost">
82
- <a
83
- href="https://docs.pylon.dev"
84
- target="_blank"
85
- rel="noreferrer noopener"
86
- >
87
- Read the docs
88
- </a>
89
- </Button>
90
- </div>
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>
81
+ </section>
91
82
 
92
83
  <p className="text-xs text-muted-foreground">
93
- You're at <code>{url}</code>. Edit{" "}
94
- <code>app/page.tsx</code> and save the page reloads instantly.
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>.
95
86
  </p>
96
87
  </div>
97
88
  );
98
89
  }
90
+
91
+ function Feature({
92
+ title,
93
+ children,
94
+ }: {
95
+ title: string;
96
+ children: React.ReactNode;
97
+ }) {
98
+ 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>
109
+ );
110
+ }
111
+
112
+ function Code({ children }: { children: React.ReactNode }) {
113
+ return <code className="rounded bg-muted px-1 text-xs">{children}</code>;
114
+ }
@@ -0,0 +1,12 @@
1
+ import type { Robots } from "@pylonsync/react";
2
+
3
+ // app/robots.ts → served at /robots.txt. The default export may also be async.
4
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
5
+
6
+ export default function robots(): Robots {
7
+ return {
8
+ // Keep the authenticated app and the API out of the index.
9
+ rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
10
+ sitemap: `${SITE}/sitemap.xml`,
11
+ };
12
+ }
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@/components/ui/card";
10
+ import { AuthForm } from "../auth-form";
11
+
12
+ export const metadata: Metadata = {
13
+ title: "Create your account — __APP_NAME__",
14
+ robots: "noindex",
15
+ };
16
+
17
+ // `app/signup/page.tsx` → `/signup`. Same shell as /login, register mode.
18
+ export default function SignupPage({ auth, response }: PageProps) {
19
+ if (auth.user_id) response.redirect("/dashboard");
20
+ return (
21
+ <div className="mx-auto max-w-sm">
22
+ <Card>
23
+ <CardHeader>
24
+ <CardTitle>Create your account</CardTitle>
25
+ <CardDescription>
26
+ Email + password. No credit card, no email verification step in dev.
27
+ </CardDescription>
28
+ </CardHeader>
29
+ <CardContent className="space-y-4">
30
+ <AuthForm mode="signup" />
31
+ <p className="text-center text-sm text-muted-foreground">
32
+ Already have an account?{" "}
33
+ <Link
34
+ href="/login"
35
+ className="font-medium text-foreground hover:underline"
36
+ >
37
+ Sign in
38
+ </Link>
39
+ </p>
40
+ </CardContent>
41
+ </Card>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+
3
+ // app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
4
+ // it can enumerate dynamic pages from your database. Point SITE_URL at your
5
+ // domain in production.
6
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
7
+
8
+ export default async function sitemap(): Promise<Sitemap> {
9
+ // Only public pages belong here — /dashboard is private (and noindex), so
10
+ // it's intentionally left out.
11
+ const staticRoutes: Sitemap = [
12
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
13
+ { url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
14
+ { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
15
+ ];
16
+
17
+ // The export is async, so you can enumerate dynamic pages from a DB read:
18
+ //
19
+ // const posts = await fetchPublishedPosts();
20
+ // const postRoutes: Sitemap = posts.map((p) => ({
21
+ // url: `${SITE}/blog/${p.slug}`,
22
+ // lastModified: p.updatedAt,
23
+ // }));
24
+ // return [...staticRoutes, ...postRoutes];
25
+
26
+ return staticRoutes;
27
+ }
@@ -1,28 +1,87 @@
1
- import { buildManifest, discoverAppRoutes, entity, field } from "@pylonsync/sdk";
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ } from "@pylonsync/sdk";
2
9
 
3
- // Your data model. Every `entity()` becomes a synced table with a REST +
4
- // realtime API and a typed client no migrations to write, no resolvers
5
- // to wire. Add fields here and they show up everywhere.
6
- const Note = entity("Note", {
7
- body: field.string(),
8
- done: field.boolean().default(false),
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.
15
+ const User = entity(
16
+ "User",
17
+ {
18
+ email: field.string(),
19
+ displayName: field.string().optional(),
20
+ passwordHash: field.string().serverOnly().optional(),
21
+ createdAt: field.datetime().defaultNow(),
22
+ },
23
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
24
+ );
25
+
26
+ // A note that belongs to one user. `ownerId: field.owner()` is the key move:
27
+ // the framework stamps the signed-in user's id server-side on insert and
28
+ // rejects any forged value — so the dashboard can do a plain, optimistic
29
+ // `db.insert("Note", { body })` (the row shows instantly, no round-trip) while
30
+ // the owner stays unspoofable. No createNote function to write.
31
+ const Note = entity(
32
+ "Note",
33
+ {
34
+ ownerId: field.string().owner(),
35
+ body: field.string(),
36
+ done: field.boolean().default(false),
37
+ createdAt: field.datetime().defaultNow(),
38
+ },
39
+ { indexes: [{ name: "by_owner", fields: ["ownerId"], unique: false }] },
40
+ );
41
+
42
+ // Notes are private — every read and write is gated to the owner. An entity
43
+ // with NO policy is denied to clients by default, so this is exactly what
44
+ // makes the dashboard's live query + optimistic writes work, and only for
45
+ // your own rows. `auth.userId` is the session user; `data.ownerId` is the row.
46
+ const notePolicy = policy({
47
+ name: "note_access",
48
+ entity: "Note",
49
+ allowRead: "auth.userId == data.ownerId",
50
+ allowInsert: "auth.userId != null",
51
+ allowUpdate: "auth.userId == data.ownerId",
52
+ allowDelete: "auth.userId == data.ownerId",
53
+ });
54
+
55
+ // User rows are read-only to clients, and only your own (so the dashboard can
56
+ // read your display name). The auth subsystem owns writes — registration and
57
+ // login go through /api/auth/password/*, never the entity API.
58
+ const userPolicy = policy({
59
+ name: "user_access",
60
+ entity: "User",
61
+ allowRead: "auth.userId == data.id",
62
+ allowInsert: "false",
63
+ allowUpdate: "false",
64
+ allowDelete: "false",
9
65
  });
10
66
 
11
- // The manifest is your whole app in one object: data, server functions,
12
- // access policies, and the file-based routes under `app/`. `pylon dev`
13
- // reads this, serves the SSR frontend and the API from one port, and
14
- // regenerates a typed client on every change.
15
- //
16
- // File-based routing: `discoverAppRoutes()` walks `app/**/page.tsx` and
17
- // emits one route per page. Drop `app/about/page.tsx` to add `/about` —
18
- // no route table to maintain.
67
+ // The manifest is your whole app in one object: data, policies, and the
68
+ // file-based routes under `app/`. `pylon dev` reads this, serves the SSR
69
+ // frontend and the API from one port, and regenerates a typed client on
70
+ // every change.
19
71
  const manifest = buildManifest({
20
72
  name: "__APP_NAME__",
21
73
  version: "0.1.0",
22
- entities: [Note],
74
+ entities: [User, Note],
23
75
  queries: [],
24
76
  actions: [],
25
- policies: [],
77
+ policies: [userPolicy, notePolicy],
78
+ // Email/password is on by default against the User entity above. `auth()`
79
+ // is the knob for session lifetime, exposed fields, orgs, and trusted
80
+ // origins — `auth({ session: { expiresIn: 60 * 60 * 24 * 7 } })` for a
81
+ // 7-day session, etc.
82
+ auth: auth(),
83
+ // File-based routing: `discoverAppRoutes()` walks `app/**/page.tsx` and
84
+ // emits one route per page. Drop `app/about/page.tsx` to add `/about`.
26
85
  routes: await discoverAppRoutes(),
27
86
  });
28
87
 
@@ -12,6 +12,7 @@
12
12
  "@pylonsync/react": "^__PYLON_VERSION__",
13
13
  "@pylonsync/sdk": "^__PYLON_VERSION__",
14
14
  "@pylonsync/functions": "^__PYLON_VERSION__",
15
+ "@pylonsync/client": "^__PYLON_VERSION__",
15
16
  "react": "^19.0.0",
16
17
  "react-dom": "^19.0.0",
17
18
  "tailwindcss": "^4.3.0",
@@ -1,54 +0,0 @@
1
- import React from "react";
2
- import type { PageProps } from "@pylonsync/react";
3
- import { Button } from "@/components/ui/button";
4
-
5
- // `app/counter/page.tsx` → `/counter`. This page is server-rendered AND
6
- // interactive: the HTML arrives with the initial count already in it (try
7
- // /counter?start=10), then the per-route chunk hydrates and useState takes
8
- // over. No client/server split to manage — it's one component. The buttons
9
- // are shadcn/ui `Button`s, hydrated in place.
10
- export default function CounterPage({ searchParams }: PageProps) {
11
- const start = Number(searchParams.start ?? "0") || 0;
12
- const [count, setCount] = React.useState(start);
13
- return (
14
- <div className="space-y-6">
15
- <h1 className="text-2xl font-semibold tracking-tight">Counter</h1>
16
- <p className="text-muted-foreground">
17
- Rendered on the server, hydrated in the browser. The buttons work
18
- because the page's JS chunk hydrated this exact markup.
19
- </p>
20
- <div className="flex items-center gap-4">
21
- <Button
22
- variant="outline"
23
- size="icon"
24
- onClick={() => setCount((c) => c - 1)}
25
- aria-label="Decrement"
26
- >
27
-
28
- </Button>
29
- <span className="min-w-12 text-center text-2xl font-semibold tabular-nums">
30
- {count}
31
- </span>
32
- <Button
33
- variant="outline"
34
- size="icon"
35
- onClick={() => setCount((c) => c + 1)}
36
- aria-label="Increment"
37
- >
38
- +
39
- </Button>
40
- </div>
41
- <p className="text-xs text-muted-foreground">
42
- Initial value comes from <code>?start=</code> — search params flow
43
- through SSR. Try{" "}
44
- <a
45
- href="/counter?start=10"
46
- className="text-primary underline-offset-4 hover:underline"
47
- >
48
- /counter?start=10
49
- </a>
50
- .
51
- </p>
52
- </div>
53
- );
54
- }
@@ -1,153 +0,0 @@
1
- import React, { Suspense, use } from "react";
2
- import {
3
- Form,
4
- type Metadata,
5
- type PageProps,
6
- type ServerData,
7
- } from "@pylonsync/react";
8
- import { Button } from "@/components/ui/button";
9
- import {
10
- Card,
11
- CardContent,
12
- CardDescription,
13
- CardHeader,
14
- CardTitle,
15
- } from "@/components/ui/card";
16
-
17
- // SEO metadata for `/notes`. (`generateMetadata(props)` is the dynamic
18
- // form when the title depends on params — e.g. a `[slug]` route.)
19
- export const metadata: Metadata = {
20
- title: "Notes — __APP_NAME__",
21
- description:
22
- "A list of notes read from the database during the server render — no client fetch, no loading flash.",
23
- };
24
-
25
- // Progressive streaming opt-in (#278). With this, the page shell — heading,
26
- // blurb, the <Form> — flushes to the browser IMMEDIATELY, and the inner
27
- // <Suspense> around <NotesList> streams in its "Loading notes…" fallback,
28
- // then swaps in the real rows when serverData resolves. Without it, the whole
29
- // page (including the notes) is buffered and arrives in one shot. Streaming
30
- // pages are never CDN/disk-cached (the head commits before the data resolves),
31
- // so don't combine it with `export const revalidate`.
32
- export const streaming = true;
33
-
34
- // The `Note` entity from app.ts. Type your rows however you like; the
35
- // shape is whatever your entity declares.
36
- interface Note {
37
- id: string;
38
- body: string;
39
- done: boolean;
40
- }
41
-
42
- // This component reads the database DURING the render. `serverData.list`
43
- // returns a promise; React 19 `use()` suspends the subtree until it
44
- // resolves on the server, then the HTML streams with the rows already in
45
- // it — no `useEffect`, no client round-trip, no loading flash on first
46
- // paint. The resolved value is replayed into the hydration payload, so the
47
- // browser renders the exact same markup. Reads run through the same policy
48
- // gate as a query function's `ctx.db`; writes are rejected.
49
- function NotesList({ serverData }: { serverData: ServerData }) {
50
- const notes = use(serverData.list<Note>("Note"));
51
-
52
- if (notes.length === 0) {
53
- return (
54
- <p className="text-sm text-muted-foreground">
55
- No notes yet. Create one through the auto-generated API:{" "}
56
- <code className="rounded bg-muted px-1">
57
- curl -X POST localhost:8787/api/entities/Note -d
58
- '{"{"}"body":"hello"{"}"}'
59
- </code>{" "}
60
- then refresh.
61
- </p>
62
- );
63
- }
64
-
65
- return (
66
- <ul className="space-y-2">
67
- {notes.map((note) => (
68
- <li
69
- key={note.id}
70
- className="flex items-center gap-3 rounded-md border px-3 py-2 text-sm"
71
- >
72
- <span
73
- aria-hidden
74
- className={
75
- note.done
76
- ? "text-emerald-600"
77
- : "text-muted-foreground/50"
78
- }
79
- >
80
- {note.done ? "✓" : "○"}
81
- </span>
82
- <span className={note.done ? "line-through text-muted-foreground" : ""}>
83
- {note.body}
84
- </span>
85
- </li>
86
- ))}
87
- </ul>
88
- );
89
- }
90
-
91
- // `app/notes/page.tsx` → `/notes`. The page destructures `serverData` out
92
- // of its `PageProps` and hands it to the suspending child. (The same props
93
- // carry `response` for status/redirect/cookies and `params`/`searchParams`
94
- // for the URL — all typed, all from @pylonsync/react.)
95
- export default function NotesPage({ serverData, searchParams }: PageProps) {
96
- return (
97
- <div className="space-y-6">
98
- <section>
99
- <h1 className="text-2xl font-semibold tracking-tight">Notes</h1>
100
- <p className="mt-2 text-muted-foreground">
101
- These rows are read from the database <em>during the server
102
- render</em> with <code>serverData</code> + React&apos;s{" "}
103
- <code>use()</code>. The HTML arrives with the data in it — view
104
- source and you&apos;ll see the notes in the markup, not an empty
105
- shell that fetches later.
106
- </p>
107
- </section>
108
-
109
- {/* No-JS form (#276). Posts to app/notes/route.ts, which creates a Note
110
- and 303-redirects back here. Works with JS disabled; the runtime
111
- enhances it (no full reload) when JS is on. */}
112
- {searchParams.created ? (
113
- <p className="rounded-md border border-emerald-600/30 bg-emerald-600/10 px-3 py-2 text-sm text-emerald-700">
114
- Note added.
115
- </p>
116
- ) : null}
117
- {searchParams.error ? (
118
- <p className="rounded-md border border-red-600/30 bg-red-600/10 px-3 py-2 text-sm text-red-700">
119
- {searchParams.error}
120
- </p>
121
- ) : null}
122
- <Form action="/notes" className="flex items-center gap-2">
123
- <input
124
- name="body"
125
- placeholder="Write a note…"
126
- aria-label="Note"
127
- 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"
128
- />
129
- <Button type="submit">Add</Button>
130
- </Form>
131
-
132
- <Card>
133
- <CardHeader>
134
- <CardTitle>From the database</CardTitle>
135
- <CardDescription>
136
- Server-rendered, then hydrated. Edit{" "}
137
- <code className="rounded bg-muted px-1">app.ts</code> to change
138
- the <code className="rounded bg-muted px-1">Note</code> shape.
139
- </CardDescription>
140
- </CardHeader>
141
- <CardContent>
142
- <Suspense
143
- fallback={
144
- <p className="text-sm text-muted-foreground">Loading notes…</p>
145
- }
146
- >
147
- <NotesList serverData={serverData} />
148
- </Suspense>
149
- </CardContent>
150
- </Card>
151
- </div>
152
- );
153
- }
@@ -1,21 +0,0 @@
1
- import { type RouteHandler } from "@pylonsync/react";
2
-
3
- // `app/notes/route.ts` → handles POST /notes (the same path the page lives at).
4
- // A `<Form action="/notes">` posts here; we create a Note, then 303-redirect
5
- // back to /notes (POST-redirect-GET) so a no-JS browser re-renders with the new
6
- // note. Validation errors round-trip through a query param. With JS, the
7
- // runtime intercepts the submit + swaps the page in without a full reload —
8
- // same handler, no extra code.
9
- //
10
- // CSRF is automatic: Pylon's Origin gate rejects cross-site POSTs before this
11
- // runs, and the session cookie is SameSite=Lax. Writes here use the normal
12
- // function trust model — gate sensitive actions on `auth` inside the handler.
13
- export const POST: RouteHandler = async ({ form, db, response }) => {
14
- const body = (form.get("body") ?? "").trim();
15
- if (!body) {
16
- response.redirect("/notes?error=" + encodeURIComponent("Note can't be empty"));
17
- return;
18
- }
19
- await db.insert("Note", { body });
20
- response.redirect("/notes?created=1");
21
- };