@pylonsync/create-pylon 0.3.296 → 0.3.298

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 (49) hide show
  1. package/package.json +1 -1
  2. package/templates/agency/components/marketing.tsx +2 -4
  3. package/templates/agency/lib/site.config.ts +3 -5
  4. package/templates/ai-chat/app/layout.tsx +0 -2
  5. package/templates/ai-chat/app.ts +3 -5
  6. package/templates/ai-chat/lib/site.config.ts +3 -3
  7. package/templates/ai-studio/app/layout.tsx +3 -5
  8. package/templates/ai-studio/app.ts +5 -7
  9. package/templates/ai-studio/lib/site.config.ts +2 -2
  10. package/templates/ai-studio/lib/studio.ts +1 -1
  11. package/templates/backend/b2b/apps/api/schema.ts +10 -24
  12. package/templates/backend/consumer/apps/api/schema.ts +4 -7
  13. package/templates/barebones/app/layout.tsx +3 -3
  14. package/templates/barebones/app.ts +2 -3
  15. package/templates/chat/app.ts +3 -9
  16. package/templates/consumer/app.ts +2 -3
  17. package/templates/creator/.env.example +3 -3
  18. package/templates/creator/app.ts +8 -10
  19. package/templates/creator/components/marketing.tsx +2 -4
  20. package/templates/default/lib/products.ts +2 -3
  21. package/templates/default/lib/site.config.ts +1 -2
  22. package/templates/default/lib/site.ts +3 -4
  23. package/templates/directory/app/auth-form.tsx +5 -5
  24. package/templates/directory/app/sitemap.ts +1 -1
  25. package/templates/directory/lib/owner.ts +5 -5
  26. package/templates/expo/chat/apps/expo/App.tsx +2 -3
  27. package/templates/local-service/app/auth-form.tsx +4 -4
  28. package/templates/local-service/app/sitemap.ts +1 -1
  29. package/templates/local-service/components/marketing.tsx +2 -4
  30. package/templates/local-service/lib/owner.ts +3 -3
  31. package/templates/local-service/lib/site.config.ts +4 -6
  32. package/templates/marketplace/app/listing/[id]/page.tsx +3 -3
  33. package/templates/marketplace/functions/makeOffer.ts +0 -1
  34. package/templates/restaurant/app/auth-form.tsx +5 -5
  35. package/templates/restaurant/app/sitemap.ts +1 -1
  36. package/templates/restaurant/lib/owner.ts +5 -5
  37. package/templates/shop/app/auth-form.tsx +5 -5
  38. package/templates/shop/app/layout.tsx +1 -1
  39. package/templates/shop/app/sitemap.ts +1 -1
  40. package/templates/shop/lib/owner.ts +6 -5
  41. package/templates/todo/app/layout.tsx +2 -2
  42. package/templates/todo/app/sitemap.ts +1 -1
  43. package/templates/todo/app.ts +4 -5
  44. package/templates/vite/todo/apps/web/vite.config.ts +5 -9
  45. package/templates/waitlist/app/waitlist-hero.tsx +4 -5
  46. package/templates/waitlist/app.ts +11 -9
  47. package/templates/waitlist/lib/site.config.ts +3 -5
  48. package/templates/web/barebones/apps/web/postcss.config.mjs +0 -1
  49. 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.296",
3
+ "version": "0.3.298",
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"
@@ -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
@@ -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
 
