@pylonsync/create-pylon 0.3.295 → 0.3.297

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/templates/agency/app/globals.css +8 -1
  3. package/templates/agency/app/layout.tsx +4 -6
  4. package/templates/agency/app.ts +15 -0
  5. package/templates/agency/components/marketing.tsx +2 -4
  6. package/templates/agency/lib/site.config.ts +3 -5
  7. package/templates/ai-chat/app/globals.css +8 -1
  8. package/templates/ai-chat/app/layout.tsx +4 -8
  9. package/templates/ai-chat/app.ts +18 -5
  10. package/templates/ai-chat/lib/site.config.ts +3 -3
  11. package/templates/ai-studio/app/globals.css +8 -1
  12. package/templates/ai-studio/app/layout.tsx +7 -11
  13. package/templates/ai-studio/app.ts +20 -7
  14. package/templates/ai-studio/lib/site.config.ts +2 -2
  15. package/templates/ai-studio/lib/studio.ts +1 -1
  16. package/templates/backend/b2b/apps/api/schema.ts +10 -24
  17. package/templates/backend/consumer/apps/api/schema.ts +4 -7
  18. package/templates/barebones/app/layout.tsx +3 -3
  19. package/templates/barebones/app.ts +2 -3
  20. package/templates/chat/app.ts +3 -9
  21. package/templates/consumer/app.ts +2 -3
  22. package/templates/creator/.env.example +3 -3
  23. package/templates/creator/app/globals.css +8 -1
  24. package/templates/creator/app/layout.tsx +4 -6
  25. package/templates/creator/app.ts +23 -10
  26. package/templates/creator/components/marketing.tsx +2 -4
  27. package/templates/default/app/globals.css +8 -1
  28. package/templates/default/app/layout.tsx +6 -14
  29. package/templates/default/app.ts +15 -0
  30. package/templates/default/lib/products.ts +2 -3
  31. package/templates/default/lib/site.config.ts +1 -2
  32. package/templates/default/lib/site.ts +3 -4
  33. package/templates/directory/app/auth-form.tsx +5 -5
  34. package/templates/directory/app/globals.css +8 -1
  35. package/templates/directory/app/layout.tsx +4 -6
  36. package/templates/directory/app/sitemap.ts +1 -1
  37. package/templates/directory/app.ts +15 -0
  38. package/templates/directory/lib/owner.ts +5 -5
  39. package/templates/expo/chat/apps/expo/App.tsx +2 -3
  40. package/templates/local-service/app/auth-form.tsx +4 -4
  41. package/templates/local-service/app/globals.css +8 -1
  42. package/templates/local-service/app/layout.tsx +4 -6
  43. package/templates/local-service/app/sitemap.ts +1 -1
  44. package/templates/local-service/app.ts +15 -0
  45. package/templates/local-service/components/marketing.tsx +2 -4
  46. package/templates/local-service/lib/owner.ts +3 -3
  47. package/templates/local-service/lib/site.config.ts +4 -6
  48. package/templates/marketplace/app/listing/[id]/page.tsx +3 -3
  49. package/templates/marketplace/functions/makeOffer.ts +0 -1
  50. package/templates/restaurant/app/auth-form.tsx +5 -5
  51. package/templates/restaurant/app/globals.css +8 -1
  52. package/templates/restaurant/app/layout.tsx +4 -6
  53. package/templates/restaurant/app/sitemap.ts +1 -1
  54. package/templates/restaurant/app.ts +15 -0
  55. package/templates/restaurant/lib/owner.ts +5 -5
  56. package/templates/shop/app/auth-form.tsx +5 -5
  57. package/templates/shop/app/globals.css +8 -1
  58. package/templates/shop/app/layout.tsx +5 -7
  59. package/templates/shop/app/sitemap.ts +1 -1
  60. package/templates/shop/app.ts +15 -0
  61. package/templates/shop/lib/owner.ts +6 -5
  62. package/templates/todo/app/layout.tsx +2 -2
  63. package/templates/todo/app/sitemap.ts +1 -1
  64. package/templates/todo/app.ts +4 -5
  65. package/templates/vite/todo/apps/web/vite.config.ts +5 -9
  66. package/templates/waitlist/app/globals.css +8 -1
  67. package/templates/waitlist/app/layout.tsx +4 -6
  68. package/templates/waitlist/app/waitlist-hero.tsx +4 -5
  69. package/templates/waitlist/app.ts +26 -9
  70. package/templates/waitlist/lib/site.config.ts +3 -5
  71. package/templates/web/barebones/apps/web/postcss.config.mjs +0 -1
  72. package/templates/web/barebones/apps/web/src/app/globals.css +1 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.295",
