@pylonsync/create-pylon 0.3.158 → 0.3.161

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.158",
3
+ "version": "0.3.161",
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"
@@ -27,23 +27,29 @@ import {
27
27
  const Org = entity("Org", {
28
28
  slug: field.string(),
29
29
  name: field.string(),
30
- ownerId: field.id("User"),
31
- createdAt: field.datetime(),
30
+ // `.readonly()` blocks HTTP PATCH from rewriting ownership — closes
31
+ // the IDOR-via-update-payload shape where an attacker would
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.
36
+ ownerId: field.id("User").readonly(),
37
+ createdAt: field.datetime().readonly(),
32
38
  });
33
39
 
34
40
  const Membership = entity("Membership", {
35
- orgId: field.id("Org"),
36
- userId: field.id("User"),
41
+ orgId: field.id("Org").readonly(),
42
+ userId: field.id("User").readonly(),
37
43
  role: field.enum_(["owner", "admin", "member"]),
38
- createdAt: field.datetime(),
44
+ createdAt: field.datetime().readonly(),
39
45
  });
40
46
 
41
47
  const Project = entity("Project", {
42
- orgId: field.id("Org"),
48
+ orgId: field.id("Org").readonly(),
43
49
  name: field.string(),
44
- createdBy: field.id("User"),
50
+ createdBy: field.id("User").readonly(),
45
51
  archived: field.bool(),
46
- createdAt: field.datetime(),
52
+ createdAt: field.datetime().readonly(),
47
53
  });
48
54
 
49
55
  // ---------------------------------------------------------------------------
@@ -108,11 +114,18 @@ const archiveProject = action("archiveProject", {
108
114
  const orgPolicy = policy({
109
115
  name: "org_membership",
110
116
  entity: "Org",
111
- // Anyone can read an org if they're a member; only the owner can update.
117
+ // 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 row's value) rather than `data.ownerId` (the proposed
120
+ // payload) — without this pin, an attacker could PATCH with
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.
112
125
  allowRead: "exists(Membership where orgId = data.id and userId = auth.userId)",
113
126
  allowInsert: "auth.userId == data.ownerId",
114
- allowUpdate: "data.ownerId == auth.userId",
115
- allowDelete: "data.ownerId == auth.userId",
127
+ allowUpdate: "existing.ownerId == auth.userId",
128
+ allowDelete: "existing.ownerId == auth.userId",
116
129
  });
117
130
 
118
131
  const membershipPolicy = policy({
@@ -120,31 +133,40 @@ const membershipPolicy = policy({
120
133
  entity: "Membership",
121
134
  // You can see your own memberships, plus all memberships in any org
122
135
  // where you're an owner/admin (so the admin UI can list everyone).
136
+ // Update/delete pin `existing.orgId` so an attacker can't move a
137
+ // membership to another org by PATCHing the payload. The entity
138
+ // also marks `orgId` + `userId` as `.readonly()` so HTTP PATCH
139
+ // rejects those fields outright — server actions like
140
+ // `setMemberRole` write only `role`.
123
141
  allowRead:
124
142
  "data.userId == auth.userId or exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
125
143
  allowInsert:
126
144
  "exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
127
145
  allowUpdate:
128
- "exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
146
+ "exists(Membership where orgId = existing.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
129
147
  allowDelete:
130
- "exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
148
+ "exists(Membership where orgId = existing.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
131
149
  });
132
150
 
133
151
  const projectPolicy = policy({
134
152
  name: "project_org_scope",
135
153
  entity: "Project",
136
154
  // Tenant scope: you can only touch a project if you're a member of
137
- // its org. Regardless of which `orgId` the client claims, the policy
138
- // pulls the row's actual orgId and checks membership.
155
+ // its org. `existing.orgId` on update/delete pins the row's
156
+ // current org without it, an attacker could PATCH with
157
+ // `{orgId: <my_org>}` to "import" a project from a foreign org
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.
139
161
  allowRead:
140
162
  "exists(Membership where orgId = data.orgId and userId = auth.userId)",
141
163
  allowInsert:
142
164
  "exists(Membership where orgId = data.orgId and userId = auth.userId)",
143
165
  // Only owners/admins can rename or archive.
144
166
  allowUpdate:
145
- "exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
167
+ "exists(Membership where orgId = existing.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
146
168
  allowDelete:
147
- "exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
169
+ "exists(Membership where orgId = existing.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
148
170
  });
149
171
 
150
172
  // ---------------------------------------------------------------------------
@@ -25,11 +25,14 @@ const Room = entity("Room", {
25
25
  });
26
26
 
27
27
  const Message = entity("Message", {
28
- roomId: field.id("Room"),
29
- authorId: field.id("User"),
28
+ // `.readonly()` on identity fields blocks HTTP PATCH from
29
+ // rewriting authorship / message location. Server-side
30
+ // ctx.db.update still goes through inside actions.
31
+ roomId: field.id("Room").readonly(),
32
+ authorId: field.id("User").readonly(),
30
33
  authorName: field.string(),
31
34
  body: field.string(),
32
- createdAt: field.datetime(),
35
+ createdAt: field.datetime().readonly(),
33
36
  });
34
37
 
35
38
  // ---------------------------------------------------------------------------
@@ -23,23 +23,26 @@ import {
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  const Profile = entity("Profile", {
26
- userId: field.id("User"),
26
+ // Ownership fields lock with `.readonly()` so HTTP PATCH can't
27
+ // rewrite them. Server-side ctx.db.update inside actions still
28
+ // goes through — readonly is an HTTP-boundary check only.
29
+ userId: field.id("User").readonly(),
27
30
  handle: field.string(),
28
31
  displayName: field.string(),
29
32
  bio: field.string().optional(),
30
- createdAt: field.datetime(),
33
+ createdAt: field.datetime().readonly(),
31
34
  });
32
35
 
33
36
  const Post = entity("Post", {
34
- authorId: field.id("Profile"),
37
+ authorId: field.id("Profile").readonly(),
35
38
  body: field.string(),
36
- createdAt: field.datetime(),
39
+ createdAt: field.datetime().readonly(),
37
40
  });
38
41
 
39
42
  const Like = entity("Like", {
40
- profileId: field.id("Profile"),
41
- postId: field.id("Post"),
42
- createdAt: field.datetime(),
43
+ profileId: field.id("Profile").readonly(),
44
+ postId: field.id("Post").readonly(),
45
+ createdAt: field.datetime().readonly(),
43
46
  });
44
47
 
45
48
  // ---------------------------------------------------------------------------
@@ -79,33 +82,43 @@ const profilePolicy = policy({
79
82
  entity: "Profile",
80
83
  allowRead: "true",
81
84
  allowInsert: "auth.userId == data.userId",
82
- allowUpdate: "auth.userId == data.userId",
83
- allowDelete: "auth.userId == data.userId",
85
+ // Update + delete pin `existing.userId` so an attacker can't
86
+ // PATCH `{userId: <attacker>}` to "claim" someone else's profile.
87
+ // `userId` is also `.readonly()` on the entity so PATCH bounces
88
+ // the field outright — belt + suspenders.
89
+ allowUpdate: "auth.userId == existing.userId",
90
+ allowDelete: "auth.userId == existing.userId",
84
91
  });
85
92
 
86
93
  const postPolicy = policy({
87
94
  name: "post_public",
88
95
  entity: "Post",
89
96
  allowRead: "true",
90
- // Caller must own a Profile that's being claimed as the author.
97
+ // Insert: caller must own a Profile they're claiming as author.
91
98
  allowInsert:
92
99
  "exists(Profile where id = data.authorId and userId = auth.userId)",
100
+ // Update + delete pin `existing.authorId` so an attacker can't
101
+ // rewrite the author in the PATCH payload to grant themselves
102
+ // access. `authorId` is also `.readonly()` on the entity.
93
103
  allowUpdate:
94
- "exists(Profile where id = data.authorId and userId = auth.userId)",
104
+ "exists(Profile where id = existing.authorId and userId = auth.userId)",
95
105
  allowDelete:
96
- "exists(Profile where id = data.authorId and userId = auth.userId)",
106
+ "exists(Profile where id = existing.authorId and userId = auth.userId)",
97
107
  });
98
108
 
99
109
  const likePolicy = policy({
100
110
  name: "like_public",
101
111
  entity: "Like",
102
112
  allowRead: "true",
103
- // Caller must own the Profile doing the liking.
113
+ // Caller must own the Profile doing the liking. Like has no
114
+ // update path (allowUpdate: "false"), so the IDOR-via-PATCH
115
+ // shape doesn't apply — but delete pins `existing.profileId`
116
+ // for consistency with the other policies.
104
117
  allowInsert:
105
118
  "exists(Profile where id = data.profileId and userId = auth.userId)",
106
119
  allowUpdate: "false",
107
120
  allowDelete:
108
- "exists(Profile where id = data.profileId and userId = auth.userId)",
121
+ "exists(Profile where id = existing.profileId and userId = auth.userId)",
109
122
  });
110
123
 
111
124
  // ---------------------------------------------------------------------------