@pylonsync/create-pylon 0.3.327 → 0.3.329
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/default/AGENTS.md +1 -1
- package/templates/default/app/dashboard/dashboard-client.tsx +19 -9
- package/templates/default/app/layout.tsx +1 -10
- package/templates/default/functions/updateProject.ts +53 -0
- package/templates/default/lib/projects.ts +14 -0
- package/templates/default/lib/site.config.ts +2 -2
- package/templates/default/tests/projects.test.ts +18 -0
- package/templates/expo/chat/apps/expo/App.tsx +9 -1
- package/templates/expo/chat/apps/expo/package.json +2 -1
- package/templates/expo/consumer/apps/expo/App.tsx +9 -1
- package/templates/expo/consumer/apps/expo/package.json +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.329",
|
|
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"
|
|
@@ -22,7 +22,7 @@ Operating rules for a coding agent in this Pylon app. You — the agent — are
|
|
|
22
22
|
|
|
23
23
|
## Key gotchas
|
|
24
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
|
|
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 inside the handler with `ctx.auth` checks, or `ctx.requireMember(orgId, { role: ["owner", "admin"] })` for org membership / role gating (throws `FORBIDDEN` for non-members; available on query, mutation, and action). Don't hand-roll a members lookup — the primitive fails closed.
|
|
26
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
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
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`.
|
|
@@ -445,9 +445,10 @@ function IconBtn({
|
|
|
445
445
|
);
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
// One project card.
|
|
449
|
-
//
|
|
450
|
-
//
|
|
448
|
+
// One project card. Archive + delete are optimistic client `db` writes (gated
|
|
449
|
+
// by the tenant policy) that sync across tabs; editing details saves through the
|
|
450
|
+
// updateProject server function. Editing swaps the card for an inline name +
|
|
451
|
+
// description form.
|
|
451
452
|
function ProjectCard({ p }: { p: Project }) {
|
|
452
453
|
const archived = (p.status ?? "active") === "archived";
|
|
453
454
|
const [editing, setEditing] = useState(false);
|
|
@@ -459,7 +460,12 @@ function ProjectCard({ p }: { p: Project }) {
|
|
|
459
460
|
const n = name.trim();
|
|
460
461
|
if (!n) return;
|
|
461
462
|
setEditing(false);
|
|
462
|
-
|
|
463
|
+
// Saving details goes through the updateProject server function (server-side
|
|
464
|
+
// validation + a workspace-membership re-check) rather than a bare
|
|
465
|
+
// db.update — see functions/updateProject.ts. The reactive `db` still
|
|
466
|
+
// re-renders this card the moment the write lands.
|
|
467
|
+
await callFn("updateProject", {
|
|
468
|
+
projectId: p.id,
|
|
463
469
|
name: n,
|
|
464
470
|
description: desc.trim() || undefined,
|
|
465
471
|
});
|
|
@@ -1049,12 +1055,14 @@ function BillingView({
|
|
|
1049
1055
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1050
1056
|
// A missing STRIPE_PRICE_PRO surfaces as "plan pro has no monthly
|
|
1051
1057
|
// priceId" rather than STRIPE_NOT_CONFIGURED, so treat the price/config
|
|
1052
|
-
// errors the same — both mean
|
|
1058
|
+
// errors the same — both mean the Stripe setup isn't finished (set
|
|
1059
|
+
// STRIPE_SECRET_KEY + STRIPE_PRICE_PRO). That guidance is for you, the
|
|
1060
|
+
// developer; the customer just sees the generic "contact support" line.
|
|
1053
1061
|
setError(
|
|
1054
1062
|
/not.?configured|STRIPE_NOT_CONFIGURED|no monthly price|priceId/i.test(
|
|
1055
1063
|
msg,
|
|
1056
1064
|
)
|
|
1057
|
-
? "
|
|
1065
|
+
? "Billing isn't available yet. Contact support and we'll get you set up."
|
|
1058
1066
|
: msg,
|
|
1059
1067
|
);
|
|
1060
1068
|
setBusy(null);
|
|
@@ -1174,10 +1182,12 @@ function BillingView({
|
|
|
1174
1182
|
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
|
|
1175
1183
|
</Card>
|
|
1176
1184
|
|
|
1185
|
+
{/* Dev setup (not shown to customers): set STRIPE_SECRET_KEY,
|
|
1186
|
+
STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_PRO to go live; point the
|
|
1187
|
+
Stripe webhook at /api/fn/stripeWebhook. Billing is wired through
|
|
1188
|
+
@pylonsync/stripe — see lib/billing.ts. */}
|
|
1177
1189
|
<p className="text-xs text-zinc-400">
|
|
1178
|
-
|
|
1179
|
-
plugin. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_PRO
|
|
1180
|
-
to go live; point the webhook at <code>/api/fn/stripeWebhook</code>.
|
|
1190
|
+
Payments and invoices are processed securely by Stripe.
|
|
1181
1191
|
</p>
|
|
1182
1192
|
</div>
|
|
1183
1193
|
);
|
|
@@ -439,17 +439,8 @@ function SiteFooter() {
|
|
|
439
439
|
</div>
|
|
440
440
|
</div>
|
|
441
441
|
|
|
442
|
-
<div className="mt-14
|
|
442
|
+
<div className="mt-14 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400">
|
|
443
443
|
<span>© {new Date().getFullYear()} {siteConfig.brand.copyrightName}</span>
|
|
444
|
-
<span>
|
|
445
|
-
Built with{" "}
|
|
446
|
-
<a
|
|
447
|
-
href="https://pylonsync.com"
|
|
448
|
-
className="font-medium text-zinc-600 hover:text-zinc-900"
|
|
449
|
-
>
|
|
450
|
-
Pylon
|
|
451
|
-
</a>
|
|
452
|
-
</span>
|
|
453
444
|
</div>
|
|
454
445
|
</div>
|
|
455
446
|
</footer>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { normalizeProjectName } from "../lib/projects";
|
|
3
|
+
|
|
4
|
+
// updateProject — the reference example for a first-party server function, and
|
|
5
|
+
// the core authoring loop end to end: entity → policy → FUNCTION → call it from
|
|
6
|
+
// the client. The Projects tab's edit form (app/dashboard/dashboard-client.tsx)
|
|
7
|
+
// saves through this via `callFn("updateProject", …)`.
|
|
8
|
+
//
|
|
9
|
+
// Why a server function and not a direct client `db.update`? Two reasons the
|
|
10
|
+
// pattern exists:
|
|
11
|
+
// 1. Server-side validation the client can't be trusted to enforce — the name
|
|
12
|
+
// bounds run on the server no matter what the browser sends.
|
|
13
|
+
// 2. Authorization beyond "owns the row". Functions BYPASS entity policies and
|
|
14
|
+
// run with full DB access, so a handler that trusted a caller-supplied
|
|
15
|
+
// `projectId` would be an IDOR. `ctx.requireMember` re-checks that the
|
|
16
|
+
// caller belongs to THAT project's workspace, failing CLOSED (throws
|
|
17
|
+
// FORBIDDEN otherwise). Pass `{ role: ["owner", "admin"] }` to make edits
|
|
18
|
+
// admin-only.
|
|
19
|
+
//
|
|
20
|
+
// Quick archive/delete stay as direct client `db` writes — the Project row
|
|
21
|
+
// policy (`auth.tenantId == data.orgId`) already covers "edit a row you own".
|
|
22
|
+
// Reach for a function when you need more than that.
|
|
23
|
+
export default mutation<
|
|
24
|
+
{ projectId: string; name: string; description?: string },
|
|
25
|
+
{ id: string; name: string }
|
|
26
|
+
>({
|
|
27
|
+
// `auth` defaults to "user" (secure-by-default) — requireMember does the rest.
|
|
28
|
+
args: {
|
|
29
|
+
projectId: v.string(),
|
|
30
|
+
name: v.string(),
|
|
31
|
+
description: v.optional(v.string()),
|
|
32
|
+
},
|
|
33
|
+
async handler(ctx, args) {
|
|
34
|
+
const name = normalizeProjectName(args.name);
|
|
35
|
+
if (!name) {
|
|
36
|
+
throw ctx.error("INVALID_ARGS", "Project name must be 1–80 characters.");
|
|
37
|
+
}
|
|
38
|
+
const description = args.description?.trim() || undefined;
|
|
39
|
+
|
|
40
|
+
// Load the target row, then authorize against ITS OWN workspace — never a
|
|
41
|
+
// caller-supplied org id.
|
|
42
|
+
const project = await ctx.db.get("Project", args.projectId);
|
|
43
|
+
if (!project) {
|
|
44
|
+
throw ctx.error("NOT_FOUND", "Project not found.");
|
|
45
|
+
}
|
|
46
|
+
await ctx.requireMember(project.orgId as string);
|
|
47
|
+
|
|
48
|
+
// Authorized above, so write through the explicit trusted-handler surface
|
|
49
|
+
// (also correct under PYLON_STRICT_FN_POLICIES).
|
|
50
|
+
await ctx.db.unsafe.update("Project", args.projectId, { name, description });
|
|
51
|
+
return { id: args.projectId, name };
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pure project helpers. AGENTS.md's testing guidance: keep the decision logic
|
|
2
|
+
// out of the handler and in a pure function here, so it's exhaustively testable
|
|
3
|
+
// without a running server (see tests/projects.test.ts). functions/updateProject.ts
|
|
4
|
+
// is a thin wrapper around this.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Trim + validate a project name. Returns the cleaned name, or null when it's
|
|
8
|
+
* empty (or whitespace-only) or longer than 80 characters after trimming.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeProjectName(raw: string): string | null {
|
|
11
|
+
const name = raw.trim();
|
|
12
|
+
if (name.length < 1 || name.length > 80) return null;
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
@@ -124,12 +124,12 @@ export const siteConfig: SiteConfig = {
|
|
|
124
124
|
socials: [
|
|
125
125
|
{
|
|
126
126
|
label: "X",
|
|
127
|
-
href: "https://x.com/
|
|
127
|
+
href: "https://x.com/acme",
|
|
128
128
|
path: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
label: "GitHub",
|
|
132
|
-
href: "https://github.com/
|
|
132
|
+
href: "https://github.com/acme",
|
|
133
133
|
path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
|
|
134
134
|
},
|
|
135
135
|
],
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { normalizeProjectName } from "../lib/projects";
|
|
3
|
+
|
|
4
|
+
// Tier 1 (pure logic) — the validation functions/updateProject.ts enforces
|
|
5
|
+
// server-side, tested without a running app. Keep decision logic like this in
|
|
6
|
+
// lib/ and the handler a thin wrapper, so it's covered here.
|
|
7
|
+
|
|
8
|
+
test("normalizeProjectName trims and accepts 1–80 chars", () => {
|
|
9
|
+
expect(normalizeProjectName(" Launch ")).toBe("Launch");
|
|
10
|
+
expect(normalizeProjectName("x")).toBe("x");
|
|
11
|
+
expect(normalizeProjectName("a".repeat(80))).toBe("a".repeat(80));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("normalizeProjectName rejects empty, whitespace-only, and too-long names", () => {
|
|
15
|
+
expect(normalizeProjectName("")).toBeNull();
|
|
16
|
+
expect(normalizeProjectName(" ")).toBeNull();
|
|
17
|
+
expect(normalizeProjectName("a".repeat(81))).toBeNull();
|
|
18
|
+
});
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
Platform,
|
|
11
11
|
Alert,
|
|
12
12
|
KeyboardAvoidingView,
|
|
13
|
-
SafeAreaView,
|
|
14
13
|
} from "react-native";
|
|
14
|
+
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
|
15
15
|
import { StatusBar } from "expo-status-bar";
|
|
16
16
|
import { init, db, callFn } from "@pylonsync/react-native";
|
|
17
17
|
|
|
@@ -47,6 +47,14 @@ function ensureInit() {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export default function App() {
|
|
50
|
+
return (
|
|
51
|
+
<SafeAreaProvider>
|
|
52
|
+
<AppContent />
|
|
53
|
+
</SafeAreaProvider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function AppContent() {
|
|
50
58
|
const [ready, setReady] = useState(false);
|
|
51
59
|
useEffect(() => {
|
|
52
60
|
ensureInit().then(() => setReady(true));
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
StyleSheet,
|
|
10
10
|
Platform,
|
|
11
11
|
Alert,
|
|
12
|
-
SafeAreaView,
|
|
13
12
|
} from "react-native";
|
|
13
|
+
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
|
14
14
|
import { StatusBar } from "expo-status-bar";
|
|
15
15
|
import { init, db, callFn } from "@pylonsync/react-native";
|
|
16
16
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
@@ -56,6 +56,14 @@ function ensureInit() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export default function App() {
|
|
59
|
+
return (
|
|
60
|
+
<SafeAreaProvider>
|
|
61
|
+
<AppContent />
|
|
62
|
+
</SafeAreaProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function AppContent() {
|
|
59
67
|
const [ready, setReady] = useState(false);
|
|
60
68
|
const [profileId, setProfileId] = useState<string | null>(null);
|
|
61
69
|
useEffect(() => {
|