3
+ "version": "0.3.297",
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"
@@ -139,7 +139,14 @@
139
139
  body {
140
140
  background-color: var(--color-background);
141
141
  color: var(--color-foreground);
142
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
142
+ font-family: var(
143
+ --font-sans,
144
+ Inter,
145
+ ui-sans-serif,
146
+ system-ui,
147
+ -apple-system,
148
+ sans-serif
149
+ );
143
150
  -webkit-font-smoothing: antialiased;
144
151
  }
145
152
  button {
@@ -45,12 +45,10 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
45
45
  <meta charSet="utf-8" />
46
46
  <meta name="viewport" content="width=device-width, initial-scale=1" />
47
47
  {/* No <title> here — each page's exported `metadata` sets it. */}
48
- <link rel="preconnect" href="https://fonts.googleapis.com" />
49
- <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
50
- <link
51
- rel="stylesheet"
52
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
53
- />
48
+ {/* Inter is declared in app.ts (fonts: [...]) and self-hosted by the
49
+ build — the runtime injects @font-face + <link rel=preload> + a
50
+ size-adjusted fallback here automatically. No third-party request,
51
+ no layout shift; change the family in app.ts. */}
54
52
  {/* Tailwind is compiled by Pylon from app/globals.css and injected here. */}
55
53
  </head>
56
54
  <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
@@ -5,6 +5,7 @@ import {
5
5
  auth,
6
6
  buildManifest,
7
7
  discoverAppRoutes,
8
+ font,
8
9
  } from "@pylonsync/sdk";
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -276,6 +277,20 @@ const manifest = buildManifest({
276
277
  // Email/password is on by default against the User entity above. No orgs, no
277
278
  // billing — a single studio is single-tenant (one business, one owner).
278
279
  auth: auth(),
280
+ // Self-hosted Inter (next/font parity): the build fetches the woff2, serves it
281
+ // same-origin (no third-party request, no FOUT), preloads it, and synthesizes a
282
+ // size-adjusted fallback face so there's no layout shift. globals.css reads it
283
+ // via `var(--font-sans, …)`; layout.tsx carries no font <link>.
284
+ fonts: [
285
+ font({
286
+ family: "Inter",
287
+ variable: "--font-sans",
288
+ weights: ["400", "500", "600", "700"],
289
+ subsets: ["latin"],
290
+ display: "swap",
291
+ preload: true,
292
+ }),
293
+ ],
279
294
  routes: await discoverAppRoutes(),
280
295
  });
281
296
 
@@ -134,10 +134,8 @@ export function ProjectCard({ p }: { p: ProjectView }) {
134
134
  );
135
135
  }
136
136
 
137
- // A deliberately-obvious image placeholder. Real sites drop a photo here; this
138
- // makes the spot unmistakable — dashed border, a photo glyph, and a one-line
139
- // "swap this" instruction telling you exactly what to replace and where. Looks
140
- // tidy enough to demo, but no one will mistake it for a finished design.
137
+ // A deliberately-obvious image placeholder for spots where a real photo belongs:
138
+ // dashed border, a photo glyph, and a one-line "swap this" instruction.
141
139
  //
142
140
  // shape — "landscape" | "portrait" | "square" | "circle"
143
141
  // title — what photo belongs here ("Your headshot")
@@ -1,8 +1,6 @@
1
- // THE single source of truth for everything business-specific on this agency
2
- // site. Rebrand the whole studio by editing this ONE file — the landing page,
3
- // layout, and the seedCapacity function all read from here. The create-pylon
4
- // scaffolder and Mast target this file: a whole studio site is themed from one
5
- // typed object.
1
+ // The single source of truth for everything business-specific on this agency
2
+ // site. Rebrand the whole studio by editing this one file — the landing page,
3
+ // layout, and the seedCapacity function all read from here.
6
4
  //
