@pylonsync/create-pylon 0.3.274 → 0.3.275

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 (323) hide show
  1. package/bin/create-pylon.js +80 -0
  2. package/package.json +1 -1
  3. package/templates/ARCHETYPES.md +339 -0
  4. package/templates/agency/.env.example +12 -0
  5. package/templates/agency/AGENTS.md +61 -0
  6. package/templates/agency/README.md +90 -0
  7. package/templates/agency/app/auth-form.tsx +129 -0
  8. package/templates/agency/app/contact-form.tsx +258 -0
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +286 -0
  10. package/templates/agency/app/dashboard/page.tsx +70 -0
  11. package/templates/agency/app/error.tsx +26 -0
  12. package/templates/agency/app/globals.css +148 -0
  13. package/templates/agency/app/layout.tsx +174 -0
  14. package/templates/agency/app/login/page.tsx +39 -0
  15. package/templates/agency/app/not-found.tsx +19 -0
  16. package/templates/agency/app/page.tsx +207 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/sitemap.ts +9 -0
  19. package/templates/agency/app.ts +135 -0
  20. package/templates/agency/components/marketing.tsx +148 -0
  21. package/templates/agency/components/section-scroller.tsx +35 -0
  22. package/templates/agency/components/ui/button.tsx +56 -0
  23. package/templates/agency/components/ui/card.tsx +90 -0
  24. package/templates/agency/components.json +20 -0
  25. package/templates/agency/functions/bookInquiry.ts +42 -0
  26. package/templates/agency/functions/declineInquiry.ts +41 -0
  27. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  28. package/templates/agency/functions/seedCapacity.ts +26 -0
  29. package/templates/agency/functions/setCapacity.ts +32 -0
  30. package/templates/agency/functions/submitInquiry.ts +55 -0
  31. package/templates/agency/gitignore +10 -0
  32. package/templates/agency/lib/agency.ts +27 -0
  33. package/templates/agency/lib/owner.ts +26 -0
  34. package/templates/agency/lib/site.config.ts +239 -0
  35. package/templates/agency/lib/utils.ts +10 -0
  36. package/templates/agency/package.json +34 -0
  37. package/templates/agency/tsconfig.json +18 -0
  38. package/templates/ai-chat/.env.example +33 -0
  39. package/templates/ai-chat/AGENTS.md +61 -0
  40. package/templates/ai-chat/README.md +99 -0
  41. package/templates/ai-chat/app/auth-form.tsx +124 -0
  42. package/templates/ai-chat/app/chat-client.tsx +414 -0
  43. package/templates/ai-chat/app/error.tsx +26 -0
  44. package/templates/ai-chat/app/globals.css +148 -0
  45. package/templates/ai-chat/app/layout.tsx +75 -0
  46. package/templates/ai-chat/app/login/page.tsx +39 -0
  47. package/templates/ai-chat/app/not-found.tsx +19 -0
  48. package/templates/ai-chat/app/page.tsx +23 -0
  49. package/templates/ai-chat/app.ts +121 -0
  50. package/templates/ai-chat/components.json +20 -0
  51. package/templates/ai-chat/gitignore +10 -0
  52. package/templates/ai-chat/lib/site.config.ts +103 -0
  53. package/templates/ai-chat/lib/utils.ts +10 -0
  54. package/templates/ai-chat/package.json +34 -0
  55. package/templates/ai-chat/tsconfig.json +18 -0
  56. package/templates/ai-studio/.env.example +19 -0
  57. package/templates/ai-studio/AGENTS.md +61 -0
  58. package/templates/ai-studio/README.md +83 -0
  59. package/templates/ai-studio/app/auth-form.tsx +124 -0
  60. package/templates/ai-studio/app/error.tsx +26 -0
  61. package/templates/ai-studio/app/globals.css +148 -0
  62. package/templates/ai-studio/app/layout.tsx +75 -0
  63. package/templates/ai-studio/app/login/page.tsx +39 -0
  64. package/templates/ai-studio/app/not-found.tsx +19 -0
  65. package/templates/ai-studio/app/page.tsx +34 -0
  66. package/templates/ai-studio/app/studio-client.tsx +214 -0
  67. package/templates/ai-studio/app.ts +108 -0
  68. package/templates/ai-studio/components.json +20 -0
  69. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  70. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  71. package/templates/ai-studio/functions/generate.ts +42 -0
  72. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  73. package/templates/ai-studio/gitignore +10 -0
  74. package/templates/ai-studio/lib/site.config.ts +80 -0
  75. package/templates/ai-studio/lib/studio.ts +52 -0
  76. package/templates/ai-studio/lib/utils.ts +10 -0
  77. package/templates/ai-studio/package.json +34 -0
  78. package/templates/ai-studio/tsconfig.json +18 -0
  79. package/templates/creator/.env.example +12 -0
  80. package/templates/creator/AGENTS.md +61 -0
  81. package/templates/creator/README.md +67 -0
  82. package/templates/creator/app/auth-form.tsx +129 -0
  83. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  84. package/templates/creator/app/dashboard/page.tsx +70 -0
  85. package/templates/creator/app/error.tsx +26 -0
  86. package/templates/creator/app/globals.css +148 -0
  87. package/templates/creator/app/layout.tsx +160 -0
  88. package/templates/creator/app/login/page.tsx +39 -0
  89. package/templates/creator/app/newsletter-signup.tsx +162 -0
  90. package/templates/creator/app/not-found.tsx +19 -0
  91. package/templates/creator/app/page.tsx +160 -0
  92. package/templates/creator/app/robots.ts +12 -0
  93. package/templates/creator/app/sitemap.ts +9 -0
  94. package/templates/creator/app.ts +134 -0
  95. package/templates/creator/components/marketing.tsx +148 -0
  96. package/templates/creator/components/section-scroller.tsx +35 -0
  97. package/templates/creator/components/ui/button.tsx +56 -0
  98. package/templates/creator/components/ui/card.tsx +90 -0
  99. package/templates/creator/components.json +20 -0
  100. package/templates/creator/functions/subscribe.ts +82 -0
  101. package/templates/creator/functions/subscriberStats.ts +75 -0
  102. package/templates/creator/gitignore +10 -0
  103. package/templates/creator/lib/owner.ts +26 -0
  104. package/templates/creator/lib/site.config.ts +173 -0
  105. package/templates/creator/lib/stats.ts +30 -0
  106. package/templates/creator/lib/utils.ts +10 -0
  107. package/templates/creator/package.json +34 -0
  108. package/templates/creator/tsconfig.json +18 -0
  109. package/templates/default/app/layout.tsx +26 -27
  110. package/templates/default/app/page.tsx +90 -274
  111. package/templates/default/lib/products.ts +9 -122
  112. package/templates/default/lib/site.config.ts +739 -0
  113. package/templates/default/lib/site.ts +14 -261
  114. package/templates/directory/.env.example +12 -0
  115. package/templates/directory/AGENTS.md +61 -0
  116. package/templates/directory/README.md +80 -0
  117. package/templates/directory/app/auth-form.tsx +129 -0
  118. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  119. package/templates/directory/app/dashboard/page.tsx +70 -0
  120. package/templates/directory/app/directory-browse.tsx +328 -0
  121. package/templates/directory/app/error.tsx +26 -0
  122. package/templates/directory/app/globals.css +148 -0
  123. package/templates/directory/app/layout.tsx +171 -0
  124. package/templates/directory/app/login/page.tsx +39 -0
  125. package/templates/directory/app/not-found.tsx +19 -0
  126. package/templates/directory/app/page.tsx +50 -0
  127. package/templates/directory/app/robots.ts +12 -0
  128. package/templates/directory/app/sitemap.ts +9 -0
  129. package/templates/directory/app/submit/page.tsx +30 -0
  130. package/templates/directory/app/submit-form.tsx +151 -0
  131. package/templates/directory/app.ts +146 -0
  132. package/templates/directory/components/marketing.tsx +148 -0
  133. package/templates/directory/components/section-scroller.tsx +35 -0
  134. package/templates/directory/components/ui/button.tsx +56 -0
  135. package/templates/directory/components/ui/card.tsx +90 -0
  136. package/templates/directory/components.json +20 -0
  137. package/templates/directory/functions/approveSubmission.ts +45 -0
  138. package/templates/directory/functions/rejectSubmission.ts +20 -0
  139. package/templates/directory/functions/seedListings.ts +33 -0
  140. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  141. package/templates/directory/functions/submitListing.ts +63 -0
  142. package/templates/directory/functions/upvote.ts +24 -0
  143. package/templates/directory/gitignore +10 -0
  144. package/templates/directory/lib/directory.ts +45 -0
  145. package/templates/directory/lib/owner.ts +26 -0
  146. package/templates/directory/lib/site.config.ts +130 -0
  147. package/templates/directory/lib/utils.ts +10 -0
  148. package/templates/directory/package.json +34 -0
  149. package/templates/directory/tsconfig.json +18 -0
  150. package/templates/local-service/.env.example +12 -0
  151. package/templates/local-service/AGENTS.md +61 -0
  152. package/templates/local-service/README.md +82 -0
  153. package/templates/local-service/app/auth-form.tsx +129 -0
  154. package/templates/local-service/app/booking-widget.tsx +399 -0
  155. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  156. package/templates/local-service/app/dashboard/page.tsx +63 -0
  157. package/templates/local-service/app/error.tsx +26 -0
  158. package/templates/local-service/app/globals.css +148 -0
  159. package/templates/local-service/app/layout.tsx +151 -0
  160. package/templates/local-service/app/login/page.tsx +39 -0
  161. package/templates/local-service/app/not-found.tsx +19 -0
  162. package/templates/local-service/app/page.tsx +233 -0
  163. package/templates/local-service/app/robots.ts +12 -0
  164. package/templates/local-service/app/sitemap.ts +9 -0
  165. package/templates/local-service/app.ts +131 -0
  166. package/templates/local-service/components/marketing.tsx +162 -0
  167. package/templates/local-service/components/section-scroller.tsx +35 -0
  168. package/templates/local-service/components/ui/button.tsx +56 -0
  169. package/templates/local-service/components/ui/card.tsx +90 -0
  170. package/templates/local-service/components.json +20 -0
  171. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  172. package/templates/local-service/functions/cancelBooking.ts +27 -0
  173. package/templates/local-service/functions/confirmBooking.ts +18 -0
  174. package/templates/local-service/functions/createBooking.ts +98 -0
  175. package/templates/local-service/gitignore +10 -0
  176. package/templates/local-service/lib/booking.ts +24 -0
  177. package/templates/local-service/lib/owner.ts +26 -0
  178. package/templates/local-service/lib/site.config.ts +232 -0
  179. package/templates/local-service/lib/slots.ts +97 -0
  180. package/templates/local-service/lib/utils.ts +10 -0
  181. package/templates/local-service/package.json +34 -0
  182. package/templates/local-service/tsconfig.json +18 -0
  183. package/templates/marketplace/.env.example +9 -0
  184. package/templates/marketplace/AGENTS.md +61 -0
  185. package/templates/marketplace/README.md +78 -0
  186. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  187. package/templates/marketplace/app/error.tsx +26 -0
  188. package/templates/marketplace/app/globals.css +64 -0
  189. package/templates/marketplace/app/layout.tsx +60 -0
  190. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  191. package/templates/marketplace/app/me/page.tsx +15 -0
  192. package/templates/marketplace/app/not-found.tsx +20 -0
  193. package/templates/marketplace/app/page.tsx +159 -0
  194. package/templates/marketplace/app/robots.ts +12 -0
  195. package/templates/marketplace/app/sell/page.tsx +26 -0
  196. package/templates/marketplace/app/sitemap.ts +14 -0
  197. package/templates/marketplace/app.ts +190 -0
  198. package/templates/marketplace/client/AuthNav.tsx +46 -0
  199. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  200. package/templates/marketplace/client/LoginCard.tsx +130 -0
  201. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  202. package/templates/marketplace/client/MyMarket.tsx +180 -0
  203. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  204. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  205. package/templates/marketplace/client/SellForm.tsx +160 -0
  206. package/templates/marketplace/client/WatchButton.tsx +88 -0
  207. package/templates/marketplace/client/market.ts +341 -0
  208. package/templates/marketplace/functions/buyNow.ts +78 -0
  209. package/templates/marketplace/functions/makeOffer.ts +65 -0
  210. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  211. package/templates/marketplace/functions/seedMarket.ts +90 -0
  212. package/templates/marketplace/gitignore +10 -0
  213. package/templates/marketplace/package.json +35 -0
  214. package/templates/marketplace/tsconfig.json +14 -0
  215. package/templates/marketplace/ui/badge.tsx +30 -0
  216. package/templates/marketplace/ui/button.tsx +49 -0
  217. package/templates/marketplace/ui/card.tsx +48 -0
  218. package/templates/marketplace/ui/input.tsx +17 -0
  219. package/templates/marketplace/ui/label.tsx +18 -0
  220. package/templates/marketplace/ui/textarea.tsx +17 -0
  221. package/templates/marketplace/ui/tokens.css +32 -0
  222. package/templates/marketplace/ui/utils.ts +6 -0
  223. package/templates/restaurant/.env.example +12 -0
  224. package/templates/restaurant/AGENTS.md +61 -0
  225. package/templates/restaurant/README.md +77 -0
  226. package/templates/restaurant/app/auth-form.tsx +129 -0
  227. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  228. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  229. package/templates/restaurant/app/error.tsx +26 -0
  230. package/templates/restaurant/app/globals.css +148 -0
  231. package/templates/restaurant/app/layout.tsx +151 -0
  232. package/templates/restaurant/app/login/page.tsx +39 -0
  233. package/templates/restaurant/app/not-found.tsx +19 -0
  234. package/templates/restaurant/app/page.tsx +194 -0
  235. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  236. package/templates/restaurant/app/robots.ts +12 -0
  237. package/templates/restaurant/app/sitemap.ts +9 -0
  238. package/templates/restaurant/app.ts +115 -0
  239. package/templates/restaurant/components/marketing.tsx +162 -0
  240. package/templates/restaurant/components/section-scroller.tsx +35 -0
  241. package/templates/restaurant/components/ui/button.tsx +56 -0
  242. package/templates/restaurant/components/ui/card.tsx +90 -0
  243. package/templates/restaurant/components.json +20 -0
  244. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  245. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  246. package/templates/restaurant/functions/createReservation.ts +92 -0
  247. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  248. package/templates/restaurant/gitignore +10 -0
  249. package/templates/restaurant/lib/owner.ts +26 -0
  250. package/templates/restaurant/lib/reservation.ts +22 -0
  251. package/templates/restaurant/lib/site.config.ts +218 -0
  252. package/templates/restaurant/lib/slots.ts +55 -0
  253. package/templates/restaurant/lib/utils.ts +10 -0
  254. package/templates/restaurant/package.json +34 -0
  255. package/templates/restaurant/tsconfig.json +18 -0
  256. package/templates/shop/.env.example +32 -0
  257. package/templates/shop/AGENTS.md +61 -0
  258. package/templates/shop/README.md +102 -0
  259. package/templates/shop/app/auth-form.tsx +129 -0
  260. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  261. package/templates/shop/app/dashboard/page.tsx +59 -0
  262. package/templates/shop/app/error.tsx +26 -0
  263. package/templates/shop/app/globals.css +148 -0
  264. package/templates/shop/app/layout.tsx +160 -0
  265. package/templates/shop/app/login/page.tsx +39 -0
  266. package/templates/shop/app/not-found.tsx +19 -0
  267. package/templates/shop/app/page.tsx +95 -0
  268. package/templates/shop/app/robots.ts +12 -0
  269. package/templates/shop/app/shop-client.tsx +436 -0
  270. package/templates/shop/app/sitemap.ts +9 -0
  271. package/templates/shop/app/success/page.tsx +33 -0
  272. package/templates/shop/app.ts +134 -0
  273. package/templates/shop/components/marketing.tsx +96 -0
  274. package/templates/shop/components/section-scroller.tsx +35 -0
  275. package/templates/shop/components/ui/button.tsx +56 -0
  276. package/templates/shop/components/ui/card.tsx +90 -0
  277. package/templates/shop/components.json +20 -0
  278. package/templates/shop/functions/cancelOrder.ts +33 -0
  279. package/templates/shop/functions/checkout.ts +130 -0
  280. package/templates/shop/functions/fulfillOrder.ts +17 -0
  281. package/templates/shop/functions/markGroupPaid.ts +26 -0
  282. package/templates/shop/functions/ordersForOwner.ts +28 -0
  283. package/templates/shop/functions/releaseGroup.ts +36 -0
  284. package/templates/shop/functions/reserveCart.ts +87 -0
  285. package/templates/shop/functions/restockProduct.ts +23 -0
  286. package/templates/shop/functions/seedProducts.ts +30 -0
  287. package/templates/shop/functions/stripeWebhook.ts +72 -0
  288. package/templates/shop/gitignore +10 -0
  289. package/templates/shop/lib/owner.ts +26 -0
  290. package/templates/shop/lib/shop.ts +45 -0
  291. package/templates/shop/lib/site.config.ts +198 -0
  292. package/templates/shop/lib/utils.ts +10 -0
  293. package/templates/shop/package.json +35 -0
  294. package/templates/shop/tsconfig.json +18 -0
  295. package/templates/waitlist/.env.example +12 -0
  296. package/templates/waitlist/AGENTS.md +61 -0
  297. package/templates/waitlist/README.md +81 -0
  298. package/templates/waitlist/app/auth-form.tsx +129 -0
  299. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  300. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  301. package/templates/waitlist/app/error.tsx +26 -0
  302. package/templates/waitlist/app/globals.css +148 -0
  303. package/templates/waitlist/app/layout.tsx +158 -0
  304. package/templates/waitlist/app/login/page.tsx +39 -0
  305. package/templates/waitlist/app/not-found.tsx +19 -0
  306. package/templates/waitlist/app/page.tsx +119 -0
  307. package/templates/waitlist/app/robots.ts +12 -0
  308. package/templates/waitlist/app/sitemap.ts +9 -0
  309. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  310. package/templates/waitlist/app.ts +134 -0
  311. package/templates/waitlist/components/marketing.tsx +96 -0
  312. package/templates/waitlist/components/ui/button.tsx +56 -0
  313. package/templates/waitlist/components/ui/card.tsx +90 -0
  314. package/templates/waitlist/components.json +20 -0
  315. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  316. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  317. package/templates/waitlist/gitignore +10 -0
  318. package/templates/waitlist/lib/owner.ts +26 -0
  319. package/templates/waitlist/lib/site.config.ts +178 -0
  320. package/templates/waitlist/lib/stats.ts +30 -0
  321. package/templates/waitlist/lib/utils.ts +10 -0
  322. package/templates/waitlist/package.json +34 -0
  323. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,23 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // restockProduct — owner-only. Adds units to a product's stock. The bump syncs
