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