@@ -11,9 +11,8 @@ import {
11
11
  // ---------------------------------------------------------------------------
12
12
  // ai-chat — a streaming AI chat app. Tokens stream from the built-in
13
13
  // `POST /api/ai/stream` endpoint (your PYLON_AI_API_KEY never leaves the
14
- // server); the conversation itself is sync-backed, so your chats follow you
15
- // across tabs and devices in realtime — open two tabs and a reply you send in
16
- // 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.
17
16
  //
18
17
  // Two data entities (+ User):
19
18
  // • Conversation — a chat thread. Owner-scoped: you only ever see your own.
@@ -75,8 +74,7 @@ const User = entity(
75
74
  // Conversations + messages are PRIVATE: a signed-in (or guest) user can only
76
75
  // read, create, and modify their OWN rows. `field.owner()` stamps userId from
77
76
  // the session on insert, so "create your own" is enforced at write time and
78
- // reads are scoped by the same id — your chats never leak to anyone else, and
79
- // the sync engine only ever ships you yours.
77
+ // reads are scoped by the same id.
80
78
  const conversationPolicy = policy({
81
79
  name: "conversation_owner",
82
80
  entity: "Conversation",
@@ -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.
@@ -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
 
@@ -68,11 +68,9 @@ const User = entity(
68
68
  { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
69
69
  );
70
70
 
71
- // Generations are PRIVATE per user: you can READ only your own, and you can't
72
- // write them from the client at all the generate action (which runs the
73
- // provider call with the server-side key) is the only writer. So the gallery is
74
- // live (the sync engine ships you your rows as the action updates them) without
75
- // 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.
76
74
  const generationPolicy = policy({
77
75
  name: "generation_owner_read",
78
76
  entity: "Generation",
@@ -95,8 +93,8 @@ const manifest = buildManifest({
95
93
  name: "__APP_NAME__",
96
94
  version: "0.1.0",
97
95
  entities: [Generation, User],
98
- // generate (public action) + _createGeneration / _finishGeneration (internal
99
- // 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.
100
98
  queries: [],
101
99
  actions: [],
102
100
  policies: [generationPolicy, userPolicy],
@@ -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) ──────────────────────────────────────────────────────
@@ -10,9 +10,8 @@ import {
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // newsletter — a pre-launch / coming-soon landing page with a LIVE subscriber
13
- // counter. The whole point is the realtime hook: open the page in two tabs,
14
- // submit an email in one, and the counter on the other ticks up with no
15
- // 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.
16
15
  //
17
16
  // The data model is deliberately tiny — two entities:
18
17
  // • Subscriber — one row per email. Holds visitor PII, so it denies ALL client
@@ -75,13 +74,12 @@ const User = entity(
75
74
  { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
76
75
  );
77
76
 
78
- // PRIVACY — the heart of the spec. Subscriber holds visitor emails, so it denies
79
- // EVERY client read and write. No `db.useQuery("Subscriber")` can ever pull a row,
80
- // and no client can insert/update/delete directly. Writes happen only inside
81
- // the server-side `subscribe` mutation (functions bypass policies); reads
82
- // happen only inside `newsletterCount` (returns a bare integer) and the
83
- // owner-gated `subscriberStats`. A marketing site must never leak its own
84
- // 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.
85
83
  const subscriberPolicy = policy({
86
84
  name: "subscriber_private",
87
85
  entity: "Subscriber",
@@ -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")
@@ -1,6 +1,5 @@
1
- // The marketing "products" now live in the single site config so the whole
2
- // template can be rebranded from one file. This module re-exports them so
3
- // existing imports (`@/lib/products`) keep working. Edit lib/site.config.ts.
1
+ // Re-exports the marketing products from the single site config so existing
2
+ // `@/lib/products` imports keep working. Edit lib/site.config.ts.
4
3
  export {
5
4
  PRODUCTS,
6
5
  productBySlug,
@@ -1,8 +1,7 @@
1
1
  // THE single source of truth for everything business-specific in this template.
2
2
  // Rebrand the entire site by editing this ONE file — the marketing components
3
3
  // (hero, pricing, FAQ, footer, nav) all read from here and stay generic. The
4
- // `create-pylon` scaffolder and automated generators target this file too, so a
5
- // whole site can be themed by producing one typed object.
4
+ // `create-pylon` scaffolder and automated generators target this file too.
6
5
  //
7
6
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx), so
8
7
  // you don't touch globals.css to re-theme the marketing pages.
@@ -1,7 +1,6 @@
1
- // The marketing content (solutions, resources, company, comparisons) now lives
2
- // in the single site config so the whole template can be rebranded from one
3
- // file. This module re-exports it so existing imports (`@/lib/site`) keep
4
- // working. Edit lib/site.config.ts.
1
+ // Re-exports the marketing content (solutions, resources, company, comparisons)
2
+ // from the single site config so existing `@/lib/site` imports keep working.
3
+ // Edit lib/site.config.ts.
5
4
  export {
6
5
  SOLUTIONS,
7
6
  RESOURCES,
@@ -13,15 +13,15 @@ import {
13
13
  // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
14
  // token to local storage so the sync engine + `callFn` authenticate AS THE
15
15
  // OWNER on the next load. This step matters here specifically: the landing page
16
- // mints an anonymous guest session (for the live slot counter), and without
16
+ // mints an anonymous guest session (for the live upvotes), and without
17
17
  // persisting the real session that stale guest token would shadow the owner's
18
- // — so the owner-only `inquiriesForOwner` call would come back as a guest and
18
+ // — so the owner-only `submissionsForOwner` call would come back as a guest and
19
19
  // get rejected. We then do a full navigation to /dashboard so the SSR runtime
20
20
  // re-resolves auth from the HttpOnly cookie and renders server-side.
21
21
  //
22
- // A studio is single-tenant: there's no public sign-up, just the owner creating
23
- // their one account. Whoever signs in only sees data if their email matches
24
- // PYLON_OWNER_EMAIL — enforced by the inquiriesForOwner function.
22
+ // A directory is single-tenant: there's no public sign-up, just the owner
23
+ // creating their one account. Whoever signs in only sees data if their email
24
+ // matches PYLON_OWNER_EMAIL — enforced by the submissionsForOwner function.
25
25
  export function AuthForm() {
26
26
  const [mode, setMode] = useState<"login" | "register">("login");
27
27
  const [email, setEmail] = useState("");
@@ -1,7 +1,7 @@
1
1
  import type { Sitemap } from "@pylonsync/react";
2
2
 
3
3
  // app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
4
- // production. The studio site is a single public page, so the sitemap is just "/".
4
+ // production. This lists the public homepage; add more URLs as the site grows.
5
5
  const SITE = process.env.SITE_URL ?? "http://localhost:4321";
6
6
 
7
7
  export default async function sitemap(): Promise<Sitemap> {
@@ -1,11 +1,11 @@
1
- // Who owns this studio site? A studio is single-tenant — one business, one
2
- // owner — so ownership is just "the email the owner signs in with", configured
3
- // once via the PYLON_OWNER_EMAIL env var. The owner-only functions
4
- // (inquiriesForOwner etc.) read that env (via `ctx.env`) and compare it here.
1
+ // Who owns this directory? A directory is single-tenant — one curator — so
2
+ // ownership is just "the email the owner signs in with", configured once via
3
+ // the PYLON_OWNER_EMAIL env var. The owner-only functions (submissionsForOwner
4
+ // etc.) read that env (via `ctx.env`) and compare it here.
5
5
  //
6
6
  // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
7
7
  // dashboard stays locked. That's deliberate — an unset owner on a public site
8
- // must not mean "everyone can read the inquiries". Set it in .env (see
8
+ // must not mean "everyone can read the submissions". Set it in .env (see
9
9
  // .env.example) before signing in.
10
10
 
11
11
  export function normalizeOwner(raw: string | null | undefined): string | null {
@@ -62,9 +62,8 @@ export default function App() {
62
62
  }
63
63
 
64
64
  function Chat() {
65
- // Live subscriptions — sync engine pushes diffs over WebSocket.
66
- // New rooms / new messages from any other device or device update
67
- // re-render this component without polling.
65
+ // Live subscriptions — the sync engine pushes diffs over WebSocket, so new
66
+ // rooms/messages from any device re-render this component without polling.
68
67
  const { data: rooms = [] } = db.useQuery<Room>("Room", {
69
68
  orderBy: { createdAt: "asc" },
70
69
  });
@@ -13,15 +13,15 @@ import {
13
13
  // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
14
  // token to local storage so the sync engine + `callFn` authenticate AS THE
15
15
  // OWNER on the next load. This step matters here specifically: the landing page
16
- // mints an anonymous guest session (for the live counter), and without
16
+ // mints an anonymous guest session (for the live booking picker), and without
17
17
  // persisting the real session that stale guest token would shadow the owner's
18
- // — so the owner-only `waitlistStats` call would come back as a guest and get
18
+ // — so the owner-only `bookingsForOwner` call would come back as a guest and get
19
19
  // rejected. We then do a full navigation to /dashboard so the SSR runtime
20
20
  // re-resolves auth from the HttpOnly cookie and renders server-side.
21
21
  //
22
- // A waitlist is single-tenant: there's no public signup funnel, just the owner
22
+ // A booking site is single-tenant: there's no public signup funnel, just the owner
23
23
  // creating their one account. Whoever signs in only sees data if their email
24
- // matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
24
+ // matches PYLON_OWNER_EMAIL — enforced by the bookingsForOwner function.
25
25
  export function AuthForm() {
26
26
  const [mode, setMode] = useState<"login" | "signup">("login");
27
27
  const [email, setEmail] = useState("");
@@ -1,7 +1,7 @@
1
1
  import type { Sitemap } from "@pylonsync/react";
2
2
 
3
3
  // app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
4
- // production. The waitlist is a single public page, so the sitemap is just "/".
4
+ // production. This site is a single public page, so the sitemap is just "/".
5
5
  const SITE = process.env.SITE_URL ?? "http://localhost:4321";
6
6
 
7
7
  export default async function sitemap(): Promise<Sitemap> {
@@ -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, a photo glyph, and
99
+ // a one-line "swap this" instruction. Real sites drop a photo here.
102
100
  //
103
101
  // shape — "landscape" | "portrait" | "square" | "circle"
104
102
  // title — what photo belongs here ("A photo of your shop")
@@ -1,11 +1,11 @@
1
- // Who owns this waitlist? A waitlist is single-tenant — one business, one
1
+ // Who owns this site? A booking site is single-tenant — one business, one
2
2
  // owner — so ownership is just "the email the owner signs in with", configured
3
- // once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
3
+ // once via the PYLON_OWNER_EMAIL env var. The owner-only `bookingsForOwner`
4
4
  // function reads that env (via `ctx.env`) and compares it here.
5
5
  //
6
6
  // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
7
7
  // dashboard stays locked. That's deliberate — an unset owner on a public site
8
- // must not mean "everyone can read the signups". Set it in .env (see
8
+ // must not mean "everyone can read the bookings". Set it in .env (see
9
9
  // .env.example) before signing in.
10
10
 
11
11
  export function normalizeOwner(raw: string | null | undefined): string | null {
@@ -1,9 +1,7 @@
1
- // THE single source of truth for everything business-specific. Rebrand the
2
- // whole site and reconfigure the booking engine by editing this ONE file.
3
- // The landing page, layout, AND the createBooking server function all read from
4
- // here, so services, prices, weekly hours, and lead time stay in lockstep. The
5
- // create-pylon scaffolder and Mast target this file: a whole appointment site
6
- // is themed + configured by producing one typed object.
1
+ // THE single source of truth for everything business-specific. The landing
2
+ // page, layout, AND the createBooking server function all read from here, so
3
+ // services, prices, weekly hours, and lead time stay in lockstep rebrand and
4
+ // reconfigure the whole site by editing this ONE file.
7
5
  //
8
6
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx).
9
7
  //
@@ -18,9 +18,6 @@ import {
18
18
  type Listing,
19
19
  } from "../../../client/market";
20
20
 
21
- // Data-driven SEO: the title + description come from the listing itself,
22
- // fetched on the server. `generateMetadata` is handed the same PageProps as
23
- // the page (params + serverData), so it reads the row directly.
24
21
  // Resolve a listing from the URL segment, which is its slug
25
22
  // ("herman-miller-aeron-a1f3"). Falls back to a raw id lookup so older
26
23
  // id-shaped links keep working.
@@ -34,6 +31,9 @@ async function resolveListing(
34
31
  );
35
32
  }
36
33
 
34
+ // Data-driven SEO: the title + description come from the listing itself,
35
+ // fetched on the server. `generateMetadata` is handed the same PageProps as
36
+ // the page (params + serverData), so it reads the row directly.
37
37
  export const generateMetadata: GenerateMetadata = async ({
38
38
  params,
39
39
  serverData,
@@ -48,7 +48,6 @@ export default mutation<MakeOfferArgs, MakeOfferResult>({
48
48
  throw ctx.error("INVALID_ARGS", "offer must be greater than zero");
49
49
 
50
50
  const id = await ctx.db.insert("Offer", {
51
- // Reuse the optimistic ghost's id so the broadcast merges in place.
52
51
  id: args._optimisticId,
53
52
  listingId: args.listingId,
54
53
  listingTitle: listing.title,
@@ -13,15 +13,15 @@ import {
13
13
  // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
14
  // token to local storage so the sync engine + `callFn` authenticate AS THE
15
15
  // OWNER on the next load. This step matters here specifically: the landing page
16
- // mints an anonymous guest session (for the live counter), and without
16
+ // mints an anonymous guest session (for the live table picker), and without
17
17
  // persisting the real session that stale guest token would shadow the owner's
18
- // — so the owner-only `waitlistStats` call would come back as a guest and get
19
- // rejected. We then do a full navigation to /dashboard so the SSR runtime
18
+ // — so the owner-only `reservationsForOwner` call would come back as a guest and
19
+ // get rejected. We then do a full navigation to /dashboard so the SSR runtime
20
20
  // re-resolves auth from the HttpOnly cookie and renders server-side.
21
21
  //
22
- // A waitlist is single-tenant: there's no public signup funnel, just the owner
22
+ // This site is single-tenant: there's no public signup funnel, just the owner
23
23
  // creating their one account. Whoever signs in only sees data if their email
24
- // matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
24
+ // matches PYLON_OWNER_EMAIL — enforced by the reservationsForOwner function.
25
25
  export function AuthForm() {
26
26
  const [mode, setMode] = useState<"login" | "signup">("login");
27
27
  const [email, setEmail] = useState("");
@@ -1,7 +1,7 @@
1
1
  import type { Sitemap } from "@pylonsync/react";
2
2
 
3
3
  // app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
4
- // production. The waitlist is a single public page, so the sitemap is just "/".
4
+ // production. The site is a single public page, so the sitemap is just "/".
5
5
  const SITE = process.env.SITE_URL ?? "http://localhost:4321";
6
6
 
7
7
  export default async function sitemap(): Promise<Sitemap> {
@@ -1,11 +1,11 @@
1
- // Who owns this waitlist? A waitlist is single-tenant — one business, one
2
- // owner — so ownership is just "the email the owner signs in with", configured
3
- // once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
4
- // function reads that env (via `ctx.env`) and compares it here.
1
+ // Who owns this restaurant site? It's single-tenant — one venue, one owner —
2
+ // so ownership is just "the email the owner signs in with", configured once via
3
+ // the PYLON_OWNER_EMAIL env var. The owner-only `reservationsForOwner` function
4
+ // reads that env (via `ctx.env`) and compares it here.
5
5
  //
6
6
  // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
7
7
  // dashboard stays locked. That's deliberate — an unset owner on a public site
8
- // must not mean "everyone can read the signups". Set it in .env (see
8
+ // must not mean "everyone can read the reservations". Set it in .env (see
9
9
  // .env.example) before signing in.
10
10
 
11
11
  export function normalizeOwner(raw: string | null | undefined): string | null {
@@ -12,16 +12,16 @@ import {
12
12
  // auth API directly (`passwordLogin` / `passwordRegister` POST to
13
13
  // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
14
  // token to local storage so the sync engine + `callFn` authenticate AS THE
15
- // OWNER on the next load. This step matters here specifically: the landing page
16
- // mints an anonymous guest session (for the live counter), and without
15
+ // OWNER on the next load. This step matters here specifically: the storefront
16
+ // mints an anonymous guest session (for the live stock grid), and without
17
17
  // persisting the real session that stale guest token would shadow the owner's
18
- // — so the owner-only `waitlistStats` call would come back as a guest and get
18
+ // — so the owner-only `ordersForOwner` call would come back as a guest and get
19
19
  // rejected. We then do a full navigation to /dashboard so the SSR runtime
20
20
  // re-resolves auth from the HttpOnly cookie and renders server-side.
21
21
  //
22
- // A waitlist is single-tenant: there's no public signup funnel, just the owner
22
+ // A shop is single-tenant: there's no public signup funnel, just the owner
23
23
  // creating their one account. Whoever signs in only sees data if their email
24
- // matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
24
+ // matches PYLON_OWNER_EMAIL — enforced by the ordersForOwner function.
25
25
  export function AuthForm() {
26
26
  const [mode, setMode] = useState<"login" | "signup">("login");
27
27
  const [email, setEmail] = useState("");
@@ -15,7 +15,7 @@ interface LayoutProps {
15
15
  }
16
16
 
17
17
  export default function RootLayout({ children, url, auth }: LayoutProps) {
18
- // A guest session (minted by <EnsureGuest> for the live counter) has a
18
+ // A guest session (minted by <EnsureGuest> for the live stock grid) has a
19
19
  // `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
20
20
  // so it shouldn't flip the nav to "Dashboard".
21
21
  const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
@@ -1,7 +1,7 @@
1
1
  import type { Sitemap } from "@pylonsync/react";
2
2
 
3
3
  // app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
4
- // production. The waitlist is a single public page, so the sitemap is just "/".
4
+ // production. The storefront is a single public page, so the sitemap is just "/".
5
5
  const SITE = process.env.SITE_URL ?? "http://localhost:4321";
6
6
 
7
7
  export default async function sitemap(): Promise<Sitemap> {
@@ -1,11 +1,12 @@
1
- // Who owns this waitlist? A waitlist is single-tenant — one business, one
2
- // owner — so ownership is just "the email the owner signs in with", configured
3
- // once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
4
- // function reads that env (via `ctx.env`) and compares it here.
1
+ // Who owns this shop? A shop is single-tenant — one business, one owner — so
2
+ // ownership is just "the email the owner signs in with", configured once via the
3
+ // PYLON_OWNER_EMAIL env var. The owner-only functions (ordersForOwner,
4
+ // fulfillOrder, cancelOrder, restockProduct) read that env (via `ctx.env`) and
5
+ // compare it here.
5
6
  //
6
7
  // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
7
8
  // dashboard stays locked. That's deliberate — an unset owner on a public site
8
- // must not mean "everyone can read the signups". Set it in .env (see
9
+ // must not mean "everyone can read the orders". Set it in .env (see
9
10
  // .env.example) before signing in.
10
11
 
11
12
  export function normalizeOwner(raw: string | null | undefined): string | null {
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
 
3
- // A layout wraps every page. This one is intentionally minimal — a header
4
- // and a centered column. The page below it is server-rendered first (so the
3
+ // A layout wraps every page. This one is intentionally minimal — just a
4
+ // centered column. The page below it is server-rendered first (so the
5
5
  // shell and copy are in the HTML), then hydrates into the live todo UI.
6
6
  interface LayoutProps {
7
7
  children: React.ReactNode;
@@ -14,7 +14,7 @@ export default async function sitemap(): Promise<Sitemap> {
14
14
  { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
15
15
  ];
16
16
 
17
- // The export is async, so you can enumerate dynamic pages from a DB read:
17
+ // Example enumerate dynamic pages from a DB read:
18
18
  //
19
19
  // const posts = await fetchPublishedPosts();
20
20
  // const postRoutes: Sitemap = posts.map((p) => ({
@@ -7,11 +7,10 @@ import {
7
7
  discoverAppRoutes,
8
8
  } from "@pylonsync/sdk";
9
9
 
10
- // A todo that belongs to one person. `userId: field.owner()` is the key
11
- // move: the framework stamps the signed-in (here: guest) user's id
12
- // server-side on insert and rejects any forged value — so the UI can do a
13
- // plain, optimistic `db.insert("Todo", { title })` (the row shows instantly,
14
- // no round-trip) while ownership stays unspoofable. No createTodo function to
10
+ // A todo that belongs to one person. `userId: field.owner()` stamps the
11
+ // signed-in (here: guest) user's id server-side on insert and rejects any
12
+ // forged value — so the UI can do a plain, optimistic `db.insert("Todo",
13
+ // { title })` and never send (or spoof) userId. No createTodo function to
15
14
  // write — every verb is a direct, policy-checked entity call.
16
15
  const Todo = entity(
17
16
  "Todo",
@@ -4,18 +4,14 @@ import tailwindcss from "@tailwindcss/vite";
4
4
 
5
5
  // Pylon dev exposes TWO ports:
6
6
  // :4321 → HTTP (functions, entity CRUD, /api/sync/pull, /api/auth/*)
7
- // :4322 → dedicated WebSocket listener with TCP read timeouts set
8
- // on the raw socket. Broadcasts flow immediately because
9
- // the reader thread's mutex is released every 200ms by the
10
- // kernel-level read timeout — no client keepalive ping
11
- // needed to break the wedge.
7
+ // :4322 → dedicated WebSocket listener that pushes broadcasts with
8
+ // lower latency than the HTTP-multiplexed path.
12
9
  //
13
10
  // We route the WS upgrade to :4322 and everything else to :4321. The
14
- // HTTP-multiplexed `/api/sync/ws` on :4321 also works (and is the
11
+ // HTTP-multiplexed `/api/sync/ws` on :4321 also works and is the
15
12
  // production fallback for proxies that can't forward to a secondary
16
- // port), but it can't set stream-level timeouts because tiny_http's
17
- // `CustomStream` hides the underlying TcpStream — so broadcasts there
18
- // are latency-bounded by the client SDK's 200ms keepalive ping.
13
+ // port, but its broadcasts are latency-bounded by the client SDK's
14
+ // 200ms keepalive ping.
19
15
  const PYLON_HTTP_TARGET = process.env.PYLON_TARGET ?? "http://localhost:4321";
20
16
  const PYLON_WS_TARGET = process.env.PYLON_WS_TARGET ?? "ws://localhost:4322";
21
17
 
@@ -6,11 +6,10 @@ import { EnsureGuest } from "@pylonsync/client";
6
6
  import type { WaitlistConfig } from "@/lib/site.config";
7
7
 
8
8
  // The interactive top of the landing page: the email-capture form and the LIVE
9
- // signup counter. This is the realtime proof the counter is a live
10
- // `db.useQuery("WaitlistStat")` over the public, PII-free aggregate row, so the
11
- // moment anyone (this tab or another) submits an email, joinWaitlist updates
12
- // that row and the new count syncs to every open tab through the replica. No
13
- // refresh, no polling.
9
+ // signup counter. The counter is a live `db.useQuery("WaitlistStat")` over the
10
+ // public, PII-free aggregate row, so the moment anyone (this tab or another)
11
+ // submits an email, joinWaitlist updates that row and the new count syncs to
12
+ // every open tab through the replica. No refresh, no polling.
14
13
  //
15
14
  // The signup form (joinWaitlist) is a public mutation, so it works for any
16
15
  // anonymous visitor. The counter needs a live sync connection, so it's wrapped
@@ -10,16 +10,18 @@ import {
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // waitlist — a pre-launch / coming-soon landing page with a LIVE signup
13
- // counter. The whole point is the realtime hook: open the page in two tabs,
14
- // submit an email in one, and the counter on the other ticks up with no
15
- // 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.
16
15
  //
17
- // The data model is deliberately tiny — two entities:
18
- // • Signup — one row per email. Holds visitor PII, so it denies ALL client
19
- // reads/writes (writes go through the joinWaitlist mutation; the
20
- // public page only ever sees an aggregate count, never an email).
21
- // User — the business owner's account (email/password is built in), so
22
- // the owner can sign in to the dashboard and see their signups.
16
+ // The data model is deliberately tiny — three entities:
17
+ // • Signup — one row per email. Holds visitor PII, so it denies ALL
18
+ // client reads/writes (writes go through the joinWaitlist
19
+ // mutation; the public page only ever sees an aggregate
20
+ // count, never an email).
21
+ // WaitlistStat a single-row, PII-free aggregate (just the count) the
22
+ // public page reads live for the counter.
23
+ // • User — the business owner's account (email/password is built in),
24
+ // so the owner can sign in to the dashboard and see signups.
23
25
  // ---------------------------------------------------------------------------
24
26
 
25
27
  // One waitlist signup. `email` is the only PII; `createdAt` powers the
@@ -1,11 +1,9 @@
1
1
  // THE single source of truth for everything business-specific on this waitlist.
2
2
  // Rebrand the whole page by editing this ONE file — the landing page and layout
3
- // read from here and stay generic. The `create-pylon` scaffolder and automated
4
- // generators (Mast) target this file too, so a whole site can be themed by
5
- // producing one typed object.
3
+ // read from here and stay generic.
6
4
  //
7
- // Colors live here (applied as CSS variables on <html> in app/layout.tsx), so
8
- // you don't touch globals.css to re-theme.
5
+ // Colors live here too (applied as CSS variables on <html> in app/layout.tsx),
6
+ // so you don't touch globals.css to re-theme.
9
7
  //
10
8
  // Fictional demo copy — replace the values, keep the shape.
11
9
 
@@ -1,4 +1,3 @@
1
- /** Tailwind v4 PostCSS pipeline. */
2
1
  export default {
3
2
  plugins: { "@tailwindcss/postcss": {} },
4
3
  };
@@ -1,9 +1,6 @@
1
1
  @import "tailwindcss";
2
2
  @source "../../../../packages/ui/src/**/*.{ts,tsx}";
3
3
 
4
- :root {
5
- color-scheme: light dark;
6
- }
7
-
4
+ :root { color-scheme: light dark; }
8
5
  html, body { height: 100%; }
9
6
  body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }