@vibes.diy/prompts 6.2.3 → 6.2.5
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/llms/fireproof.md +29 -40
- package/llms/image-gen.md +7 -10
- package/package.json +4 -4
- package/system-prompt-initial-oneshot.md +106 -29
- package/system-prompt-initial.md +106 -29
- package/system-prompt.md +71 -35
package/llms/fireproof.md
CHANGED
|
@@ -506,17 +506,14 @@ export function announcements(doc, oldDoc, user, ctx) {
|
|
|
506
506
|
|
|
507
507
|
if (doc.type === "post") {
|
|
508
508
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
if (!oldDoc) {
|
|
509
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
510
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
511
|
+
if (oldDoc === null) {
|
|
513
512
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
514
|
-
} else {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
);
|
|
519
|
-
if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
513
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
514
|
+
throw { forbidden: "cannot change author" };
|
|
515
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
516
|
+
throw { forbidden: "not author" };
|
|
520
517
|
}
|
|
521
518
|
ctx.requireAccess(doc.channel);
|
|
522
519
|
return { channels: [doc.channel] };
|
|
@@ -643,17 +640,14 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
643
640
|
|
|
644
641
|
if (doc.type === "post") {
|
|
645
642
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
if (!oldDoc) {
|
|
643
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
644
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
645
|
+
if (oldDoc === null) {
|
|
650
646
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
651
|
-
} else {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
);
|
|
656
|
-
if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
647
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
648
|
+
throw { forbidden: "cannot change author" };
|
|
649
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
650
|
+
throw { forbidden: "not author" };
|
|
657
651
|
}
|
|
658
652
|
// Any signed-in author may post to this open channel. Do NOT call
|
|
659
653
|
// ctx.requireAccess(doc.channel) here: the channel is grant.public
|
|
@@ -798,7 +792,7 @@ Access functions live in `/access.js`, a separate file in the vibe's filesystem
|
|
|
798
792
|
|
|
799
793
|
**`requireAccess` checks _membership_, not public read — don't gate an open channel's writes on it.** `ctx.requireAccess(channelId)` passes only for a channel the user is a member of: granted directly through `grant.users[handle]`, or through a `grant.roles` role they hold. **`grant.public` does NOT satisfy `requireAccess`** — public is read-only ("anyone through the door can _read_"), it never confers write membership. So a channel that is only `grant.public` and gated on `ctx.requireAccess` can be written by **nobody** but the owner-in-admin-mode — every other write returns `not in channel`, silently hiding the form (`useVibe().can` faithfully reflects this). Choose by intent:
|
|
800
794
|
|
|
801
|
-
- **Open channel — any signed-in user may post** (public board, guestbook, comment wall): do **not** call `ctx.requireAccess`. Route the doc to the channel and check the author on create — `if (
|
|
795
|
+
- **Open channel — any signed-in user may post** (public board, guestbook, comment wall): do **not** call `ctx.requireAccess`. Route the doc to the channel and check the author on create — `if (oldDoc === null && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` — keep ownership immutable on update (`else if (doc.authorHandle !== oldDoc.authorHandle) throw`), and let a non-author's `<ImgGen>` version append through via `ctx.isImgGenVersionAppend(doc, oldDoc)` as shown in "Author-equality gates create and ownership change" below; `return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
|
|
802
796
|
- **Restricted channel — only members may post**: gate the write on `ctx.requireAccess(doc.channelId)` **and** grant writers membership explicitly — `grant.users` (direct) or `grant.roles` + a `members`/role-grant doc. `public` alongside is read-only and is fine for letting non-members read, but it is never what lets a member write.
|
|
803
797
|
|
|
804
798
|
### AccessDescriptor return type
|
|
@@ -807,22 +801,19 @@ All fields are optional, but a stored document must be routed to at least one ch
|
|
|
807
801
|
|
|
808
802
|
**Grant/member/meta docs need a channel too.** A role grant, membership, or config singleton that returns only `members`/`grant` with **no `channels`** is refused exactly like any other channel-less write — so the owner can't even create it. Route these to an owner-readable **admin channel** (e.g. `channels: ["admin:grants"]` with `grant: { users: { [user.userHandle]: ["admin:grants"] } }`), not a public channel. The `members`/`grant` still take effect globally; the channel just makes the doc persist and lets the owner read the roster back.
|
|
809
803
|
|
|
810
|
-
**Author-equality gates create and ownership change, not every update.** Checking the new author field on create isn't enough — also pin ownership with `oldDoc`. But a blanket `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw` denies `<ImgGen>` version appends on a shared/public-read doc: a version append runs as the VIEWING user, and denying it after the platform already billed it arms a billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update
|
|
804
|
+
**Author-equality gates create and ownership change, not every update.** Checking the new author field on create isn't enough — also pin ownership with `oldDoc`. But a blanket `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw` denies `<ImgGen>` version appends on a shared/public-read doc: a version append runs as the VIEWING user, and denying it after the platform already billed it arms a billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
811
805
|
|
|
812
806
|
```js
|
|
813
|
-
|
|
814
|
-
if (!oldDoc) {
|
|
807
|
+
if (oldDoc === null) {
|
|
815
808
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
816
|
-
} else {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
);
|
|
821
|
-
if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
809
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
810
|
+
throw { forbidden: "cannot change author" };
|
|
811
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
812
|
+
throw { forbidden: "not author" };
|
|
822
813
|
}
|
|
823
814
|
```
|
|
824
815
|
|
|
825
|
-
`<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …)
|
|
816
|
+
`<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …). The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically for author-only display control. Write-once docs can simply `if (oldDoc === null) {} else throw`; a genuinely private per-user doc no other viewer can reach may stay author-only on update.
|
|
826
817
|
|
|
827
818
|
```ts
|
|
828
819
|
type AccessDescriptor = {
|
|
@@ -882,16 +873,14 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
882
873
|
|
|
883
874
|
if (doc.type === "message") {
|
|
884
875
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
if (
|
|
876
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
877
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
878
|
+
if (oldDoc === null) {
|
|
888
879
|
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
889
|
-
} else {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
);
|
|
894
|
-
if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
880
|
+
} else if (doc.userHandle !== oldDoc.userHandle) {
|
|
881
|
+
throw { forbidden: "cannot change author" };
|
|
882
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
|
|
883
|
+
throw { forbidden: "not author" };
|
|
895
884
|
}
|
|
896
885
|
ctx.requireAccess(doc.channelId);
|
|
897
886
|
return { channels: [doc.channelId] };
|
package/llms/image-gen.md
CHANGED
|
@@ -148,22 +148,19 @@ Prefer attaching images to the host doc whose access you already grant; reach fo
|
|
|
148
148
|
|
|
149
149
|
**ImgGen does not stamp your app's author field.** The doc ImgGen writes (a standalone `type: "image"` doc, or the version it appends to a host doc) carries no `authorHandle`/`userHandle`/`senderHandle` of yours — that field is `null`/`undefined`. So an access-fn branch that author-gates the type ImgGen writes (`if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }`) **rejects the write**: `null !== user.userHandle`, the signed-in maker included, and the image silently fails to persist as the doc you meant (`Failed to put document:`). Two fixes: (1) **attach the image to a host doc you created with the author field already stamped** — `<ImgGen _id={hostDoc._id} database={db} />` inherits the host's `type` and access, so the author check already passed when you wrote the host; this is the preferred shape. (2) For a genuinely **standalone** image doc, do NOT author-gate the `"image"` branch on strict author equality — route it to the channel you want and skip the author check (the platform's ImgGen write is server-authoritative, so there's no spoof to guard against). Never author-gate a type whose docs ImgGen originates without stamping the author yourself first.
|
|
150
150
|
|
|
151
|
-
**A version append (regenerate, or an img2img edit on an existing `_id`) arrives as the VIEWING user, not the original author.** So even when you DID stamp the author on the host doc, an `if (oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` on that type denies any _other_ signed-in viewer's generation on a shared/public-read doc — after the platform already billed it, arming an unbounded billed-retry loop (#3784/#3832). Author-equality belongs on **create and ownership change**, not on every update. Fix the author at create, forbid re-authoring, and for a non-author update
|
|
151
|
+
**A version append (regenerate, or an img2img edit on an existing `_id`) arrives as the VIEWING user, not the original author.** So even when you DID stamp the author on the host doc, an `if (oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` on that type denies any _other_ signed-in viewer's generation on a shared/public-read doc — after the platform already billed it, arming an unbounded billed-retry loop (#3784/#3832). Author-equality belongs on **create and ownership change**, not on every update. Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
152
152
|
|
|
153
153
|
```js
|
|
154
|
-
|
|
155
|
-
if (!oldDoc) {
|
|
154
|
+
if (oldDoc === null) {
|
|
156
155
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
157
|
-
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
162
|
-
if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
156
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
157
|
+
throw { forbidden: "cannot change author" };
|
|
158
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
159
|
+
throw { forbidden: "not author" };
|
|
163
160
|
}
|
|
164
161
|
```
|
|
165
162
|
|
|
166
|
-
`
|
|
163
|
+
The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically if you need author-only display control.
|
|
167
164
|
|
|
168
165
|
## Choosing a Model
|
|
169
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"description": "",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"license": "Apache-2.0",
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@adviser/cement": "~0.5.34",
|
|
27
|
-
"@vibes.diy/call-ai-v2": "^6.2.
|
|
28
|
-
"@vibes.diy/identity": "^6.2.
|
|
29
|
-
"@vibes.diy/use-vibes-types": "^6.2.
|
|
27
|
+
"@vibes.diy/call-ai-v2": "^6.2.5",
|
|
28
|
+
"@vibes.diy/identity": "^6.2.5",
|
|
29
|
+
"@vibes.diy/use-vibes-types": "^6.2.5",
|
|
30
30
|
"arktype": "~2.2.3",
|
|
31
31
|
"json-schema-faker": "~0.6.2"
|
|
32
32
|
},
|
|
@@ -75,17 +75,14 @@ export function wall(doc, oldDoc, user, ctx) {
|
|
|
75
75
|
|
|
76
76
|
if (doc.type === "post") {
|
|
77
77
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
if (!oldDoc) {
|
|
78
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
79
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
80
|
+
if (oldDoc === null) {
|
|
82
81
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
83
|
-
} else {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
);
|
|
88
|
-
if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
82
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
83
|
+
throw { forbidden: "cannot change author" };
|
|
84
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
85
|
+
throw { forbidden: "not author" };
|
|
89
86
|
}
|
|
90
87
|
return { channels: [doc.channelId] };
|
|
91
88
|
}
|
|
@@ -121,11 +118,36 @@ export function board(doc, oldDoc, user, ctx) {
|
|
|
121
118
|
}
|
|
122
119
|
```
|
|
123
120
|
|
|
124
|
-
`ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author
|
|
121
|
+
`ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
|
|
125
122
|
|
|
126
123
|
**Build the permission model around what a newcomer should be able to do.** When a stranger opens the app, they should immediately be able to do the thing it's _for_ — add their own todos, post a note, drop a pin, join a shared canvas. So the default is: every signed-in visitor is a first-class participant who creates their own objects and edits what they created (`doc.authorHandle === user.userHandle`, checking `oldDoc` on updates), from first load, with no one needing to let them in.
|
|
127
124
|
|
|
128
|
-
**
|
|
125
|
+
**A personal list, tracker, journal, or notes app — one the prompt frames as the user's own, with no sharing asked for — gives every visitor their own private space on a single per-user channel.** A todo list, a daily habit tracker, a reading list, a diary, a notes app, a workout log, or a budget where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle — `user:${user.userHandle}` — self-granted so only they read it, with `authorHandle: user.userHandle` fixed at create and held immutable on update (`oldDoc.authorHandle === user.userHandle`). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
|
|
126
|
+
|
|
127
|
+
access.js
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
export function notes(doc, oldDoc, user, ctx) {
|
|
131
|
+
if (!user) throw { forbidden: "sign in" };
|
|
132
|
+
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
133
|
+
|
|
134
|
+
if (doc.type === "note") {
|
|
135
|
+
if (!oldDoc) {
|
|
136
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
137
|
+
} else {
|
|
138
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
|
|
139
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
140
|
+
}
|
|
141
|
+
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw { forbidden: "unknown document type" };
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small "Only you can see this" cue near their content. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way. The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
|
|
149
|
+
|
|
150
|
+
**When the prompt asks to share, invite, or collaborate — bring in a partner, a buddy, a friend, a team, or a group that co-edits — each shared thing becomes its own object others can be invited into.** A shopping list you invite your partner to, a board a group co-edits, a document you open to a collaborator: route that shared thing to its **own object channel** (`list:<id>`/`board:<id>`) and self-grant it at creation (`grant: { users: { [user.userHandle]: [ch] } }`), with the `authorHandle` create + `oldDoc` author checks so each item stays on its object. Then let the creator invite a chosen friend in: a `share` doc the creator authors grants that friend the same channel (`grant: { users: { [doc.invitee]: [ch] } }`), so they collaborate on that one list — sharing a single list or a whole space is the same grant at a different node of the object graph. A wall, guestbook, or map where each visitor adds their _own_ items is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
|
|
129
151
|
|
|
130
152
|
**When a user's work is private by default, show it — and offer a way to publish.** If everything a user does routes to a channel only they can read (a per-user `user:<handle>`, a private journal/notes/tracker with no `grant.public`), the UI must say so: a small, persistent "Only you can see this" / "Private to you" cue near their content, so no one wonders who's watching their unfinished work. Then, where sharing fits the app, give them a publish control — but publish at the granularity of a **channel**, not a doc: channels are the unit of read isolation, so adding `grant.public` to the shared `user:<handle>` channel would expose _every_ private item on it, not just the one they meant to share. To publish a single item, route it to its **own** channel and flip only that channel's read-grant from a `visibility` field the access fn reads — exactly the per-item-channel shape the worked example below uses (`const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];`) — for anonymous visitors, or grant a shared app channel for all granted members. Gate the control on `useVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). Making the user's _whole_ space public is fine when that's the intent; silently leaking their other private items by publishing one is the trap to avoid. **If the app brief is private-only/confidential — a journal, private notes, a health tracker — do not add publish/share controls.** Keep publish opt-in: for private-only apps, omit the publish UI entirely; for share-capable apps, make publish one tap for user-selected items.
|
|
131
153
|
|
|
@@ -167,12 +189,25 @@ export function habits(doc, oldDoc, user, ctx) {
|
|
|
167
189
|
}
|
|
168
190
|
```
|
|
169
191
|
|
|
170
|
-
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in.
|
|
192
|
+
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. A **plain daily habit tracker** — one framed as the user's own, with no catalog, leaderboard, or buddy asked for — is instead the per-visitor shape shown above: every visitor's habits and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
|
|
171
193
|
|
|
172
194
|
**"Invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or a board/canvas/room/whiteboard a group co-edits → per-object collaboration** (the second worked example above) — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object (`board:<id>`/`list:<id>`); the creator self-grants at creation (`grant: { users: { [user.userHandle]: [ch] } }`); child docs gate on `ctx.requireAccess(ch)` so any member edits any child in it (not just their own); a member-authored `share` doc grants a peer the same channel; a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable. **Two traps to avoid:** don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request).
|
|
173
195
|
|
|
174
196
|
**Ownership is just the object graph** — whoever authored or created a doc owns it (`doc.authorHandle === user.userHandle`, checked against `oldDoc` on updates). There's no broadcaster shape to reach for by default, and **owner-only publishing is a dead end** — never gate the content itself on `requireRole("owner")`. **A blog, magazine, or publication is public read + author-owned posts, with the owner controlling the _author roster_:** the owner approves authors with a grant doc (`if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["blog:authors"], grant: { users: { [doc.authorHandle]: ["blog:authors"] }, roles: { owner: ["blog:authors"] } } }; }` — the **one** place `requireRole("owner")` belongs, gating who may author, never the posts); a post then gates on `ctx.requireAccess("blog:authors")` (membership) and is author-owned (`doc.authorHandle === user.userHandle` + the `oldDoc` check), so once approved each author's post is _their own_ object — only they edit it, and they moderate the comments on it (a comment is allowed if it's your comment **or** you own the post: `doc.authorHandle === user.userHandle || doc.postAuthorHandle === user.userHandle`). A personal blog is just this with a roster of one. Always gate write UI on `useVibe(dbName).can`.
|
|
175
197
|
|
|
198
|
+
The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
if (doc.type === "post") {
|
|
202
|
+
if (!oldDoc) {
|
|
203
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
204
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
205
|
+
return { channels: ["posts"], grant: { public: ["posts"] } };
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (`draft:<handle>`, granted just to the author) — publishing moves the doc to the public `posts` channel.
|
|
210
|
+
|
|
176
211
|
**Sharing objects: channels carry objects, roles carry types.** A channel is one shareable thing (`list:<id>`) — membership in it means "can reach this object." A role is a _kind_ of participant (`author`, `editor`), a small reusable vocabulary. To let people collaborate on _their own_ objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on `ctx.requireAccess("list:<id>")`. The reserved `owner` role is the only one auto-seeded — every _other_ role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a `request` doc a not-yet-member authors (taking **no** `requireRole`) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
|
|
177
212
|
## Social: followers see your stuff (platform graph)
|
|
178
213
|
|
|
@@ -190,11 +225,57 @@ docs in your database, and never build follow UI state machines.
|
|
|
190
225
|
follow again later (blocking is harsher and lives in Settings, not in your app).
|
|
191
226
|
Following a private account sits at `state:"requested"` and grants no reads until
|
|
192
227
|
approved — filter to `state === "active"` when deciding whose content to show.
|
|
193
|
-
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
228
|
+
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
|
|
229
|
+
writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
|
|
230
|
+
`export default function (doc, oldDoc, user, ctx)` — the platform reads the writer from that default
|
|
231
|
+
export's third positional parameter `user`, so `user.userHandle` names the live writer and the
|
|
232
|
+
audience resolves against that writer's own graph:
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
export default function (doc, oldDoc, user, ctx) {
|
|
236
|
+
if (!user) throw { forbidden: "sign in" };
|
|
237
|
+
const ch = "picks:" + user.userHandle; // the writer's own channel
|
|
238
|
+
if (doc._deleted) {
|
|
239
|
+
// A tombstone carries no fields — authorize AND route it off oldDoc.
|
|
240
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
|
|
241
|
+
if (oldDoc.type === "reaction" || oldDoc.type === "comment")
|
|
242
|
+
return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
|
|
243
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
244
|
+
}
|
|
245
|
+
if (doc.type === "reaction" || doc.type === "comment") {
|
|
246
|
+
// A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
|
|
247
|
+
// pick's own audience already carries it to the right readers, so it returns
|
|
248
|
+
// channels only. Audience belongs to the doc types the WRITER shares.
|
|
249
|
+
// Author and target are fixed at create.
|
|
250
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
251
|
+
if (oldDoc) {
|
|
252
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
253
|
+
if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
|
|
254
|
+
}
|
|
255
|
+
return { channels: ["picks:" + doc.pickOwnerHandle] };
|
|
256
|
+
}
|
|
257
|
+
// A pick is the writer's own per-user doc: author fixed at create, and only
|
|
258
|
+
// the author updates it — so the channel and audience stay the author's own.
|
|
259
|
+
if (!oldDoc) {
|
|
260
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
261
|
+
} else {
|
|
262
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
263
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
264
|
+
}
|
|
265
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
(`mutualsOf: user.userHandle` for both-directions-only). The subject is always
|
|
270
|
+
`user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
|
|
271
|
+
create, the update, and the delete tombstone alike (a delete runs as the author, so
|
|
272
|
+
`user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
|
|
273
|
+
rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
|
|
274
|
+
against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
|
|
275
|
+
instantly revokes. The writer is always in their own audience — no self-grant needed. When a
|
|
276
|
+
reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
|
|
277
|
+
channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
|
|
278
|
+
`user.userHandle`. Keep at least one real channel; keep PRIVATE data
|
|
198
279
|
on channels+grant without `audience`; never write channel names starting with `~`.
|
|
199
280
|
- Copy: "followers can see your picks" / "people you follow" — never "friends".
|
|
200
281
|
Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
|
|
@@ -225,23 +306,19 @@ docs in your database, and never build follow UI state machines.
|
|
|
225
306
|
|
|
226
307
|
**The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
|
|
227
308
|
|
|
228
|
-
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update
|
|
309
|
+
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
229
310
|
|
|
230
311
|
```js
|
|
231
|
-
|
|
232
|
-
const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
|
|
233
|
-
if (!oldDoc) {
|
|
312
|
+
if (oldDoc === null) {
|
|
234
313
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
235
|
-
} else {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
);
|
|
240
|
-
if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
314
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
315
|
+
throw { forbidden: "cannot change author" };
|
|
316
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
317
|
+
throw { forbidden: "not author" };
|
|
241
318
|
}
|
|
242
319
|
```
|
|
243
320
|
|
|
244
|
-
|
|
321
|
+
The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
|
|
245
322
|
|
|
246
323
|
**Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
|
|
247
324
|
|
package/system-prompt-initial.md
CHANGED
|
@@ -76,17 +76,14 @@ export function wall(doc, oldDoc, user, ctx) {
|
|
|
76
76
|
|
|
77
77
|
if (doc.type === "post") {
|
|
78
78
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
if (!oldDoc) {
|
|
79
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
80
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
81
|
+
if (oldDoc === null) {
|
|
83
82
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
84
|
-
} else {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
83
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
84
|
+
throw { forbidden: "cannot change author" };
|
|
85
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
86
|
+
throw { forbidden: "not author" };
|
|
90
87
|
}
|
|
91
88
|
return { channels: [doc.channelId] };
|
|
92
89
|
}
|
|
@@ -122,11 +119,36 @@ export function board(doc, oldDoc, user, ctx) {
|
|
|
122
119
|
}
|
|
123
120
|
```
|
|
124
121
|
|
|
125
|
-
`ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author
|
|
122
|
+
`ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
|
|
126
123
|
|
|
127
124
|
**Build the permission model around what a newcomer should be able to do.** When a stranger opens the app, they should immediately be able to do the thing it's _for_ — add their own todos, post a note, drop a pin, join a shared canvas. So the default is: every signed-in visitor is a first-class participant who creates their own objects and edits what they created (`doc.authorHandle === user.userHandle`, checking `oldDoc` on updates), from first load, with no one needing to let them in.
|
|
128
125
|
|
|
129
|
-
**
|
|
126
|
+
**A personal list, tracker, journal, or notes app — one the prompt frames as the user's own, with no sharing asked for — gives every visitor their own private space on a single per-user channel.** A todo list, a daily habit tracker, a reading list, a diary, a notes app, a workout log, or a budget where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle — `user:${user.userHandle}` — self-granted so only they read it, with `authorHandle: user.userHandle` fixed at create and held immutable on update (`oldDoc.authorHandle === user.userHandle`). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
|
|
127
|
+
|
|
128
|
+
access.js
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
export function notes(doc, oldDoc, user, ctx) {
|
|
132
|
+
if (!user) throw { forbidden: "sign in" };
|
|
133
|
+
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
134
|
+
|
|
135
|
+
if (doc.type === "note") {
|
|
136
|
+
if (!oldDoc) {
|
|
137
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
138
|
+
} else {
|
|
139
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
|
|
140
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
141
|
+
}
|
|
142
|
+
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw { forbidden: "unknown document type" };
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small "Only you can see this" cue near their content. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way. The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
|
|
150
|
+
|
|
151
|
+
**When the prompt asks to share, invite, or collaborate — bring in a partner, a buddy, a friend, a team, or a group that co-edits — each shared thing becomes its own object others can be invited into.** A shopping list you invite your partner to, a board a group co-edits, a document you open to a collaborator: route that shared thing to its **own object channel** (`list:<id>`/`board:<id>`) and self-grant it at creation (`grant: { users: { [user.userHandle]: [ch] } }`), with the `authorHandle` create + `oldDoc` author checks so each item stays on its object. Then let the creator invite a chosen friend in: a `share` doc the creator authors grants that friend the same channel (`grant: { users: { [doc.invitee]: [ch] } }`), so they collaborate on that one list — sharing a single list or a whole space is the same grant at a different node of the object graph. A wall, guestbook, or map where each visitor adds their _own_ items is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
|
|
130
152
|
|
|
131
153
|
**When a user's work is private by default, show it — and offer a way to publish.** If everything a user does routes to a channel only they can read (a per-user `user:<handle>`, a private journal/notes/tracker with no `grant.public`), the UI must say so: a small, persistent "Only you can see this" / "Private to you" cue near their content, so no one wonders who's watching their unfinished work. Then, where sharing fits the app, give them a publish control — but publish at the granularity of a **channel**, not a doc: channels are the unit of read isolation, so adding `grant.public` to the shared `user:<handle>` channel would expose _every_ private item on it, not just the one they meant to share. To publish a single item, route it to its **own** channel and flip only that channel's read-grant from a `visibility` field the access fn reads — exactly the per-item-channel shape the worked example below uses (`const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];`) — for anonymous visitors, or grant a shared app channel for all granted members. Gate the control on `useVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). Making the user's _whole_ space public is fine when that's the intent; silently leaking their other private items by publishing one is the trap to avoid. **If the app brief is private-only/confidential — a journal, private notes, a health tracker — do not add publish/share controls.** Keep publish opt-in: for private-only apps, omit the publish UI entirely; for share-capable apps, make publish one tap for user-selected items.
|
|
132
154
|
|
|
@@ -168,12 +190,25 @@ export function habits(doc, oldDoc, user, ctx) {
|
|
|
168
190
|
}
|
|
169
191
|
```
|
|
170
192
|
|
|
171
|
-
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in.
|
|
193
|
+
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. A **plain daily habit tracker** — one framed as the user's own, with no catalog, leaderboard, or buddy asked for — is instead the per-visitor shape shown above: every visitor's habits and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
|
|
172
194
|
|
|
173
195
|
**"Invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or a board/canvas/room/whiteboard a group co-edits → per-object collaboration** (the second worked example above) — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object (`board:<id>`/`list:<id>`); the creator self-grants at creation (`grant: { users: { [user.userHandle]: [ch] } }`); child docs gate on `ctx.requireAccess(ch)` so any member edits any child in it (not just their own); a member-authored `share` doc grants a peer the same channel; a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable. **Two traps to avoid:** don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request).
|
|
174
196
|
|
|
175
197
|
**Ownership is just the object graph** — whoever authored or created a doc owns it (`doc.authorHandle === user.userHandle`, checked against `oldDoc` on updates). There's no broadcaster shape to reach for by default, and **owner-only publishing is a dead end** — never gate the content itself on `requireRole("owner")`. **A blog, magazine, or publication is public read + author-owned posts, with the owner controlling the _author roster_:** the owner approves authors with a grant doc (`if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["blog:authors"], grant: { users: { [doc.authorHandle]: ["blog:authors"] }, roles: { owner: ["blog:authors"] } } }; }` — the **one** place `requireRole("owner")` belongs, gating who may author, never the posts); a post then gates on `ctx.requireAccess("blog:authors")` (membership) and is author-owned (`doc.authorHandle === user.userHandle` + the `oldDoc` check), so once approved each author's post is _their own_ object — only they edit it, and they moderate the comments on it (a comment is allowed if it's your comment **or** you own the post: `doc.authorHandle === user.userHandle || doc.postAuthorHandle === user.userHandle`). A personal blog is just this with a roster of one. Always gate write UI on `useVibe(dbName).can`.
|
|
176
198
|
|
|
199
|
+
The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
if (doc.type === "post") {
|
|
203
|
+
if (!oldDoc) {
|
|
204
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
205
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
206
|
+
return { channels: ["posts"], grant: { public: ["posts"] } };
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (`draft:<handle>`, granted just to the author) — publishing moves the doc to the public `posts` channel.
|
|
211
|
+
|
|
177
212
|
**Sharing objects: channels carry objects, roles carry types.** A channel is one shareable thing (`list:<id>`) — membership in it means "can reach this object." A role is a _kind_ of participant (`author`, `editor`), a small reusable vocabulary. To let people collaborate on _their own_ objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on `ctx.requireAccess("list:<id>")`. The reserved `owner` role is the only one auto-seeded — every _other_ role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a `request` doc a not-yet-member authors (taking **no** `requireRole`) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
|
|
178
213
|
## Social: followers see your stuff (platform graph)
|
|
179
214
|
|
|
@@ -191,11 +226,57 @@ docs in your database, and never build follow UI state machines.
|
|
|
191
226
|
follow again later (blocking is harsher and lives in Settings, not in your app).
|
|
192
227
|
Following a private account sits at `state:"requested"` and grants no reads until
|
|
193
228
|
approved — filter to `state === "active"` when deciding whose content to show.
|
|
194
|
-
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
229
|
+
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
|
|
230
|
+
writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
|
|
231
|
+
`export default function (doc, oldDoc, user, ctx)` — the platform reads the writer from that default
|
|
232
|
+
export's third positional parameter `user`, so `user.userHandle` names the live writer and the
|
|
233
|
+
audience resolves against that writer's own graph:
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
export default function (doc, oldDoc, user, ctx) {
|
|
237
|
+
if (!user) throw { forbidden: "sign in" };
|
|
238
|
+
const ch = "picks:" + user.userHandle; // the writer's own channel
|
|
239
|
+
if (doc._deleted) {
|
|
240
|
+
// A tombstone carries no fields — authorize AND route it off oldDoc.
|
|
241
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
|
|
242
|
+
if (oldDoc.type === "reaction" || oldDoc.type === "comment")
|
|
243
|
+
return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
|
|
244
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
245
|
+
}
|
|
246
|
+
if (doc.type === "reaction" || doc.type === "comment") {
|
|
247
|
+
// A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
|
|
248
|
+
// pick's own audience already carries it to the right readers, so it returns
|
|
249
|
+
// channels only. Audience belongs to the doc types the WRITER shares.
|
|
250
|
+
// Author and target are fixed at create.
|
|
251
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
252
|
+
if (oldDoc) {
|
|
253
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
254
|
+
if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
|
|
255
|
+
}
|
|
256
|
+
return { channels: ["picks:" + doc.pickOwnerHandle] };
|
|
257
|
+
}
|
|
258
|
+
// A pick is the writer's own per-user doc: author fixed at create, and only
|
|
259
|
+
// the author updates it — so the channel and audience stay the author's own.
|
|
260
|
+
if (!oldDoc) {
|
|
261
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
262
|
+
} else {
|
|
263
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
264
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
265
|
+
}
|
|
266
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
(`mutualsOf: user.userHandle` for both-directions-only). The subject is always
|
|
271
|
+
`user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
|
|
272
|
+
create, the update, and the delete tombstone alike (a delete runs as the author, so
|
|
273
|
+
`user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
|
|
274
|
+
rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
|
|
275
|
+
against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
|
|
276
|
+
instantly revokes. The writer is always in their own audience — no self-grant needed. When a
|
|
277
|
+
reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
|
|
278
|
+
channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
|
|
279
|
+
`user.userHandle`. Keep at least one real channel; keep PRIVATE data
|
|
199
280
|
on channels+grant without `audience`; never write channel names starting with `~`.
|
|
200
281
|
- Copy: "followers can see your picks" / "people you follow" — never "friends".
|
|
201
282
|
Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
|
|
@@ -226,23 +307,19 @@ docs in your database, and never build follow UI state machines.
|
|
|
226
307
|
|
|
227
308
|
**The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
|
|
228
309
|
|
|
229
|
-
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update
|
|
310
|
+
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
230
311
|
|
|
231
312
|
```js
|
|
232
|
-
|
|
233
|
-
const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
|
|
234
|
-
if (!oldDoc) {
|
|
313
|
+
if (oldDoc === null) {
|
|
235
314
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
236
|
-
} else {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
241
|
-
if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
315
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
316
|
+
throw { forbidden: "cannot change author" };
|
|
317
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
318
|
+
throw { forbidden: "not author" };
|
|
242
319
|
}
|
|
243
320
|
```
|
|
244
321
|
|
|
245
|
-
|
|
322
|
+
The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
|
|
246
323
|
|
|
247
324
|
**Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
|
|
248
325
|
|
package/system-prompt.md
CHANGED
|
@@ -286,17 +286,14 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
286
286
|
|
|
287
287
|
if (doc.type === "message") {
|
|
288
288
|
// Author fixed at create; ownership immutable. On update a non-author may
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
if (!oldDoc) {
|
|
289
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
290
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
291
|
+
if (oldDoc === null) {
|
|
293
292
|
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
294
|
-
} else {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
);
|
|
299
|
-
if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
293
|
+
} else if (doc.userHandle !== oldDoc.userHandle) {
|
|
294
|
+
throw { forbidden: "cannot change author" };
|
|
295
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
|
|
296
|
+
throw { forbidden: "not author" };
|
|
300
297
|
}
|
|
301
298
|
ctx.requireAccess(doc.channelId);
|
|
302
299
|
return { channels: [doc.channelId] };
|
|
@@ -306,7 +303,7 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
306
303
|
}
|
|
307
304
|
```
|
|
308
305
|
|
|
309
|
-
`ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author
|
|
306
|
+
`ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `chat.message` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged. See the fireproof access docs.
|
|
310
307
|
|
|
311
308
|
**A follow-up edit that adds a NEW doc type updates `access.js` FIRST.** The app runs live while your edits stream in, and writes are enforced against the access.js that was in force before this turn until the whole turn completes — so a `db.put` of a doc type the old function rejects fails immediately (`unknown document type`), even though your access.js update lands later in the same reply. Emit the access.js edit adding the new type's branch before the App.jsx edits that write it. And never fire-and-forget background writes of a newly added type: gate seed/auto-write effects on `useVibe(dbName)` — `if (!ready || !can.create(sampleDoc).ok) return;` with `ready`/`can` in the effect deps, checking a representative sample of **each** doc type the effect writes — so a not-yet-allowed write is skipped quietly instead of surfacing rejection errors the user didn't cause.
|
|
312
309
|
|
|
@@ -399,11 +396,57 @@ docs in your database, and never build follow UI state machines.
|
|
|
399
396
|
follow again later (blocking is harsher and lives in Settings, not in your app).
|
|
400
397
|
Following a private account sits at `state:"requested"` and grants no reads until
|
|
401
398
|
approved — filter to `state === "active"` when deciding whose content to show.
|
|
402
|
-
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
399
|
+
- Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
|
|
400
|
+
writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
|
|
401
|
+
`export default function (doc, oldDoc, user, ctx)` — the platform reads the writer from that default
|
|
402
|
+
export's third positional parameter `user`, so `user.userHandle` names the live writer and the
|
|
403
|
+
audience resolves against that writer's own graph:
|
|
404
|
+
|
|
405
|
+
```js
|
|
406
|
+
export default function (doc, oldDoc, user, ctx) {
|
|
407
|
+
if (!user) throw { forbidden: "sign in" };
|
|
408
|
+
const ch = "picks:" + user.userHandle; // the writer's own channel
|
|
409
|
+
if (doc._deleted) {
|
|
410
|
+
// A tombstone carries no fields — authorize AND route it off oldDoc.
|
|
411
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
|
|
412
|
+
if (oldDoc.type === "reaction" || oldDoc.type === "comment")
|
|
413
|
+
return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
|
|
414
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
415
|
+
}
|
|
416
|
+
if (doc.type === "reaction" || doc.type === "comment") {
|
|
417
|
+
// A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
|
|
418
|
+
// pick's own audience already carries it to the right readers, so it returns
|
|
419
|
+
// channels only. Audience belongs to the doc types the WRITER shares.
|
|
420
|
+
// Author and target are fixed at create.
|
|
421
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
422
|
+
if (oldDoc) {
|
|
423
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
424
|
+
if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
|
|
425
|
+
}
|
|
426
|
+
return { channels: ["picks:" + doc.pickOwnerHandle] };
|
|
427
|
+
}
|
|
428
|
+
// A pick is the writer's own per-user doc: author fixed at create, and only
|
|
429
|
+
// the author updates it — so the channel and audience stay the author's own.
|
|
430
|
+
if (!oldDoc) {
|
|
431
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
432
|
+
} else {
|
|
433
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
434
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
435
|
+
}
|
|
436
|
+
return { channels: [ch], audience: { followersOf: user.userHandle } };
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
(`mutualsOf: user.userHandle` for both-directions-only). The subject is always
|
|
441
|
+
`user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
|
|
442
|
+
create, the update, and the delete tombstone alike (a delete runs as the author, so
|
|
443
|
+
`user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
|
|
444
|
+
rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
|
|
445
|
+
against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
|
|
446
|
+
instantly revokes. The writer is always in their own audience — no self-grant needed. When a
|
|
447
|
+
reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
|
|
448
|
+
channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
|
|
449
|
+
`user.userHandle`. Keep at least one real channel; keep PRIVATE data
|
|
407
450
|
on channels+grant without `audience`; never write channel names starting with `~`.
|
|
408
451
|
- Copy: "followers can see your picks" / "people you follow" — never "friends".
|
|
409
452
|
Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
|
|
@@ -434,23 +477,19 @@ docs in your database, and never build follow UI state machines.
|
|
|
434
477
|
|
|
435
478
|
**The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
|
|
436
479
|
|
|
437
|
-
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update
|
|
480
|
+
**Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
438
481
|
|
|
439
482
|
```js
|
|
440
|
-
|
|
441
|
-
const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
|
|
442
|
-
if (!oldDoc) {
|
|
483
|
+
if (oldDoc === null) {
|
|
443
484
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
444
|
-
} else {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
);
|
|
449
|
-
if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
485
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
486
|
+
throw { forbidden: "cannot change author" };
|
|
487
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
488
|
+
throw { forbidden: "not author" };
|
|
450
489
|
}
|
|
451
490
|
```
|
|
452
491
|
|
|
453
|
-
|
|
492
|
+
The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
|
|
454
493
|
|
|
455
494
|
**Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
|
|
456
495
|
|
|
@@ -520,17 +559,14 @@ Example streamed output for a team board app:
|
|
|
520
559
|
> }
|
|
521
560
|
>
|
|
522
561
|
> if (doc.type === "post") {
|
|
523
|
-
> // Author fixed at create; on update a non-author may
|
|
524
|
-
> //
|
|
525
|
-
>
|
|
526
|
-
> if (
|
|
562
|
+
> // Author fixed at create; on update a non-author may only append one
|
|
563
|
+
> // legitimate ImgGen version — the platform predicate ctx.isImgGenVersionAppend
|
|
564
|
+
> // decides that (oldDoc is null on create).
|
|
565
|
+
> if (oldDoc === null) {
|
|
527
566
|
> if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
528
567
|
> } else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
529
568
|
> throw { forbidden: "cannot change author" };
|
|
530
|
-
> } else if (
|
|
531
|
-
> Object.keys({ ...doc, ...oldDoc }).some((k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])) &&
|
|
532
|
-
> oldDoc.authorHandle !== user.userHandle
|
|
533
|
-
> ) {
|
|
569
|
+
> } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
534
570
|
> throw { forbidden: "not author" };
|
|
535
571
|
> }
|
|
536
572
|
> return { channels: [doc.channelId] };
|