5
+ // to every open grid, so the shelf updates live (a sold-out item reappears).
6
+ export default mutation<{ slug: string; add: number }, { ok: boolean; stock: number }>({
7
+ auth: "user",
8
+ args: { slug: v.string(), add: v.int() },
9
+ async handler(ctx, args) {
10
+ const me = await ctx.db.get("User", ctx.auth.userId);
11
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
12
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage stock.");
13
+ }
14
+ const add = Math.max(0, Math.trunc(args.add));
15
+ const product = (await ctx.db.unsafe.lookup("Product", "slug", args.slug)) as
16
+ | { id: string; stock: number }
17
+ | null;
18
+ if (!product) throw ctx.error("NOT_FOUND", "Product not found.");
19
+ const stock = product.stock + add;
20
+ await ctx.db.unsafe.update("Product", product.id, { stock });
21
+ return { ok: true, stock };
22
+ },
23
+ });
@@ -0,0 +1,30 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { siteConfig } from "../lib/site.config";
3
+
4
+ // seedProducts — idempotently load the catalog (incl. starting stock) from
5
+ // config into the Product table on first visit. The product grid calls this on
6
+ // mount; it's a no-op once products exist, so it's safe to call every load. A
7
+ // lock keeps two concurrent first-visits from double-seeding.
8
+ //
9
+ // Public so an anonymous first visitor seeds the shelf — it only ever writes
10
+ // the config catalog, never reads or returns anything sensitive.
11
+ export default mutation<Record<string, never>, { seeded: boolean; count: number }>({
12
+ auth: "public",
13
+ async handler(ctx) {
14
+ await ctx.db.advisoryLock("shop_seed_products");
15
+ const existing = await ctx.db.unsafe.list("Product");
16
+ if (existing.length > 0) return { seeded: false, count: existing.length };
17
+
18
+ for (const p of siteConfig.products.items) {
19
+ await ctx.db.unsafe.insert("Product", {
20
+ slug: p.slug,
21
+ name: p.name,
22
+ priceCents: p.priceCents,
23
+ description: p.description ?? null,
24
+ image: p.image,
25
+ stock: p.stock,
26
+ });
27
+ }
28
+ return { seeded: true, count: siteConfig.products.items.length };
29
+ },
30
+ });
@@ -0,0 +1,72 @@
1
+ import { action } from "@pylonsync/functions";
2
+ import { verifyStripeSignature } from "@pylonsync/stripe";
3
+
4
+ // stripeWebhook — Stripe's callback that settles a checkout. Mounted at
5
+ // POST /api/webhooks/stripeWebhook (the webhook route gives an action the EXACT
6
+ // raw request bytes Stripe signed; `/api/fn/...` would re-encode them and break
7
+ // the signature). Point your Stripe dashboard webhook there and put the signing
8
+ // secret in STRIPE_WEBHOOK_SECRET.
9
+ //
10
+ // SECURITY: the request is unauthenticated, so the HMAC signature IS the auth.
11
+ // We verify it with the vetted constant-time `verifyStripeSignature` BEFORE
12
+ // trusting a single byte — never parse-then-trust. A bad/missing/stale/replayed
13
+ // signature is rejected with 400.
14
+ //
15
+ // On success it settles the cart's order lines by `client_reference_id` (the
16
+ // orderGroupId set at checkout): paid → mark "paid"; expired/failed → release
17
+ // the held stock. Both settle mutations are idempotent, so Stripe's retries are
18
+ // safe.
19
+ export default action({
20
+ auth: "public",
21
+ args: {},
22
+ async handler(ctx) {
23
+ if (!ctx.request) {
24
+ throw ctx.error("BAD_INVOCATION", "stripeWebhook must be called as a webhook.");
25
+ }
26
+ const secret = ctx.env.STRIPE_WEBHOOK_SECRET?.trim();
27
+ if (!secret) {
28
+ throw ctx.error("NOT_CONFIGURED", "STRIPE_WEBHOOK_SECRET is not set.");
29
+ }
30
+
31
+ const sig = ctx.request.headers["stripe-signature"];
32
+ const verdict = await verifyStripeSignature(secret, ctx.request.rawBody, sig);
33
+ if (verdict !== true) {
34
+ throw ctx.error("INVALID_SIGNATURE", `stripe signature: ${verdict}`);
35
+ }
36
+
37
+ let event: {
38
+ type?: string;
39
+ data?: { object?: { client_reference_id?: string; payment_status?: string; metadata?: { orderGroupId?: string } } };
40
+ };
41
+ try {
42
+ event = JSON.parse(ctx.request.rawBody);
43
+ } catch {
44
+ throw ctx.error("BAD_BODY", "Webhook body is not valid JSON.");
45
+ }
46
+
47
+ const session = event.data?.object ?? {};
48
+ const orderGroupId = session.client_reference_id || session.metadata?.orderGroupId;
49
+
50
+ switch (event.type) {
51
+ case "checkout.session.completed":
52
+ // Card payments are paid on completion; delayed methods arrive as
53
+ // "unpaid" here and confirm later via async_payment_succeeded.
54
+ if (orderGroupId && session.payment_status === "paid") {
55
+ await ctx.runMutation("markGroupPaid", { orderGroupId });
56
+ }
57
+ return { ok: true, type: event.type };
58
+
59
+ case "checkout.session.async_payment_succeeded":
60
+ if (orderGroupId) await ctx.runMutation("markGroupPaid", { orderGroupId });
61
+ return { ok: true, type: event.type };
62
+
63
+ case "checkout.session.expired":
64
+ case "checkout.session.async_payment_failed":
65
+ if (orderGroupId) await ctx.runMutation("releaseGroup", { orderGroupId });
66
+ return { ok: true, type: event.type };
67
+
68
+ default:
69
+ return { ignored: event.type ?? "unknown" };
70
+ }
71
+ },
72
+ });
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ .pylon/
3
+ pylon.manifest.json
4
+ pylon.client.ts
5
+ web/dist/
6
+ *.db
7
+ *.db-*
8
+ .env
9
+ .env.local
10
+ .DS_Store
@@ -0,0 +1,26 @@
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.
5
+ //
6
+ // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
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
9
+ // .env.example) before signing in.
10
+
11
+ export function normalizeOwner(raw: string | null | undefined): string | null {
12
+ const v = raw?.trim().toLowerCase();
13
+ return v && v.length > 0 ? v : null;
14
+ }
15
+
16
+ // Pure comparator — the caller supplies the configured owner value (from
17
+ // `ctx.env.PYLON_OWNER_EMAIL`), so the rule lives in one place and stays
18
+ // testable without reaching for the environment here.
19
+ export function emailMatchesOwner(
20
+ email: string | null | undefined,
21
+ ownerRaw: string | null | undefined,
22
+ ): boolean {
23
+ const owner = normalizeOwner(ownerRaw);
24
+ if (!owner) return false;
25
+ return (email ?? "").trim().toLowerCase() === owner;
26
+ }
@@ -0,0 +1,45 @@
1
+ // Shared shop types. The Order row is what the owner dashboard sees (with PII);
2
+ // the client imports only the type, never server code.
3
+
4
+ export interface OrderRow {
5
+ id: string;
6
+ orderGroupId: string;
7
+ productSlug: string;
8
+ productName: string;
9
+ qty: number;
10
+ unitPriceCents: number;
11
+ customerName: string;
12
+ customerEmail: string;
13
+ // "pending" | "paid" | "reserved" | "fulfilled" | "cancelled"
14
+ status: string;
15
+ createdAt: string;
16
+ }
17
+
18
+ // ordersForOwner returns a discriminated result rather than throwing on a
19
+ // non-owner (a query has no `ctx.error`; a bare throw becomes a stripped
20
+ // HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data.
21
+ export type OwnerOrdersResult =
22
+ | { authorized: true; orders: OrderRow[] }
23
+ | { authorized: false };
24
+
25
+ // One cart line on its way to checkout. The client sends slug + qty; the
26
+ // server re-prices from the catalog so the price is never client-trusted.
27
+ export interface CheckoutItem {
28
+ slug: string;
29
+ qty: number;
30
+ }
31
+
32
+ // What `checkout` returns to the client.
33
+ // • mode "stripe" → redirect the browser to `checkoutUrl` (hosted Stripe).
34
+ // • mode "reserved" → no payment processor configured; the order is held and
35
+ // the owner follows up. Show the confirmation message.
36
+ // `soldOut` lists any cart lines that sold out between page-load and checkout
37
+ // (they were dropped from the order); the rest still went through.
38
+ export type CheckoutResult =
39
+ | { ok: true; mode: "stripe"; checkoutUrl: string; soldOut: string[] }
40
+ | { ok: true; mode: "reserved"; orderGroupId: string; soldOut: string[] }
41
+ | { ok: false; reason: "empty" | "sold_out" | "invalid"; soldOut: string[] };
42
+
43
+ export function formatPrice(cents: number): string {
44
+ return `$${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
45
+ }
@@ -0,0 +1,198 @@
1
+ // THE single source of truth for everything business-specific. Rebrand the
2
+ // whole store by editing this ONE file — the landing page and layout read from
3
+ // here. The product list (incl. starting stock) seeds the Product table on
4
+ // first visit; after that, stock lives in the database and updates live.
5
+ //
6
+ // Colors live here (applied as CSS variables on <html> in app/layout.tsx).
7
+ // Fictional demo copy — replace the values, keep the shape.
8
+
9
+ /* ----------------------------- types ----------------------------- */
10
+
11
+ export type Social = { label: string; href: string; path: string };
12
+
13
+ export type BaseConfig = {
14
+ brand: {
15
+ name: string;
16
+ letter: string;
17
+ domain: string;
18
+ email: string;
19
+ footerBlurb: string;
20
+ copyrightName: string;
21
+ socials: Social[];
22
+ };
23
+ colors: { brand: string; brandSoft: string; paper: string };
24
+ seo: { title: string; description: string };
25
+ };
26
+
27
+ export type ProductItem = {
28
+ slug: string;
29
+ name: string;
30
+ priceCents: number;
31
+ description?: string;
32
+ image: string; // a product photo URL/path → shown as the photo; or an emoji stand-in (tagged "sample image")
33
+ stock: number; // STARTING stock — seeded once, then live in the DB
34
+ };
35
+
36
+ export type ValueProp = { title: string; body: string; icon?: string };
37
+ export type Review = { quote: string; name: string; rating?: number };
38
+ export type Policy = { title: string; body: string };
39
+
40
+ export type ShopConfig = BaseConfig & {
41
+ hero: { tagline: string; headline: string; subcopy: string; ctaLabel: string };
42
+ products: { eyebrow: string; headline: string; items: ProductItem[] };
43
+ checkout: { confirmationMessage: string };
44
+ valueProps: { eyebrow: string; headline: string; items: ValueProp[] };
45
+ reviews?: { eyebrow: string; headline: string; items: Review[] };
46
+ policies: { eyebrow: string; headline: string; items: Policy[] };
47
+ };
48
+
49
+ /* ----------------------------- config ---------------------------- */
50
+
51
+ export const siteConfig: ShopConfig = {
52
+ brand: {
53
+ name: "Ember Goods",
54
+ letter: "E",
55
+ domain: "embergoods.com",
56
+ email: "hello@embergoods.example",
57
+ footerBlurb:
58
+ "Hand-poured candles and small-batch home goods, made in Dallas. Slow, simple, and built to last. Free shipping over $50.",
59
+ copyrightName: "Ember Goods",
60
+ socials: [
61
+ {
62
+ label: "Instagram",
63
+ href: "https://instagram.com",
64
+ path: "M12 2.2c3.2 0 3.6 0 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s0 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58 0-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 0 1-1.38-.9 3.7 3.7 0 0 1-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.2 15.58 2.2 15.2 2.2 12s0-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.2 8.8 2.2 12 2.2zm0 1.8c-3.15 0-3.5 0-4.74.07-.9.04-1.38.19-1.7.32-.43.16-.74.36-1.06.68-.32.32-.52.63-.68 1.06-.13.32-.28.8-.32 1.7C3.8 8.5 3.8 8.85 3.8 12s0 3.5.07 4.74c.04.9.19 1.38.32 1.7.16.43.36.74.68 1.06.32.32.63.52 1.06.68.32.13.8.28 1.7.32 1.24.07 1.59.07 4.74.07s3.5 0 4.74-.07c.9-.04 1.38-.19 1.7-.32.43-.16.74-.36 1.06-.68.32-.32.52-.63.68-1.06.13-.32.28-.8.32-1.7.07-1.24.07-1.59.07-4.74s0-3.5-.07-4.74c-.04-.9-.19-1.38-.32-1.7a2.85 2.85 0 0 0-.68-1.06 2.85 2.85 0 0 0-1.06-.68c-.32-.13-.8-.28-1.7-.32C15.5 4 15.15 4 12 4zm0 3.06A4.94 4.94 0 1 0 12 16.94 4.94 4.94 0 0 0 12 7.06zm0 8.15A3.21 3.21 0 1 1 12 8.8a3.21 3.21 0 0 1 0 6.4zm6.3-8.35a1.15 1.15 0 1 1-2.3 0 1.15 1.15 0 0 1 2.3 0z",
65
+ },
66
+ ],
67
+ },
68
+
69
+ colors: { brand: "#9a3412", brandSoft: "#ffedd5", paper: "#faf8f5" },
70
+
71
+ seo: {
72
+ title: "Ember Goods — hand-poured candles, made in Dallas.",
73
+ description:
74
+ "Small-batch candles and home goods, hand-poured in Dallas. Live stock — what you see is what's in the studio. Free shipping over $50.",
75
+ },
76
+
77
+ hero: {
78
+ tagline: "Small batch · Made in Dallas",
79
+ headline: "Hand-poured candles, made to last.",
80
+ subcopy:
81
+ "We pour everything by hand in small batches, so quantities are real and limited. The stock you see below is exactly what's on the studio shelf right now.",
82
+ ctaLabel: "Shop the shelf",
83
+ },
84
+
85
+ products: {
86
+ eyebrow: "The shelf",
87
+ headline: "What's in the studio right now.",
88
+ items: [
89
+ {
90
+ slug: "cedar-smoke",
91
+ name: "Cedar & Smoke",
92
+ priceCents: 3200,
93
+ description: "Cedarwood, smoked vanilla, a little leather. 60-hour burn.",
94
+ image: "🕯️",
95
+ stock: 8,
96
+ },
97
+ {
98
+ slug: "linen",
99
+ name: "Fresh Linen",
100
+ priceCents: 3200,
101
+ description: "Clean cotton and white musk. The everyday one.",
102
+ image: "🤍",
103
+ stock: 3,
104
+ },
105
+ {
106
+ slug: "brass-holder",
107
+ name: "Brass matchstick holder",
108
+ priceCents: 2400,
109
+ description: "Solid brass, striker on the base. Ages beautifully.",
110
+ image: "🟫",
111
+ stock: 12,
112
+ },
113
+ {
114
+ slug: "ceramic-vessel",
115
+ name: "Ceramic vessel candle",
116
+ priceCents: 4800,
117
+ description: "Hand-thrown stoneware you'll keep long after the wax.",
118
+ image: "🏺",
119
+ stock: 5,
120
+ },
121
+ {
122
+ slug: "wick-trimmer",
123
+ name: "Wick trimmer",
124
+ priceCents: 1800,
125
+ description: "The one tool that makes a candle last. Matte black.",
126
+ image: "✂️",
127
+ stock: 0,
128
+ },
129
+ {
130
+ slug: "gift-set",
131
+ name: "The trio gift set",
132
+ priceCents: 8400,
133
+ description: "Three minis, boxed and ready to give.",
134
+ image: "🎁",
135
+ stock: 6,
136
+ },
137
+ ],
138
+ },
139
+
140
+ checkout: {
141
+ confirmationMessage:
142
+ "Order placed! We'll email you a payment link and ship within two business days.",
143
+ },
144
+
145
+ valueProps: {
146
+ eyebrow: "Why Ember",
147
+ headline: "Made slow, on purpose.",
148
+ items: [
149
+ {
150
+ icon: "◍",
151
+ title: "Hand-poured",
152
+ body: "Every candle is poured, wicked, and labelled by hand in our Dallas studio.",
153
+ },
154
+ {
155
+ icon: "◇",
156
+ title: "Real small batch",
157
+ body: "We make what we can do well. When the shelf says 3 left, there are 3 left.",
158
+ },
159
+ {
160
+ icon: "✦",
161
+ title: "Free shipping over $50",
162
+ body: "Carbon-neutral shipping, plastic-free packaging, and easy returns.",
163
+ },
164
+ ],
165
+ },
166
+
167
+ reviews: {
168
+ eyebrow: "Reviews",
169
+ headline: "What people say.",
170
+ items: [
171
+ {
172
+ quote: "Cedar & Smoke is the best candle I've ever bought, full stop. The brass holder is gorgeous too.",
173
+ name: "Maya C.",
174
+ rating: 5,
175
+ },
176
+ {
177
+ quote: "Ordered the gift set for my mom. Beautiful packaging and it arrived in two days. Will reorder.",
178
+ name: "Daniel R.",
179
+ rating: 5,
180
+ },
181
+ {
182
+ quote: "You can tell these are made by hand. The ceramic vessel is on my shelf forever now.",
183
+ name: "Hannah K.",
184
+ rating: 5,
185
+ },
186
+ ],
187
+ },
188
+
189
+ policies: {
190
+ eyebrow: "Good to know",
191
+ headline: "Shipping & returns.",
192
+ items: [
193
+ { title: "Shipping", body: "Ships in 2 business days from Dallas. Free over $50, $6 flat otherwise." },
194
+ { title: "Returns", body: "Unused and unburned? Return within 30 days for a full refund." },
195
+ { title: "Wholesale", body: "Run a shop? Email us for the line sheet and wholesale pricing." },
196
+ ],
197
+ },
198
+ };
@@ -0,0 +1,10 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ // `cn` — the shadcn class merger. clsx resolves conditional/array class
5
+ // inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
6
+ // the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
7
+ // component routes its className through this.
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "__APP_NAME_KEBAB__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "pylon dev",
8
+ "deploy": "pylon deploy",
9
+ "check": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@pylonsync/react": "^__PYLON_VERSION__",
13
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
14
+ "@pylonsync/functions": "^__PYLON_VERSION__",
15
+ "@pylonsync/client": "^__PYLON_VERSION__",
16
+ "@pylonsync/stripe": "^__PYLON_VERSION__",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0",
19
+ "tailwindcss": "^4.3.0",
20
+ "@tailwindcss/cli": "^4.3.0",
21
+ "tw-animate-css": "^1.2.0",
22
+ "class-variance-authority": "^0.7.1",
23
+ "clsx": "^2.1.1",
24
+ "tailwind-merge": "^2.5.0",
25
+ "lucide-react": "^0.460.0",
26
+ "@radix-ui/react-slot": "^1.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@pylonsync/cli": "^__PYLON_VERSION__",
30
+ "@types/node": "^22.0.0",
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "typescript": "^5.6.0"
34
+ }
35
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react",
7
+ "esModuleInterop": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "lib": ["ES2022", "DOM"],
11
+ "types": ["react", "react-dom", "node"],
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@/*": ["./*"]
15
+ }
16
+ },
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
+ }
@@ -0,0 +1,12 @@
1
+ # Copy to `.env` and fill in. `pylon dev` loads `.env` automatically.
2
+
3
+ # ── Owner (required to use the dashboard) ────────────────────────────────────
4
+ # A waitlist is single-tenant: one business, one owner. The /dashboard is
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.
8
+ PYLON_OWNER_EMAIL=you@yourbusiness.com
9
+
10
+ # ── Site URL (optional) ──────────────────────────────────────────────────────
11
+ # Used by robots.txt + sitemap.xml. Point it at your real domain in production.
12
+ # SITE_URL=https://yourbusiness.com
@@ -0,0 +1,61 @@
1
+ # AGENTS.md — working in a Pylon project
2
+
3
+ Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like framework for realtime apps: you declare entities, policies, and server functions in TypeScript, and a single Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR — one process, one port. The full API reference is at **/llms-full.txt** (served at `/llms-full.txt`; in the repo at `apps/web/public/llms-full.txt`). Read it before guessing an API name.
4
+
5
+ ## Directory conventions
6
+
7
+ **Unified SSR app:**
8
+ - `app.ts` — data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
+ - `app/` — file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
+ - `app/globals.css` — Tailwind v4 entrypoint (auto-compiled and injected).
11
+ - `functions/` — server functions, one per file, `default`-exported.
12
+ - `.pylon/` — local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
13
+
14
+ **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated — do not hand-edit.
15
+
16
+ ## The core authoring loop
17
+
18
+ 1. **Define an entity** — `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update — use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
+ 2. **Write a policy** — `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
20
+ 3. **Author a function** in `functions/<name>.ts` — `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
+ 4. **Read it on the client** — `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
22
+
23
+ ## Key gotchas
24
+
25
+ - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access — enforce trust with `ctx.auth` checks inside the handler, not policies.
26
+ - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` — they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
+ - **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) — but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL — skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` — works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model — gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet — use urlencoded forms + `/api/files`.
29
+ - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends — a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
+ - **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED — the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable — don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render — a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
+ - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) — `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
+ - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive — during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
+ - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` — `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
+ - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
+ - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready — status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
+ - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` — use `ctx.runQuery` / `ctx.runMutation`.
37
+ - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
+ - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
39
+
40
+ ## Use the CLI — don't guess
41
+
42
+ | Need | Command |
43
+ |---|---|
44
+ | Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
45
+ | Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
46
+ | Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
47
+ | Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
48
+ | Lint policies (PYL001–PYL004) | `pylon lint --strict` |
49
+ | Tests | `pylon test` |
50
+ | Adversarial security probe | `pylon test:security` |
51
+ | Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
52
+ | Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
53
+ | Call a function | `pylon fn <name> key=value` |
54
+ | Health snapshot | `pylon status` |
55
+ | Build for prod | `pylon build` |
56
+ | Deploy (Pylon Cloud by default) | `pylon deploy` |
57
+ | Look up an error code | `pylon explain <CODE>` |
58
+
59
+ `--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
60
+
61
+ For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **/llms-full.txt**.
@@ -0,0 +1,81 @@
1
+ # __APP_NAME__
2
+
3
+ A pre-launch / coming-soon landing page built with [Pylon](https://pylonsync.com) —
4
+ a server-rendered marketing page with a **live signup counter** and a private
5
+ owner dashboard, all served from one binary on one port. No Next.js, no separate
6
+ API server.
7
+
8
+ The whole point is that it's a *real live app*, not a static page: the signup
9
+ counter ticks up in realtime for everyone with the page open.
10
+
11
+ ## Develop
12
+
13
+ ```bash
14
+ __RUN_DEV__
15
+ ```
16
+
17
+ Open http://localhost:4321. Then **open a second tab**, submit an email in one,
18
+ and watch the counter increment in the other — with no refresh. That's the live
19
+ backend doing its thing.
20
+
21
+ ## How the realtime works
22
+
23
+ - `functions/joinWaitlist.ts` — a public **mutation** that validates, lowercases,
24
+ and dedupes the email, then inserts one `Signup` row. The insert fires a
25
+ change event.
26
+ - `functions/waitlistCount.ts` — a public **query** the landing page subscribes
27
+ to with `db.useReactiveQuery`. The server records that it read the `Signup`
28
+ table, so every new signup re-runs it and pushes the fresh count to every open
29
+ tab. No polling.
30
+ - The counter island (`app/waitlist-hero.tsx`) is wrapped in `<EnsureGuest>`,
31
+ which mints an anonymous session so the live WebSocket can connect.
32
+
33
+ ## Privacy — read this
34
+
35
+ The `Signup` entity holds visitor emails (PII), so its policy in `app.ts`
36
+ **denies every client read and write**. Emails can never be pulled from the
37
+ browser. The public page only ever receives an aggregate *count* (a bare
38
+ integer); the full list — including emails — is returned only by
39
+ `waitlistStats`, which is gated to the owner server-side. A marketing site must
40
+ never leak its own customers' emails, and this is how that's guaranteed.
41
+
42
+ ## The owner dashboard
43
+
44
+ `/dashboard` shows the total, a signups-over-time chart, a searchable list, and
45
+ a CSV export — all updating live as people join.
46
+
47
+ It's single-tenant: set `PYLON_OWNER_EMAIL` in `.env` (see `.env.example`) to
48
+ the email you'll sign in with, then create that account at `/login`. Only that
49
+ account can see signups; anyone else gets a locked screen.
50
+
51
+ ## Rebrand it
52
+
53
+ Everything business-specific lives in **`lib/site.config.ts`** — brand, colors,
54
+ hero copy, value props, social proof, FAQ. Edit that one file (or have a
55
+ generator produce it) and the whole page re-themes; you never touch the JSX or
56
+ CSS.
57
+
58
+ ## Layout
59
+
60
+ ```
61
+ app.ts data model + manifest (Signup, User, policies, auth)
62
+ lib/site.config.ts ALL business copy + brand + colors (edit this)
63
+ lib/owner.ts owner-email gate (PYLON_OWNER_EMAIL)
64
+ lib/stats.ts shared dashboard-stats types
65
+ functions/joinWaitlist.ts public mutation: validate + dedupe + insert
66
+ functions/waitlistCount.ts public reactive query: the live counter
67
+ functions/waitlistStats.ts owner-only reactive query: total + chart + list
68
+ app/page.tsx the landing page (server-rendered)
69
+ app/waitlist-hero.tsx client island: signup form + live counter
70
+ app/login/page.tsx owner sign-in
71
+ app/dashboard/ owner dashboard (auth-gated, live)
72
+ app/globals.css Tailwind entrypoint (compiled by Pylon)
73
+ ```
74
+
75
+ ## Deploy
76
+
77
+ ```bash
78
+ pylon deploy
79
+ ```
80
+
81
+ Docs: https://docs.pylonsync.com