@pylonsync/create-pylon 0.3.274 → 0.3.276

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 (340) 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 +1440 -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 +249 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/seeder.tsx +26 -0
  19. package/templates/agency/app/sitemap.ts +9 -0
  20. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  21. package/templates/agency/app/work/page.tsx +83 -0
  22. package/templates/agency/app.ts +284 -0
  23. package/templates/agency/components/marketing.tsx +187 -0
  24. package/templates/agency/components/section-scroller.tsx +35 -0
  25. package/templates/agency/components/ui/button.tsx +56 -0
  26. package/templates/agency/components/ui/card.tsx +90 -0
  27. package/templates/agency/components.json +20 -0
  28. package/templates/agency/functions/bookInquiry.ts +42 -0
  29. package/templates/agency/functions/clientsForOwner.ts +27 -0
  30. package/templates/agency/functions/declineInquiry.ts +41 -0
  31. package/templates/agency/functions/deleteClient.ts +27 -0
  32. package/templates/agency/functions/deleteInvoice.ts +19 -0
  33. package/templates/agency/functions/deleteProject.ts +20 -0
  34. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  35. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  36. package/templates/agency/functions/seedCapacity.ts +26 -0
  37. package/templates/agency/functions/seedProjects.ts +41 -0
  38. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  39. package/templates/agency/functions/setCapacity.ts +32 -0
  40. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  41. package/templates/agency/functions/setProjectFlags.ts +35 -0
  42. package/templates/agency/functions/submitInquiry.ts +55 -0
  43. package/templates/agency/functions/upsertClient.ts +73 -0
  44. package/templates/agency/functions/upsertInvoice.ts +113 -0
  45. package/templates/agency/functions/upsertProject.ts +97 -0
  46. package/templates/agency/gitignore +10 -0
  47. package/templates/agency/lib/agency.ts +189 -0
  48. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  49. package/templates/agency/lib/owner.ts +26 -0
  50. package/templates/agency/lib/site.config.ts +418 -0
  51. package/templates/agency/lib/utils.ts +10 -0
  52. package/templates/agency/package.json +35 -0
  53. package/templates/agency/tsconfig.json +18 -0
  54. package/templates/ai-chat/.env.example +33 -0
  55. package/templates/ai-chat/AGENTS.md +61 -0
  56. package/templates/ai-chat/README.md +99 -0
  57. package/templates/ai-chat/app/auth-form.tsx +124 -0
  58. package/templates/ai-chat/app/chat-client.tsx +727 -0
  59. package/templates/ai-chat/app/error.tsx +26 -0
  60. package/templates/ai-chat/app/globals.css +148 -0
  61. package/templates/ai-chat/app/layout.tsx +75 -0
  62. package/templates/ai-chat/app/login/page.tsx +39 -0
  63. package/templates/ai-chat/app/not-found.tsx +19 -0
  64. package/templates/ai-chat/app/page.tsx +23 -0
  65. package/templates/ai-chat/app.ts +121 -0
  66. package/templates/ai-chat/components.json +20 -0
  67. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  68. package/templates/ai-chat/gitignore +10 -0
  69. package/templates/ai-chat/lib/site.config.ts +103 -0
  70. package/templates/ai-chat/lib/utils.ts +10 -0
  71. package/templates/ai-chat/package.json +34 -0
  72. package/templates/ai-chat/tsconfig.json +18 -0
  73. package/templates/ai-studio/.env.example +19 -0
  74. package/templates/ai-studio/AGENTS.md +61 -0
  75. package/templates/ai-studio/README.md +83 -0
  76. package/templates/ai-studio/app/auth-form.tsx +124 -0
  77. package/templates/ai-studio/app/error.tsx +26 -0
  78. package/templates/ai-studio/app/globals.css +148 -0
  79. package/templates/ai-studio/app/layout.tsx +75 -0
  80. package/templates/ai-studio/app/login/page.tsx +39 -0
  81. package/templates/ai-studio/app/not-found.tsx +19 -0
  82. package/templates/ai-studio/app/page.tsx +34 -0
  83. package/templates/ai-studio/app/studio-client.tsx +357 -0
  84. package/templates/ai-studio/app.ts +108 -0
  85. package/templates/ai-studio/components.json +20 -0
  86. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  87. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  88. package/templates/ai-studio/functions/generate.ts +42 -0
  89. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  90. package/templates/ai-studio/gitignore +10 -0
  91. package/templates/ai-studio/lib/site.config.ts +80 -0
  92. package/templates/ai-studio/lib/studio.ts +52 -0
  93. package/templates/ai-studio/lib/utils.ts +10 -0
  94. package/templates/ai-studio/package.json +34 -0
  95. package/templates/ai-studio/tsconfig.json +18 -0
  96. package/templates/creator/.env.example +12 -0
  97. package/templates/creator/AGENTS.md +61 -0
  98. package/templates/creator/README.md +67 -0
  99. package/templates/creator/app/auth-form.tsx +129 -0
  100. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  101. package/templates/creator/app/dashboard/page.tsx +70 -0
  102. package/templates/creator/app/error.tsx +26 -0
  103. package/templates/creator/app/globals.css +148 -0
  104. package/templates/creator/app/layout.tsx +160 -0
  105. package/templates/creator/app/login/page.tsx +39 -0
  106. package/templates/creator/app/newsletter-signup.tsx +162 -0
  107. package/templates/creator/app/not-found.tsx +19 -0
  108. package/templates/creator/app/page.tsx +160 -0
  109. package/templates/creator/app/robots.ts +12 -0
  110. package/templates/creator/app/sitemap.ts +9 -0
  111. package/templates/creator/app.ts +134 -0
  112. package/templates/creator/components/marketing.tsx +148 -0
  113. package/templates/creator/components/section-scroller.tsx +35 -0
  114. package/templates/creator/components/ui/button.tsx +56 -0
  115. package/templates/creator/components/ui/card.tsx +90 -0
  116. package/templates/creator/components.json +20 -0
  117. package/templates/creator/functions/subscribe.ts +82 -0
  118. package/templates/creator/functions/subscriberStats.ts +75 -0
  119. package/templates/creator/gitignore +10 -0
  120. package/templates/creator/lib/owner.ts +26 -0
  121. package/templates/creator/lib/site.config.ts +173 -0
  122. package/templates/creator/lib/stats.ts +30 -0
  123. package/templates/creator/lib/utils.ts +10 -0
  124. package/templates/creator/package.json +34 -0
  125. package/templates/creator/tsconfig.json +18 -0
  126. package/templates/default/app/layout.tsx +26 -27
  127. package/templates/default/app/page.tsx +90 -274
  128. package/templates/default/lib/products.ts +9 -122
  129. package/templates/default/lib/site.config.ts +739 -0
  130. package/templates/default/lib/site.ts +14 -261
  131. package/templates/directory/.env.example +12 -0
  132. package/templates/directory/AGENTS.md +61 -0
  133. package/templates/directory/README.md +80 -0
  134. package/templates/directory/app/auth-form.tsx +129 -0
  135. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  136. package/templates/directory/app/dashboard/page.tsx +70 -0
  137. package/templates/directory/app/directory-browse.tsx +328 -0
  138. package/templates/directory/app/error.tsx +26 -0
  139. package/templates/directory/app/globals.css +148 -0
  140. package/templates/directory/app/layout.tsx +171 -0
  141. package/templates/directory/app/login/page.tsx +39 -0
  142. package/templates/directory/app/not-found.tsx +19 -0
  143. package/templates/directory/app/page.tsx +50 -0
  144. package/templates/directory/app/robots.ts +12 -0
  145. package/templates/directory/app/sitemap.ts +9 -0
  146. package/templates/directory/app/submit/page.tsx +30 -0
  147. package/templates/directory/app/submit-form.tsx +151 -0
  148. package/templates/directory/app.ts +146 -0
  149. package/templates/directory/components/marketing.tsx +148 -0
  150. package/templates/directory/components/section-scroller.tsx +35 -0
  151. package/templates/directory/components/ui/button.tsx +56 -0
  152. package/templates/directory/components/ui/card.tsx +90 -0
  153. package/templates/directory/components.json +20 -0
  154. package/templates/directory/functions/approveSubmission.ts +45 -0
  155. package/templates/directory/functions/rejectSubmission.ts +20 -0
  156. package/templates/directory/functions/seedListings.ts +33 -0
  157. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  158. package/templates/directory/functions/submitListing.ts +63 -0
  159. package/templates/directory/functions/upvote.ts +24 -0
  160. package/templates/directory/gitignore +10 -0
  161. package/templates/directory/lib/directory.ts +45 -0
  162. package/templates/directory/lib/owner.ts +26 -0
  163. package/templates/directory/lib/site.config.ts +130 -0
  164. package/templates/directory/lib/utils.ts +10 -0
  165. package/templates/directory/package.json +34 -0
  166. package/templates/directory/tsconfig.json +18 -0
  167. package/templates/local-service/.env.example +12 -0
  168. package/templates/local-service/AGENTS.md +61 -0
  169. package/templates/local-service/README.md +82 -0
  170. package/templates/local-service/app/auth-form.tsx +129 -0
  171. package/templates/local-service/app/booking-widget.tsx +399 -0
  172. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  173. package/templates/local-service/app/dashboard/page.tsx +63 -0
  174. package/templates/local-service/app/error.tsx +26 -0
  175. package/templates/local-service/app/globals.css +148 -0
  176. package/templates/local-service/app/layout.tsx +151 -0
  177. package/templates/local-service/app/login/page.tsx +39 -0
  178. package/templates/local-service/app/not-found.tsx +19 -0
  179. package/templates/local-service/app/page.tsx +233 -0
  180. package/templates/local-service/app/robots.ts +12 -0
  181. package/templates/local-service/app/sitemap.ts +9 -0
  182. package/templates/local-service/app.ts +131 -0
  183. package/templates/local-service/components/marketing.tsx +162 -0
  184. package/templates/local-service/components/section-scroller.tsx +35 -0
  185. package/templates/local-service/components/ui/button.tsx +56 -0
  186. package/templates/local-service/components/ui/card.tsx +90 -0
  187. package/templates/local-service/components.json +20 -0
  188. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  189. package/templates/local-service/functions/cancelBooking.ts +27 -0
  190. package/templates/local-service/functions/confirmBooking.ts +18 -0
  191. package/templates/local-service/functions/createBooking.ts +98 -0
  192. package/templates/local-service/gitignore +10 -0
  193. package/templates/local-service/lib/booking.ts +24 -0
  194. package/templates/local-service/lib/owner.ts +26 -0
  195. package/templates/local-service/lib/site.config.ts +232 -0
  196. package/templates/local-service/lib/slots.ts +97 -0
  197. package/templates/local-service/lib/utils.ts +10 -0
  198. package/templates/local-service/package.json +34 -0
  199. package/templates/local-service/tsconfig.json +18 -0
  200. package/templates/marketplace/.env.example +9 -0
  201. package/templates/marketplace/AGENTS.md +61 -0
  202. package/templates/marketplace/README.md +78 -0
  203. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  204. package/templates/marketplace/app/error.tsx +26 -0
  205. package/templates/marketplace/app/globals.css +64 -0
  206. package/templates/marketplace/app/layout.tsx +60 -0
  207. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  208. package/templates/marketplace/app/me/page.tsx +15 -0
  209. package/templates/marketplace/app/not-found.tsx +20 -0
  210. package/templates/marketplace/app/page.tsx +159 -0
  211. package/templates/marketplace/app/robots.ts +12 -0
  212. package/templates/marketplace/app/sell/page.tsx +26 -0
  213. package/templates/marketplace/app/sitemap.ts +14 -0
  214. package/templates/marketplace/app.ts +190 -0
  215. package/templates/marketplace/client/AuthNav.tsx +46 -0
  216. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  217. package/templates/marketplace/client/LoginCard.tsx +130 -0
  218. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  219. package/templates/marketplace/client/MyMarket.tsx +180 -0
  220. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  221. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  222. package/templates/marketplace/client/SellForm.tsx +160 -0
  223. package/templates/marketplace/client/WatchButton.tsx +88 -0
  224. package/templates/marketplace/client/market.ts +341 -0
  225. package/templates/marketplace/functions/buyNow.ts +78 -0
  226. package/templates/marketplace/functions/makeOffer.ts +65 -0
  227. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  228. package/templates/marketplace/functions/seedMarket.ts +90 -0
  229. package/templates/marketplace/gitignore +10 -0
  230. package/templates/marketplace/package.json +35 -0
  231. package/templates/marketplace/tsconfig.json +14 -0
  232. package/templates/marketplace/ui/badge.tsx +30 -0
  233. package/templates/marketplace/ui/button.tsx +49 -0
  234. package/templates/marketplace/ui/card.tsx +48 -0
  235. package/templates/marketplace/ui/input.tsx +17 -0
  236. package/templates/marketplace/ui/label.tsx +18 -0
  237. package/templates/marketplace/ui/textarea.tsx +17 -0
  238. package/templates/marketplace/ui/tokens.css +32 -0
  239. package/templates/marketplace/ui/utils.ts +6 -0
  240. package/templates/restaurant/.env.example +12 -0
  241. package/templates/restaurant/AGENTS.md +61 -0
  242. package/templates/restaurant/README.md +77 -0
  243. package/templates/restaurant/app/auth-form.tsx +129 -0
  244. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  245. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  246. package/templates/restaurant/app/error.tsx +26 -0
  247. package/templates/restaurant/app/globals.css +148 -0
  248. package/templates/restaurant/app/layout.tsx +151 -0
  249. package/templates/restaurant/app/login/page.tsx +39 -0
  250. package/templates/restaurant/app/not-found.tsx +19 -0
  251. package/templates/restaurant/app/page.tsx +194 -0
  252. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  253. package/templates/restaurant/app/robots.ts +12 -0
  254. package/templates/restaurant/app/sitemap.ts +9 -0
  255. package/templates/restaurant/app.ts +115 -0
  256. package/templates/restaurant/components/marketing.tsx +162 -0
  257. package/templates/restaurant/components/section-scroller.tsx +35 -0
  258. package/templates/restaurant/components/ui/button.tsx +56 -0
  259. package/templates/restaurant/components/ui/card.tsx +90 -0
  260. package/templates/restaurant/components.json +20 -0
  261. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  262. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  263. package/templates/restaurant/functions/createReservation.ts +92 -0
  264. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  265. package/templates/restaurant/gitignore +10 -0
  266. package/templates/restaurant/lib/owner.ts +26 -0
  267. package/templates/restaurant/lib/reservation.ts +22 -0
  268. package/templates/restaurant/lib/site.config.ts +218 -0
  269. package/templates/restaurant/lib/slots.ts +55 -0
  270. package/templates/restaurant/lib/utils.ts +10 -0
  271. package/templates/restaurant/package.json +34 -0
  272. package/templates/restaurant/tsconfig.json +18 -0
  273. package/templates/shop/.env.example +32 -0
  274. package/templates/shop/AGENTS.md +61 -0
  275. package/templates/shop/README.md +102 -0
  276. package/templates/shop/app/auth-form.tsx +129 -0
  277. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  278. package/templates/shop/app/dashboard/page.tsx +59 -0
  279. package/templates/shop/app/error.tsx +26 -0
  280. package/templates/shop/app/globals.css +148 -0
  281. package/templates/shop/app/layout.tsx +160 -0
  282. package/templates/shop/app/login/page.tsx +39 -0
  283. package/templates/shop/app/not-found.tsx +19 -0
  284. package/templates/shop/app/page.tsx +95 -0
  285. package/templates/shop/app/robots.ts +12 -0
  286. package/templates/shop/app/shop-client.tsx +436 -0
  287. package/templates/shop/app/sitemap.ts +9 -0
  288. package/templates/shop/app/success/page.tsx +33 -0
  289. package/templates/shop/app.ts +134 -0
  290. package/templates/shop/components/marketing.tsx +96 -0
  291. package/templates/shop/components/section-scroller.tsx +35 -0
  292. package/templates/shop/components/ui/button.tsx +56 -0
  293. package/templates/shop/components/ui/card.tsx +90 -0
  294. package/templates/shop/components.json +20 -0
  295. package/templates/shop/functions/cancelOrder.ts +33 -0
  296. package/templates/shop/functions/checkout.ts +130 -0
  297. package/templates/shop/functions/fulfillOrder.ts +17 -0
  298. package/templates/shop/functions/markGroupPaid.ts +26 -0
  299. package/templates/shop/functions/ordersForOwner.ts +28 -0
  300. package/templates/shop/functions/releaseGroup.ts +36 -0
  301. package/templates/shop/functions/reserveCart.ts +87 -0
  302. package/templates/shop/functions/restockProduct.ts +23 -0
  303. package/templates/shop/functions/seedProducts.ts +30 -0
  304. package/templates/shop/functions/stripeWebhook.ts +72 -0
  305. package/templates/shop/gitignore +10 -0
  306. package/templates/shop/lib/owner.ts +26 -0
  307. package/templates/shop/lib/shop.ts +45 -0
  308. package/templates/shop/lib/site.config.ts +198 -0
  309. package/templates/shop/lib/utils.ts +10 -0
  310. package/templates/shop/package.json +35 -0
  311. package/templates/shop/tsconfig.json +18 -0
  312. package/templates/waitlist/.env.example +12 -0
  313. package/templates/waitlist/AGENTS.md +61 -0
  314. package/templates/waitlist/README.md +81 -0
  315. package/templates/waitlist/app/auth-form.tsx +129 -0
  316. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  317. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  318. package/templates/waitlist/app/error.tsx +26 -0
  319. package/templates/waitlist/app/globals.css +148 -0
  320. package/templates/waitlist/app/layout.tsx +158 -0
  321. package/templates/waitlist/app/login/page.tsx +39 -0
  322. package/templates/waitlist/app/not-found.tsx +19 -0
  323. package/templates/waitlist/app/page.tsx +119 -0
  324. package/templates/waitlist/app/robots.ts +12 -0
  325. package/templates/waitlist/app/sitemap.ts +9 -0
  326. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  327. package/templates/waitlist/app.ts +134 -0
  328. package/templates/waitlist/components/marketing.tsx +96 -0
  329. package/templates/waitlist/components/ui/button.tsx +56 -0
  330. package/templates/waitlist/components/ui/card.tsx +90 -0
  331. package/templates/waitlist/components.json +20 -0
  332. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  333. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  334. package/templates/waitlist/gitignore +10 -0
  335. package/templates/waitlist/lib/owner.ts +26 -0
  336. package/templates/waitlist/lib/site.config.ts +178 -0
  337. package/templates/waitlist/lib/stats.ts +30 -0
  338. package/templates/waitlist/lib/utils.ts +10 -0
  339. package/templates/waitlist/package.json +34 -0
  340. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,134 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ } from "@pylonsync/sdk";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // newsletter — a pre-launch / coming-soon landing page with a LIVE subscriber
12
+ // counter. The whole point is the realtime hook: open the page in two tabs,
13
+ // submit an email in one, and the counter on the other ticks up with no
14
+ // refresh. That's the proof it's a real live app and not a static page.
15
+ //
16
+ // The data model is deliberately tiny — two entities:
17
+ // • Subscriber — one row per email. Holds visitor PII, so it denies ALL client
18
+ // reads/writes (writes go through the subscribe mutation; the
19
+ // public page only ever sees an aggregate count, never an email).
20
+ // • User — the business owner's account (email/password is built in), so
21
+ // the owner can sign in to the dashboard and see their subscribers.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ // One newsletter subscriber. `email` is the only PII; `createdAt` powers the
25
+ // subscribers-over-time chart on the dashboard. The unique index on email dedupes
26
+ // at the database level — a duplicate insert is rejected even under a race, so
27
+ // subscribe can treat the conflict as "already joined".
28
+ const Subscriber = entity(
29
+ "Subscriber",
30
+ {
31
+ email: field.string(),
32
+ createdAt: field.datetime().defaultNow(),
33
+ },
34
+ {
35
+ indexes: [
36
+ { name: "by_email", fields: ["email"], unique: true },
37
+ { name: "by_created", fields: ["createdAt"], unique: false },
38
+ ],
39
+ },
40
+ );
41
+
42
+ // A single-row, PII-FREE aggregate the public page can safely read live. It
43
+ // holds only the subscriber count — no emails. `subscribe` keeps it in sync with
44
+ // the real Subscriber count on every new join. The landing page subscribes with
45
+ // `db.useQuery("SubscriberCount")`, which syncs across every open tab through the
46
+ // replica — so the counter ticks up everywhere the instant someone joins. This
47
+ // is the cross-tab-safe realtime primitive (entity sync), not a per-connection
48
+ // server-query subscription.
49
+ const SubscriberCount = entity(
50
+ "SubscriberCount",
51
+ {
52
+ count: field.int().default(0),
53
+ updatedAt: field.datetime().defaultNow(),
54
+ },
55
+ {},
56
+ );
57
+
58
+ // The business owner's account. Email/password auth is built in against an
59
+ // entity named "User" (passwordHash is server-only; the register route stamps
60
+ // avatarColor). The dashboard is gated to the owner — see PYLON_OWNER_EMAIL in
61
+ // functions/subscriberStats.ts and app/dashboard/page.tsx.
62
+ const User = entity(
63
+ "User",
64
+ {
65
+ email: field.string(),
66
+ displayName: field.string().optional(),
67
+ passwordHash: field.string().serverOnly().optional(),
68
+ avatarColor: field.string().optional(),
69
+ // Set when the owner verifies their email; unused by the newsletter flow but
70
+ // declared so the framework's email-verification routes have a column.
71
+ emailVerified: field.datetime().optional(),
72
+ createdAt: field.datetime().defaultNow(),
73
+ },
74
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
75
+ );
76
+
77
+ // PRIVACY — the heart of the spec. Subscriber holds visitor emails, so it denies
78
+ // EVERY client read and write. No `db.useQuery("Subscriber")` can ever pull a row,
79
+ // and no client can insert/update/delete directly. Writes happen only inside
80
+ // the server-side `subscribe` mutation (functions bypass policies); reads
81
+ // happen only inside `newsletterCount` (returns a bare integer) and the
82
+ // owner-gated `subscriberStats`. A marketing site must never leak its own
83
+ // customers' emails — this policy is what guarantees it.
84
+ const subscriberPolicy = policy({
85
+ name: "subscriber_private",
86
+ entity: "Subscriber",
87
+ allowRead: "false",
88
+ allowInsert: "false",
89
+ allowUpdate: "false",
90
+ allowDelete: "false",
91
+ });
92
+
93
+ // The aggregate count is public to READ (it's just a number — the whole point
94
+ // is that the landing page shows it live to everyone). Clients can't WRITE it;
95
+ // only the subscribe mutation maintains it server-side.
96
+ const subscriberCountPolicy = policy({
97
+ name: "subscriber_count_public_read",
98
+ entity: "SubscriberCount",
99
+ allowRead: "true",
100
+ allowInsert: "false",
101
+ allowUpdate: "false",
102
+ allowDelete: "false",
103
+ });
104
+
105
+ // The owner reads their own User row (the dashboard resolves their email this
106
+ // way to check ownership). The auth subsystem owns all writes.
107
+ const userPolicy = policy({
108
+ name: "user_self",
109
+ entity: "User",
110
+ allowRead: "auth.userId == data.id",
111
+ allowInsert: "false",
112
+ allowUpdate: "false",
113
+ allowDelete: "false",
114
+ });
115
+
116
+ const manifest = buildManifest({
117
+ name: "__APP_NAME__",
118
+ version: "0.1.0",
119
+ entities: [Subscriber, SubscriberCount, User],
120
+ // subscribe (public mutation) + subscriberStats (owner-gated query) live in
121
+ // functions/ and are discovered automatically — they don't need listing here.
122
+ queries: [],
123
+ actions: [],
124
+ policies: [subscriberPolicy, subscriberCountPolicy, userPolicy],
125
+ // Email/password is on by default against the User entity above. No orgs,
126
+ // no billing — a newsletter is single-tenant (one business, one owner).
127
+ auth: auth(),
128
+ routes: await discoverAppRoutes(),
129
+ });
130
+
131
+ // Emit the canonical manifest JSON to stdout — `pylon dev` captures this.
132
+ console.log(JSON.stringify(manifest, null, 2));
133
+
134
+ export default manifest;
@@ -0,0 +1,148 @@
1
+ import React from "react";
2
+
3
+ // Reusable presentational pieces for the landing page. All server-rendered —
4
+ // no client JS. Restyle here and the whole page follows. The brand accent
5
+ // (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
6
+ // app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
7
+
8
+ // Shared container: a contained, centered column.
9
+ export const WRAP = "mx-auto w-full max-w-3xl px-6";
10
+
11
+ export function Eyebrow({ children }: { children: React.ReactNode }) {
12
+ return (
13
+ <p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
14
+ {children}
15
+ </p>
16
+ );
17
+ }
18
+
19
+ // "New / Coming soon"-style pill for the hero.
20
+ export function Badge({ children }: { children: React.ReactNode }) {
21
+ return (
22
+ <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
23
+ <span className="inline-block size-1.5 rounded-full bg-brand" />
24
+ {children}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ export function Divider() {
30
+ return (
31
+ <div className={WRAP}>
32
+ <div className="border-t border-zinc-200/70" />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ export function SectionHead({
38
+ eyebrow,
39
+ title,
40
+ body,
41
+ }: {
42
+ eyebrow: string;
43
+ title: string;
44
+ body?: string;
45
+ }) {
46
+ return (
47
+ <div>
48
+ <Eyebrow>{eyebrow}</Eyebrow>
49
+ <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
50
+ {title}
51
+ </h2>
52
+ {body ? (
53
+ <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
54
+ {body}
55
+ </p>
56
+ ) : null}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ // A grid of value props — icon + title + body.
62
+ export function FeatureGrid({
63
+ items,
64
+ }: {
65
+ items: { title: string; body: string; icon?: string }[];
66
+ }) {
67
+ return (
68
+ <div className="grid gap-6 sm:grid-cols-3">
69
+ {items.map((f) => (
70
+ <div key={f.title}>
71
+ {f.icon ? (
72
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
73
+ {f.icon}
74
+ </span>
75
+ ) : null}
76
+ <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
77
+ {f.title}
78
+ </h3>
79
+ <p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
80
+ {f.body}
81
+ </p>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ // Initials for testimonial avatars, so the cards look finished without a photo.
89
+ export function initials(name: string) {
90
+ return name
91
+ .split(/\s+/)
92
+ .map((w) => w[0])
93
+ .join("")
94
+ .slice(0, 2)
95
+ .toUpperCase();
96
+ }
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.
102
+ //
103
+ // shape — "landscape" | "portrait" | "square" | "circle"
104
+ // title — what photo belongs here ("Your headshot")
105
+ // hint — how to replace it ("Replace in app/page.tsx")
106
+ export function ImagePlaceholder({
107
+ shape = "landscape",
108
+ title,
109
+ hint,
110
+ className = "",
111
+ }: {
112
+ shape?: "landscape" | "portrait" | "square" | "circle";
113
+ title: string;
114
+ hint?: string;
115
+ className?: string;
116
+ }) {
117
+ const aspect =
118
+ shape === "portrait"
119
+ ? "aspect-[4/5]"
120
+ : shape === "square" || shape === "circle"
121
+ ? "aspect-square"
122
+ : "aspect-[4/3]";
123
+ const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
124
+ return (
125
+ <div
126
+ className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
127
+ >
128
+ <div className="px-4 text-center">
129
+ <svg
130
+ className="mx-auto size-7 text-zinc-300"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="1.5"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ aria-hidden
138
+ >
139
+ <rect x="3" y="3" width="18" height="18" rx="2" />
140
+ <circle cx="9" cy="9" r="1.6" />
141
+ <path d="m21 15-4.5-4.5L7 20" />
142
+ </svg>
143
+ <p className="mt-2 text-[12.5px] font-medium text-zinc-500">{title}</p>
144
+ {hint ? <p className="mt-1 text-[11px] leading-snug text-zinc-400">{hint}</p> : null}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ // Makes in-page section links work. A hydrated Pylon page updates the URL for a
6
+ // plain `<a href="#section">` click but doesn't perform the browser's native
7
+ // fragment scroll, so the page jumps nowhere. This installs ONE delegated click
8
+ // handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
9
+ //
10
+ // Render it once (in the root layout). Renders nothing. Real route links should
11
+ // still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
12
+ export function SectionScroller() {
13
+ useEffect(() => {
14
+ function onClick(e: MouseEvent) {
15
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
16
+ return;
17
+ }
18
+ const target = e.target as Element | null;
19
+ const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
20
+ if (!link) return;
21
+ const href = link.getAttribute("href") || "";
22
+ const id = href.slice(href.indexOf("#") + 1);
23
+ if (!id) return;
24
+ const el = document.getElementById(id);
25
+ if (!el) return; // target not on this page — leave it to the browser
26
+ e.preventDefault();
27
+ el.scrollIntoView({ block: "start" });
28
+ history.replaceState(null, "", "#" + id);
29
+ }
30
+ document.addEventListener("click", onClick);
31
+ return () => document.removeEventListener("click", onClick);
32
+ }, []);
33
+
34
+ return null;
35
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-10 rounded-md px-8",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ },
34
+ );
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean;
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button";
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ );
52
+ },
53
+ );
54
+ Button.displayName = "Button";
55
+
56
+ export { Button, buttonVariants };
@@ -0,0 +1,90 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "rounded-xl border bg-card text-card-foreground shadow-sm",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({
19
+ className,
20
+ ...props
21
+ }: React.HTMLAttributes<HTMLDivElement>) {
22
+ return (
23
+ <div
24
+ data-slot="card-header"
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({
32
+ className,
33
+ ...props
34
+ }: React.HTMLAttributes<HTMLDivElement>) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function CardDescription({
45
+ className,
46
+ ...props
47
+ }: React.HTMLAttributes<HTMLDivElement>) {
48
+ return (
49
+ <div
50
+ data-slot="card-description"
51
+ className={cn("text-sm text-muted-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function CardContent({
58
+ className,
59
+ ...props
60
+ }: React.HTMLAttributes<HTMLDivElement>) {
61
+ return (
62
+ <div
63
+ data-slot="card-content"
64
+ className={cn("p-6 pt-0", className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ function CardFooter({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) {
74
+ return (
75
+ <div
76
+ data-slot="card-footer"
77
+ className={cn("flex items-center p-6 pt-0", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export {
84
+ Card,
85
+ CardHeader,
86
+ CardFooter,
87
+ CardTitle,
88
+ CardDescription,
89
+ CardContent,
90
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true
11
+ },
12
+ "aliases": {
13
+ "components": "@/components",
14
+ "utils": "@/lib/utils",
15
+ "ui": "@/components/ui",
16
+ "lib": "@/lib",
17
+ "hooks": "@/hooks"
18
+ },
19
+ "iconLibrary": "lucide"
20
+ }
@@ -0,0 +1,82 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ // Normalize + sanity-check an email without pulling in a dependency. This is a
4
+ // pragmatic "looks like an email" check, not RFC 5322 — the unique index is the
5
+ // real integrity guard. We cap the length so a hostile caller can't stuff a
6
+ // megabyte string into the row.
7
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8
+ function normalizeEmail(raw: string): string | null {
9
+ const email = raw.trim().toLowerCase();
10
+ if (email.length < 3 || email.length > 254) return null;
11
+ if (!EMAIL_RE.test(email)) return null;
12
+ return email;
13
+ }
14
+
15
+ // subscribe — the ONLY way a Subscriber row is ever written. It's a `mutation`
16
+ // (not an `action`): mutations get `ctx.db` access and run as one atomic
17
+ // transaction. It also bumps the public SubscriberCount row, which the landing
18
+ // page reads via `db.useQuery` — so the counter ticks up on every open tab in
19
+ // realtime.
20
+ //
21
+ // `auth: "public"` because a landing-page visitor has no account. Public
22
+ // mutations are still rate-limited at the HTTP layer in production (the
23
+ // framework's rate_limit plugin); here we also validate + dedupe so the same
24
+ // email can't inflate the count.
25
+ //
26
+ // PRIVACY: this returns only `{ ok, alreadyJoined }`. It never returns a Subscriber
27
+ // row, an id, or anyone else's email.
28
+ export default mutation<{ email: string }, { ok: boolean; alreadyJoined: boolean }>({
29
+ auth: "public",
30
+ args: { email: v.string() },
31
+ async handler(ctx, args) {
32
+ const email = normalizeEmail(args.email);
33
+ if (!email) {
34
+ throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
35
+ }
36
+
37
+ // Subscriber denies ALL client access by policy, so these go through
38
+ // `ctx.db.unsafe` — the explicit "this is an intentional cross-user write
39
+ // from a trusted handler" surface (also future-proofs against
40
+ // PYLON_STRICT_FN_POLICIES). The handler IS the only writer.
41
+ //
42
+ // Dedupe: if this email already joined, report success idempotently rather
43
+ // than erroring — re-submitting the same address is a no-op, not a failure.
44
+ const existing = await ctx.db.unsafe.lookup("Subscriber", "email", email);
45
+ if (existing) {
46
+ return { ok: true, alreadyJoined: true };
47
+ }
48
+
49
+ try {
50
+ await ctx.db.unsafe.insert("Subscriber", {
51
+ email,
52
+ createdAt: new Date().toISOString(),
53
+ });
54
+ } catch (e) {
55
+ // Lost a race to a concurrent insert of the same email — the unique index
56
+ // on `email` rejected the duplicate. That's still "you're on the list".
57
+ const msg = e instanceof Error ? e.message : String(e);
58
+ if (/unique|constraint|conflict|duplicate/i.test(msg)) {
59
+ return { ok: true, alreadyJoined: true };
60
+ }
61
+ throw e;
62
+ }
63
+
64
+ // Keep the public, PII-free SubscriberCount singleton in sync with the real
65
+ // count. We RECOUNT (rather than +1) so the number can never drift, and
66
+ // this whole handler is one transaction — on SQLite writers serialize, so
67
+ // the recount-then-write is consistent. The landing page reads this row via
68
+ // `db.useQuery`, which syncs the new value to every open tab.
69
+ const total = (await ctx.db.unsafe.list("Subscriber")).length;
70
+ const stat = (await ctx.db.unsafe.list("SubscriberCount"))[0] as
71
+ | { id: string }
72
+ | undefined;
73
+ const now = new Date().toISOString();
74
+ if (stat) {
75
+ await ctx.db.unsafe.update("SubscriberCount", stat.id, { count: total, updatedAt: now });
76
+ } else {
77
+ await ctx.db.unsafe.insert("SubscriberCount", { count: total, updatedAt: now });
78
+ }
79
+
80
+ return { ok: true, alreadyJoined: false };
81
+ },
82
+ });
@@ -0,0 +1,75 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { SubscriberRow, SubscriberStatsResult } from "../lib/stats";
4
+
5
+ // subscriberStats — the owner's view of the raw subscribers, INCLUDING emails. This
6
+ // is the one function allowed to return PII, so it's gated hard: only the
7
+ // configured owner (PYLON_OWNER_EMAIL) gets data; anyone else is denied.
8
+ //
9
+ // The dashboard calls this with `callFn` and re-fetches whenever the live,
10
+ // public SubscriberCount count ticks — so the total / chart / list stay current as
11
+ // people join, while the emails themselves never travel over entity sync.
12
+ //
13
+ // `auth: "user"` means an anonymous caller never reaches the handler; the
14
+ // extra owner check is what stops a *different* signed-in user from reading the
15
+ // list. Functions bypass the Subscriber read policy, which is exactly why the gate
16
+ // has to live here.
17
+ function ymd(iso: string): string {
18
+ // Bucket by calendar day in UTC. `toISOString()` is always YYYY-MM-DDT…Z.
19
+ return new Date(iso).toISOString().slice(0, 10);
20
+ }
21
+
22
+ export default query({
23
+ auth: "user",
24
+ async handler(ctx): Promise<SubscriberStatsResult> {
25
+ // AuthInfo carries the userId, not the email — resolve the caller's email
26
+ // from their own User row (the User self-read policy allows that), then
27
+ // check it against the configured owner. A non-owner gets `authorized:
28
+ // false` and NOTHING else — no count, no emails. (We return a flag instead
29
+ // of throwing: a query has no `ctx.error`, and a bare throw reaches the
30
+ // client as a generic, message-stripped HANDLER_ERROR.)
31
+ const me = await ctx.db.get("User", ctx.auth.userId);
32
+ const email = (me?.email as string | undefined) ?? null;
33
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
34
+ return { authorized: false };
35
+ }
36
+
37
+ // Subscriber denies all client reads; this returns every row (owner-only), so
38
+ // it goes through the intentional cross-user read surface.
39
+ const rows = (await ctx.db.unsafe.list("Subscriber")) as unknown as SubscriberRow[];
40
+
41
+ const subscribers = rows
42
+ .map((r) => ({ id: r.id, email: r.email, createdAt: r.createdAt }))
43
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
44
+
45
+ // Daily buckets for the last 30 days, zero-filled so the chart is
46
+ // continuous even on days with no subscribers.
47
+ const byDay = new Map<string, number>();
48
+ for (const r of rows) {
49
+ const key = ymd(r.createdAt);
50
+ byDay.set(key, (byDay.get(key) ?? 0) + 1);
51
+ }
52
+ const now = new Date();
53
+ const todayKey = now.toISOString().slice(0, 10);
54
+ const daily: { date: string; count: number }[] = [];
55
+ for (let i = 29; i >= 0; i--) {
56
+ const d = new Date(now);
57
+ d.setUTCDate(d.getUTCDate() - i);
58
+ const key = d.toISOString().slice(0, 10);
59
+ daily.push({ date: key, count: byDay.get(key) ?? 0 });
60
+ }
61
+
62
+ const sevenDaysAgo = new Date(now);
63
+ sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7);
64
+ const last7 = rows.filter((r) => new Date(r.createdAt) >= sevenDaysAgo).length;
65
+
66
+ return {
67
+ authorized: true,
68
+ total: rows.length,
69
+ today: byDay.get(todayKey) ?? 0,
70
+ last7,
71
+ daily,
72
+ subscribers,
73
+ };
74
+ },
75
+ });
@@ -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 newsletter? A newsletter 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 `subscriberStats`
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 subscribers". 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
+ }