7
5
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx).
8
6
  // Fictional demo copy — replace the values, keep the shape. Anywhere a real
@@ -139,7 +139,14 @@
139
139
  body {
140
140
  background-color: var(--color-background);
141
141
  color: var(--color-foreground);
142
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
142
+ font-family: var(
143
+ --font-sans,
144
+ Inter,
145
+ ui-sans-serif,
146
+ system-ui,
147
+ -apple-system,
148
+ sans-serif
149
+ );
143
150
  -webkit-font-smoothing: antialiased;
144
151
  }
145
152
  button {
@@ -13,8 +13,6 @@ interface LayoutProps {
13
13
  }
14
14
 
15
15
  export default function RootLayout({ children, url, auth }: LayoutProps) {
16
- // Resolved server-side from the session cookie. The app requires sign-in, so
17
- // this is set on every in-app page; the header reflects it with no flash.
18
16
  const signedIn = Boolean(auth?.user_id);
19
17
  const { brand, colors } = siteConfig;
20
18
 
@@ -36,12 +34,10 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
36
34
  <head>
37
35
  <meta charSet="utf-8" />
38
36
  <meta name="viewport" content="width=device-width, initial-scale=1" />
39
- <link rel="preconnect" href="https://fonts.googleapis.com" />
40
- <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
41
- <link
42
- rel="stylesheet"
43
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
44
- />
37
+ {/* Inter is declared in app.ts (fonts: [...]) and self-hosted by the
38
+ build — the runtime injects @font-face + <link rel=preload> + a
39
+ size-adjusted fallback here automatically. No third-party request,
40
+ no layout shift; change the family in app.ts. */}
45
41
  </head>
46
42
  <body className="bg-background text-foreground antialiased">
47
43
  {isBare ? (
@@ -5,14 +5,14 @@ import {
5
5
  auth,
6
6
  buildManifest,
7
7
  discoverAppRoutes,
8
+ font,
8
9
  } from "@pylonsync/sdk";
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // ai-chat — a streaming AI chat app. Tokens stream from the built-in
12
13
  // `POST /api/ai/stream` endpoint (your PYLON_AI_API_KEY never leaves the
13
- // server); the conversation itself is sync-backed, so your chats follow you
14
- // across tabs and devices in realtime — open two tabs and a reply you send in
15
- // one shows up in the other as it's saved.
14
+ // server); the conversation itself is sync-backed, so your chats stay in sync
15
+ // across tabs and devices in realtime.
16
16
  //
17
17
  // Two data entities (+ User):
18
18
  // • Conversation — a chat thread. Owner-scoped: you only ever see your own.
@@ -74,8 +74,7 @@ const User = entity(
74
74
  // Conversations + messages are PRIVATE: a signed-in (or guest) user can only
75
75
  // read, create, and modify their OWN rows. `field.owner()` stamps userId from
76
76
  // the session on insert, so "create your own" is enforced at write time and
77
- // reads are scoped by the same id — your chats never leak to anyone else, and
78
- // the sync engine only ever ships you yours.
77
+ // reads are scoped by the same id.
79
78
  const conversationPolicy = policy({
80
79
  name: "conversation_owner",
81
80
  entity: "Conversation",
@@ -113,6 +112,20 @@ const manifest = buildManifest({
113
112
  actions: [],
114
113
  policies: [conversationPolicy, messagePolicy, userPolicy],
115
114
  auth: auth(),
115
+ // Self-hosted Inter (next/font parity): the build fetches the woff2, serves it
116
+ // same-origin (no third-party request, no FOUT), preloads it, and synthesizes a
117
+ // size-adjusted fallback face so there's no layout shift. globals.css reads it
118
+ // via `var(--font-sans, …)`; layout.tsx carries no font <link>.
119
+ fonts: [
120
+ font({
121
+ family: "Inter",
122
+ variable: "--font-sans",
123
+ weights: ["400", "500", "600", "700"],
124
+ subsets: ["latin"],
125
+ display: "swap",
126
+ preload: true,
127
+ }),
128
+ ],
116
129
  routes: await discoverAppRoutes(),
117
130
  });
118
131
 
@@ -1,6 +1,6 @@
1
- // THE single source of truth for everything brand-specific on this AI chat app.
2
- // Rebrand the whole thing by editing this ONE file the layout + chat UI read
3
- // from here. The create-pylon scaffolder and Mast target this file.
1
+ // Single source of truth for everything brand-specific on this AI chat app
2
+ // edit this one file to rebrand; the layout + chat UI read from here. The
3
+ // create-pylon scaffolder and Mast target this file.
4
4
  //
5
5
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx).
6
6
  // Fictional demo copy — replace the values, keep the shape.
@@ -139,7 +139,14 @@
139
139
  body {
140
140
  background-color: var(--color-background);
141
141
  color: var(--color-foreground);
142
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
142
+ font-family: var(
143
+ --font-sans,
144
+ Inter,
145
+ ui-sans-serif,
146
+ system-ui,
147
+ -apple-system,
148
+ sans-serif
149
+ );
143
150
  -webkit-font-smoothing: antialiased;
144
151
  }
145
152
  button {
@@ -2,10 +2,10 @@ import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
3
  import { siteConfig } from "@/lib/site.config";
4
4
 
5
- // App shell: a slim top bar over a full-height chat. `auth.user_id` is resolved
5
+ // App shell: a slim top bar over the studio. `auth.user_id` is resolved
6
6
  // server-side from the session cookie before any HTML is sent, so the bar shows
7
- // the account / "Sign in" with no flash. The chat page fills the rest of the
8
- // viewport (h-[calc(100vh-3.5rem)] in chat-client.tsx — keep the header at h-14).
7
+ // the account / "Sign in" with no flash. The header is h-14; the studio page
8
+ // fills the rest of the viewport (min-h-[calc(100vh-3.5rem)]).
9
9
  interface LayoutProps {
10
10
  children: React.ReactNode;
11
11
  url: string;
@@ -13,8 +13,6 @@ interface LayoutProps {
13
13
  }
14
14
 
15
15
  export default function RootLayout({ children, url, auth }: LayoutProps) {
16
- // Resolved server-side from the session cookie. The app requires sign-in, so
17
- // this is set on every in-app page; the header reflects it with no flash.
18
16
  const signedIn = Boolean(auth?.user_id);
19
17
  const { brand, colors } = siteConfig;
20
18
 
@@ -36,12 +34,10 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
36
34
  <head>
37
35
  <meta charSet="utf-8" />
38
36
  <meta name="viewport" content="width=device-width, initial-scale=1" />
39
- <link rel="preconnect" href="https://fonts.googleapis.com" />
40
- <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
41
- <link
42
- rel="stylesheet"
43
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
44
- />
37
+ {/* Inter is declared in app.ts (fonts: [...]) and self-hosted by the
38
+ build — the runtime injects @font-face + <link rel=preload> + a
39
+ size-adjusted fallback here automatically. No third-party request,
40
+ no layout shift; change the family in app.ts. */}
45
41
  </head>
46
42
  <body className="bg-background text-foreground antialiased">
47
43
  {isBare ? (
@@ -5,6 +5,7 @@ import {
5
5
  auth,
6
6
  buildManifest,
7
7
  discoverAppRoutes,
8
+ font,
8
9
  } from "@pylonsync/sdk";
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -67,11 +68,9 @@ const User = entity(
67
68
  { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
68
69
  );
69
70
 
70
- // Generations are PRIVATE per user: you can READ only your own, and you can't
71
- // write them from the client at all the generate action (which runs the
72
- // provider call with the server-side key) is the only writer. So the gallery is
73
- // live (the sync engine ships you your rows as the action updates them) without
74
- // ever exposing one user's generations to another.
71
+ // Generations are PRIVATE per user: read-your-own, with no client writes at all
72
+ // (the server-side pipeline is the only writer). The gallery stays live via the
73
+ // sync engine without ever exposing one user's generations to another.
75
74
  const generationPolicy = policy({
76
75
  name: "generation_owner_read",
77
76
  entity: "Generation",
@@ -94,12 +93,26 @@ const manifest = buildManifest({
94
93
  name: "__APP_NAME__",
95
94
  version: "0.1.0",
96
95
  entities: [Generation, User],
97
- // generate (public action) + _createGeneration / _finishGeneration (internal
98
- // mutations it calls) live in functions/ and are discovered automatically.
96
+ // generate (mutation) + pollGeneration (job) + the internal _getGeneration /
97
+ // _updateGeneration live in functions/ and are discovered automatically.
99
98
  queries: [],
100
99
  actions: [],
101
100
  policies: [generationPolicy, userPolicy],
102
101
  auth: auth(),
102
+ // Self-hosted Inter (next/font parity): the build fetches the woff2, serves it
103
+ // same-origin (no third-party request, no FOUT), preloads it, and synthesizes a
104
+ // size-adjusted fallback face so there's no layout shift. globals.css reads it
105
+ // via `var(--font-sans, …)`; layout.tsx carries no font <link>.
106
+ fonts: [
107
+ font({
108
+ family: "Inter",
109
+ variable: "--font-sans",
110
+ weights: ["400", "500", "600", "700"],
111
+ subsets: ["latin"],
112
+ display: "swap",
113
+ preload: true,
114
+ }),
115
+ ],
103
116
  routes: await discoverAppRoutes(),
104
117
  });
105
118
 
@@ -28,8 +28,8 @@ export type StudioConfig = BaseConfig & {
28
28
  headline: string;
29
29
  subcopy: string;
30
30
  inputPlaceholder: string;
31
- // The generation kinds shown as a selector. `wired` flags which actually
32
- // call a provider (image + audio); video is a labeled extension point.
31
+ // The generation kinds shown as a selector. `wired` flags which call a
32
+ // provider (all three do via Replicate when REPLICATE_API_TOKEN is set).
33
33
  kinds: { id: GenerationKind; label: string; wired: boolean }[];
34
34
  // Starter prompts; clicking one fills the box.
35
35
  examples: string[];
@@ -8,7 +8,7 @@ export interface GenerationRow {
8
8
  userId: string;
9
9
  kind: string;
10
10
  prompt: string;
11
- status: string; // "pending" | "done" | "failed"
11
+ status: string; // "pending" | "processing" | "done" | "failed"
12
12
  resultUrl?: string | null;
13
13
  error?: string | null;
14
14
  demo: boolean;
@@ -27,12 +27,8 @@ import {
27
27
  const Org = entity("Org", {
28
28
  slug: field.string(),
29
29
  name: field.string(),
30
- // `.readonly()` blocks HTTP PATCH from rewriting ownership — closes
31
- // the IDOR-via-update-payload shape where an attacker would
32
- // `PATCH /api/entities/Org/<id>` with `{ownerId: <them>}` and the
33
- // policy's `existing.ownerId == auth.userId` would already be true
34
- // because they read the row. Server-side ctx.db.update still goes
35
- // through, so admin migrations + transfers work.
30
+ // `.readonly()` blocks HTTP PATCH from rewriting ownership. Server-side
31
+ // ctx.db.update still goes through, so admin migrations + transfers work.
36
32
  ownerId: field.id("User").readonly(),
37
33
  createdAt: field.datetime().readonly(),
38
34
  });
@@ -115,13 +111,9 @@ const orgPolicy = policy({
115
111
  name: "org_membership",
116
112
  entity: "Org",
117
113
  // Anyone can read an org if they're a member; only the owner can
118
- // update / delete. Update + delete pin `existing.ownerId` (the
119
- // current row's value) rather than `data.ownerId` (the proposed
120
- // payload) without this pin, an attacker could PATCH with
121
- // `{ownerId: <attacker>}` and the policy would happily compare
122
- // the payload value to their own userId. `ownerId` is also marked
123
- // `.readonly()` on the entity so updates never get to set it via
124
- // HTTP regardless — belt + suspenders.
114
+ // update / delete. Update + delete pin `existing.ownerId` (the row's
115
+ // current value) not `data.ownerId` (the payload), so an attacker
116
+ // can't PATCH `{ownerId: <them>}` past the check.
125
117
  allowRead: "exists(Membership where orgId = data.id and userId = auth.userId)",
126
118
  allowInsert: "auth.userId == data.ownerId",
127
119
  allowUpdate: "existing.ownerId == auth.userId",
@@ -133,11 +125,8 @@ const membershipPolicy = policy({
133
125
  entity: "Membership",
134
126
  // You can see your own memberships, plus all memberships in any org
135
127
  // where you're an owner/admin (so the admin UI can list everyone).
136
- // Update/delete pin `existing.orgId` so an attacker can't move a
137
- // membership to another org by PATCHing the payload. The entity
138
- // also marks `orgId` + `userId` as `.readonly()` so HTTP PATCH
139
- // rejects those fields outright — server actions like
140
- // `setMemberRole` write only `role`.
128
+ // Update/delete pin `existing.orgId` (not the payload); `orgId` +
129
+ // `userId` are `.readonly()` too, so `setMemberRole` writes only `role`.
141
130
  allowRead:
142
131
  "data.userId == auth.userId or exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
143
132
  allowInsert:
@@ -152,12 +141,9 @@ const projectPolicy = policy({
152
141
  name: "project_org_scope",
153
142
  entity: "Project",
154
143
  // Tenant scope: you can only touch a project if you're a member of
155
- // its org. `existing.orgId` on update/delete pins the row's
156
- // current org without it, an attacker could PATCH with
157
- // `{orgId: <my_org>}` to "import" a project from a foreign org
158
- // into one they own. `orgId` is also `.readonly()` on the entity,
159
- // so PATCH can't even set it. Insert uses `data.orgId` because
160
- // there is no `existing` row yet.
144
+ // its org. Update/delete pin `existing.orgId` (not the payload) so a
145
+ // project can't be "imported" into a foreign org; insert uses
146
+ // `data.orgId` since there's no `existing` row yet.
161
147
  allowRead:
162
148
  "exists(Membership where orgId = data.orgId and userId = auth.userId)",
163
149
  allowInsert:
@@ -82,10 +82,8 @@ const profilePolicy = policy({
82
82
  entity: "Profile",
83
83
  allowRead: "true",
84
84
  allowInsert: "auth.userId == data.userId",
85
- // Update + delete pin `existing.userId` so an attacker can't
86
- // PATCH `{userId: <attacker>}` to "claim" someone else's profile.
87
- // `userId` is also `.readonly()` on the entity so PATCH bounces
88
- // the field outright — belt + suspenders.
85
+ // Update + delete pin `existing.userId` (not the payload) so an
86
+ // attacker can't PATCH `{userId: <them>}` to claim another profile.
89
87
  allowUpdate: "auth.userId == existing.userId",
90
88
  allowDelete: "auth.userId == existing.userId",
91
89
  });
@@ -97,9 +95,8 @@ const postPolicy = policy({
97
95
  // Insert: caller must own a Profile they're claiming as author.
98
96
  allowInsert:
99
97
  "exists(Profile where id = data.authorId and userId = auth.userId)",
100
- // Update + delete pin `existing.authorId` so an attacker can't
101
- // rewrite the author in the PATCH payload to grant themselves
102
- // access. `authorId` is also `.readonly()` on the entity.
98
+ // Update + delete pin `existing.authorId` (not the payload) so an
99
+ // attacker can't rewrite the author to grant themselves access.
103
100
  allowUpdate:
104
101
  "exists(Profile where id = existing.authorId and userId = auth.userId)",
105
102
  allowDelete:
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
2
 
3
- // A layout wraps every page. This one is a header and a centered column
4
- // the page renders server-side first (shell + copy in the HTML), then
5
- // hydrates into the live list.
3
+ // A layout wraps every page. This one is a centered column the page renders
4
+ // server-side first (shell + copy in the HTML), then hydrates into the live
5
+ // list.
6
6
  interface LayoutProps {
7
7
  children: React.ReactNode;
8
8
  }
@@ -36,9 +36,8 @@ const itemPolicy = policy({
36
36
 
37
37
  // The manifest is your whole app: data, policies, and the file-based routes
38
38
  // under `app/`. `pylon dev` serves the SSR frontend and the API from one
39
- // port. Guest sessions (via `<EnsureGuest>` on the page) mean every visitor
40
- // implicitly becomes their own user — no login wall. Add fields to `Item`, or
41
- // a second `entity()`, and the typed client + REST/realtime API follow.
39
+ // port. Add fields to `Item`, or a second `entity()`, and the typed client +
40
+ // REST/realtime API follow.
42
41
  const manifest = buildManifest({
43
42
  name: "__APP_NAME__",
44
43
  version: "0.1.0",
@@ -9,9 +9,7 @@ import {
9
9
 
10
10
  // A chat message in the shared room. `authorId: field.owner()` stamps the
11
11
  // signed-in (guest) user's id server-side, so an optimistic
12
- // `db.insert("Message", { text })` can't forge the sender. The room is
13
- // public-read — everyone in it sees every message (that's what makes it a
14
- // chat room) — while delete is owner-only.
12
+ // `db.insert("Message", { text })` can't forge the sender.
15
13
  const Message = entity(
16
14
  "Message",
17
15
  {
@@ -37,12 +35,8 @@ const messagePolicy = policy({
37
35
  allowDelete: "auth.userId == data.authorId",
38
36
  });
39
37
 
40
- // `pylon dev` serves the SSR room and the realtime API from one port. Guest
41
- // sessions (via `<EnsureGuest>` on the page) let anyone chat with no login
42
- // `db.useQuery("Message")` is a live subscription, so messages appear the
43
- // instant they're sent, in this tab or another. Natural next steps: a `Room`
44
- // entity (+ `roomId` on Message) for multiple rooms, and a presence channel
45
- // (`ctx.connections.*`) for a "who's here" list.
38
+ // buildManifest assembles the entities, policies, auth, and routes into the
39
+ // single manifest `pylon dev` serves SSR room + realtime API on one port.
46
40
  const manifest = buildManifest({
47
41
  name: "__APP_NAME__",
48
42
  version: "0.1.0",
@@ -43,9 +43,8 @@ const Like = entity(
43
43
  },
44
44
  );
45
45
 
46
- // Posts + likes are public-read so the feed and its counts render for
47
- // everyone; writes are gated to the owner. An entity with no policy is denied
48
- // to clients by default, so these allow-lists are what make the feed work.
46
+ // An entity with no policy is denied to clients by default, so these
47
+ // allow-lists are what make the feed work.
49
48
  // `allowInsert` is `auth.userId != null`, not `== data.authorId`: the owner
50
49
  // field is stamped by field.owner() *after* the policy check, so it's null at
51
50
  // insert-time. The stamp still guarantees the new row is owned by the caller,
@@ -1,10 +1,10 @@
1
1
  # Copy to `.env` and fill in. `pylon dev` loads `.env` automatically.
2
2
 
3
3
  # ── Owner (required to use the dashboard) ────────────────────────────────────
4
- # A waitlist is single-tenant: one business, one owner. The /dashboard is
4
+ # A newsletter is single-tenant: one business, one owner. The /dashboard is
5
5
  # unlocked only for the account whose email matches this value, and the
6
- # owner-only data function refuses to return any signups otherwise. Set this to
7
- # the email you'll sign in with, then create that account at /login.
6
+ # owner-only data function refuses to return any subscribers otherwise. Set this
7
+ # to the email you'll sign in with, then create that account at /login.
8
8
  PYLON_OWNER_EMAIL=you@yourbusiness.com
9
9
 
10
10
  # ── Site URL (optional) ──────────────────────────────────────────────────────
@@ -139,7 +139,14 @@
139
139
  body {
140
140
  background-color: var(--color-background);
141
141
  color: var(--color-foreground);
142
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
142
+ font-family: var(
143
+ --font-sans,
144
+ Inter,
145
+ ui-sans-serif,
146
+ system-ui,
147
+ -apple-system,
148
+ sans-serif
149
+ );
143
150
  -webkit-font-smoothing: antialiased;
144
151
  }
145
152
  button {
@@ -45,12 +45,10 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
45
45
  <meta charSet="utf-8" />
46
46
  <meta name="viewport" content="width=device-width, initial-scale=1" />
47
47
  {/* No <title> here — each page's exported `metadata` sets it. */}
48
- <link rel="preconnect" href="https://fonts.googleapis.com" />
49
- <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
50
- <link
51
- rel="stylesheet"
52
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
53
- />
48
+ {/* Inter is declared in app.ts (fonts: [...]) and self-hosted by the
49
+ build — the runtime injects @font-face + <link rel=preload> + a
50
+ size-adjusted fallback here automatically. No third-party request,
51
+ no layout shift; change the family in app.ts. */}
54
52
  {/* Tailwind is compiled by Pylon from app/globals.css and injected here. */}
55
53
  </head>
56
54
  <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
@@ -5,13 +5,13 @@ import {
5
5
  auth,
6
6
  buildManifest,
7
7
  discoverAppRoutes,
8
+ font,
8
9
  } from "@pylonsync/sdk";
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // newsletter — a pre-launch / coming-soon landing page with a LIVE subscriber
12
- // counter. The whole point is the realtime hook: open the page in two tabs,
13
- // submit an email in one, and the counter on the other ticks up with no
14
- // refresh. That's the proof it's a real live app and not a static page.
13
+ // counter. The realtime hook: open the page in two tabs, submit an email in
14
+ // one, and the counter on the other ticks up with no refresh.
15
15
  //
16
16
  // The data model is deliberately tiny — two entities:
17
17
  // • Subscriber — one row per email. Holds visitor PII, so it denies ALL client
@@ -74,13 +74,12 @@ const User = entity(
74
74
  { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
75
75
  );
76
76
 
77
- // PRIVACY — the heart of the spec. Subscriber holds visitor emails, so it denies
78
- // EVERY client read and write. No `db.useQuery("Subscriber")` can ever pull a row,
79
- // and no client can insert/update/delete directly. Writes happen only inside
80
- // the server-side `subscribe` mutation (functions bypass policies); reads
81
- // happen only inside `newsletterCount` (returns a bare integer) and the
82
- // owner-gated `subscriberStats`. A marketing site must never leak its own
83
- // customers' emails — this policy is what guarantees it.
77
+ // PRIVACY. Subscriber holds visitor emails, so it denies EVERY client read and
78
+ // write. No `db.useQuery("Subscriber")` can ever pull a row, and no client can
79
+ // insert/update/delete directly. Writes happen only inside the server-side
80
+ // `subscribe` mutation (functions bypass policies); the emails come back only
81
+ // through the owner-gated `subscriberStats`. A marketing site must never leak
82
+ // its own customers' emails this policy is what guarantees it.
84
83
  const subscriberPolicy = policy({
85
84
  name: "subscriber_private",
86
85
  entity: "Subscriber",
@@ -125,6 +124,20 @@ const manifest = buildManifest({
125
124
  // Email/password is on by default against the User entity above. No orgs,
126
125
  // no billing — a newsletter is single-tenant (one business, one owner).
127
126
  auth: auth(),
127
+ // Self-hosted Inter (next/font parity): the build fetches the woff2, serves it
128
+ // same-origin (no third-party request, no FOUT), preloads it, and synthesizes a
129
+ // size-adjusted fallback face so there's no layout shift. globals.css reads it
130
+ // via `var(--font-sans, …)`; layout.tsx carries no font <link>.
131
+ fonts: [
132
+ font({
133
+ family: "Inter",
134
+ variable: "--font-sans",
135
+ weights: ["400", "500", "600", "700"],
136
+ subsets: ["latin"],
137
+ display: "swap",
138
+ preload: true,
139
+ }),
140
+ ],
128
141
  routes: await discoverAppRoutes(),
129
142
  });
130
143
 
@@ -95,10 +95,8 @@ export function initials(name: string) {
95
95
  .toUpperCase();
96
96
  }
97
97
 
98
- // A deliberately-obvious image placeholder. Real sites drop a photo here; this
99
- // makes the spot unmistakable dashed border, a photo glyph, and a one-line
100
- // "swap this" instruction telling you exactly what to replace and where. Looks
101
- // tidy enough to demo, but no one will mistake it for a finished design.
98
+ // A deliberately-obvious image placeholder dashed border, photo glyph, and a
99
+ // "swap this" hint. Real sites drop a photo here.
102
100
  //
103
101
  // shape — "landscape" | "portrait" | "square" | "circle"
104
102
  // title — what photo belongs here ("Your headshot")