@pylonsync/create-pylon 0.3.157 → 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.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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: "
|
|
115
|
-
allowDelete: "
|
|
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 =
|
|
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 =
|
|
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.
|
|
138
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
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 =
|
|
104
|
+
"exists(Profile where id = existing.authorId and userId = auth.userId)",
|
|
95
105
|
allowDelete:
|
|
96
|
-
"exists(Profile where id =
|
|
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 =
|
|
121
|
+
"exists(Profile where id = existing.profileId and userId = auth.userId)",
|
|
109
122
|
});
|
|
110
123
|
|
|
111
124
|
// ---------------------------------------------------------------------------
|