@pylonsync/create-pylon 0.3.328 → 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
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"
|
|
@@ -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
|
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|