@vibes.diy/prompts 6.2.0 → 6.2.2

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 CHANGED
@@ -505,10 +505,19 @@ export function announcements(doc, oldDoc, user, ctx) {
505
505
  }
506
506
 
507
507
  if (doc.type === "post") {
508
- if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
509
- // On update, the original author must be preserved never let a writer
510
- // overwrite someone else's doc or reassign its author.
511
- if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
508
+ // Author fixed at create; ownership immutable. On update a non-author may
509
+ // touch ONLY the ImgGen version fields (so a viewer's <ImgGen> append passes)
510
+ // every other key must match oldDoc (an allowlist; unlisted fields stay author-owned).
511
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
512
+ if (!oldDoc) {
513
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
514
+ } else {
515
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
516
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
517
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
518
+ );
519
+ if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
520
+ }
512
521
  ctx.requireAccess(doc.channel);
513
522
  return { channels: [doc.channel] };
514
523
  }
@@ -633,8 +642,19 @@ export function chat(doc, oldDoc, user, ctx) {
633
642
  }
634
643
 
635
644
  if (doc.type === "post") {
636
- if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
637
- if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
645
+ // Author fixed at create; ownership immutable. On update a non-author may
646
+ // touch ONLY the ImgGen version fields every other key must match oldDoc
647
+ // (an allowlist; unlisted fields stay author-owned by default).
648
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
649
+ if (!oldDoc) {
650
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
651
+ } else {
652
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
653
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
654
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
655
+ );
656
+ if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
657
+ }
638
658
  // Any signed-in author may post to this open channel. Do NOT call
639
659
  // ctx.requireAccess(doc.channel) here: the channel is grant.public
640
660
  // (read-only), which never satisfies requireAccess, so gating on it would
@@ -778,7 +798,7 @@ Access functions live in `/access.js`, a separate file in the vibe's filesystem
778
798
 
779
799
  **`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:
780
800
 
781
- - **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 — `if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }; if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" }; return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
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 (!oldDoc && 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 the field-allowlist 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.
782
802
  - **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.
783
803
 
784
804
  ### AccessDescriptor return type
@@ -787,7 +807,22 @@ All fields are optional, but a stored document must be routed to at least one ch
787
807
 
788
808
  **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.
789
809
 
790
- **Preserve the author on updates.** For an author-owned doc, checking the new author field is not enough — also check `oldDoc` so a writer can't overwrite someone else's doc or reassign its author: `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw { forbidden: "not author" }`, where `<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, ). (Write-once docs can simply `if (oldDoc) throw`.)
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 require every field EXCEPT the ImgGen-owned version fields to match `oldDoc` — an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of a few named content fields silently unprotects the rest):
811
+
812
+ ```js
813
+ const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
814
+ if (!oldDoc) {
815
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
816
+ } else {
817
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
818
+ const changed = Object.keys({ ...doc, ...oldDoc }).some(
819
+ (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
820
+ );
821
+ if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
822
+ }
823
+ ```
824
+
825
+ `<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …); `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes. Write-once docs can simply `if (oldDoc) throw`; a genuinely private per-user doc no other viewer can reach may stay author-only on update.
791
826
 
792
827
  ```ts
793
828
  type AccessDescriptor = {
@@ -846,8 +881,18 @@ export function chat(doc, oldDoc, user, ctx) {
846
881
  }
847
882
 
848
883
  if (doc.type === "message") {
849
- if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
850
- if (oldDoc && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
884
+ // Author fixed at create; ownership immutable. On update a non-author may
885
+ // touch ONLY the ImgGen version fields every other key must match oldDoc.
886
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
887
+ if (!oldDoc) {
888
+ if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
889
+ } else {
890
+ if (doc.userHandle !== oldDoc.userHandle) throw { forbidden: "cannot change author" };
891
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
892
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
893
+ );
894
+ if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
895
+ }
851
896
  ctx.requireAccess(doc.channelId);
852
897
  return { channels: [doc.channelId] };
853
898
  }
package/llms/image-gen.md CHANGED
@@ -64,7 +64,7 @@ The input image is automatically resized (max 1024px) and compressed as JPEG bef
64
64
 
65
65
  Do **not** invent wrappers like `images={x?.file ? something : undefined}` — pass the `_files` entry itself. If the stored ref might be absent, gate the whole `<ImgGen>` mount on it instead: `{doc._files?.photo && <ImgGen images={[doc._files.photo]} ... />}`.
66
66
 
67
- **Displaying an img2img result later** (gallery, detail view, reload): render by `_id` and do NOT re-pass `images` — `<ImgGen _id={doc._id} database={database} showControls={false} />` shows the stored version. Passing `images` on a doc that already has one forces a fresh generation instead of displaying the saved result. Generate once with `images`, display forever with `_id` alone.
67
+ **Displaying an img2img result later** (gallery, detail view, reload): render by `_id` and do NOT re-pass `images` — `<ImgGen _id={doc._id} database={database} showControls={false} />` shows the stored version. ImgGen records which input produced each version, so re-passing that SAME input (the doc's own stored source, e.g. `images={[doc._files.photo]}` on every render) or `_id` alone just displays the saved result and will not force a fresh, billed generation. Passing a genuinely DIFFERENT input to the same `_id` DOES generate a new version — that's a requested img2img edit, not a reload: a new upload, a different stored ref, or a PRIOR OUTPUT fed back in to refine it. To force a new version from the CURRENT input, use the regenerate control (or set `generationId`). Generate once with `images`, display forever with `_id` alone.
68
68
 
69
69
  Do **not** set `model` for img2img: when an input image is present the platform automatically selects its image-**edit** default, which is tuned to produce edits faithful to the source. An explicit `model` override bypasses that routing — only use one when the app has a specific, stated reason.
70
70
 
@@ -148,6 +148,23 @@ 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 require every field EXCEPT the ImgGen-owned version fields to match `oldDoc` — an allowlist, so a field you didn't anticipate stays author-protected by default:
152
+
153
+ ```js
154
+ const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
155
+ if (!oldDoc) {
156
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
157
+ } else {
158
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
159
+ const changed = Object.keys({ ...doc, ...oldDoc }).some(
160
+ (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
161
+ );
162
+ if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
163
+ }
164
+ ```
165
+
166
+ `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes, so a viewer's version append passes while any other field stays author-owned.
167
+
151
168
  ## Choosing a Model
152
169
 
153
170
  Override the model per component: `<ImgGen prompt="An astronaut riding a horse" model="openai/gpt-5-image-mini" />`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "6.2.0",
3
+ "version": "6.2.2",
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.0",
28
- "@vibes.diy/identity": "^6.2.0",
29
- "@vibes.diy/use-vibes-types": "^6.2.0",
27
+ "@vibes.diy/call-ai-v2": "^6.2.2",
28
+ "@vibes.diy/identity": "^6.2.2",
29
+ "@vibes.diy/use-vibes-types": "^6.2.2",
30
30
  "arktype": "~2.2.3",
31
31
  "json-schema-faker": "~0.6.2"
32
32
  },
@@ -74,8 +74,19 @@ export function wall(doc, oldDoc, user, ctx) {
74
74
  }
75
75
 
76
76
  if (doc.type === "post") {
77
- if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
78
- if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
77
+ // Author fixed at create; ownership immutable. On update a non-author may
78
+ // touch ONLY the ImgGen version fields every other key must match oldDoc
79
+ // (an allowlist, so unlisted fields stay author-owned by default).
80
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
81
+ if (!oldDoc) {
82
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
83
+ } else {
84
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
85
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
86
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
87
+ );
88
+ if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
89
+ }
79
90
  return { channels: [doc.channelId] };
80
91
  }
81
92
 
@@ -110,7 +121,7 @@ export function board(doc, oldDoc, user, ctx) {
110
121
  }
111
122
  ```
112
123
 
113
- `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, also check the old author field (`oldDoc.authorHandle`/`userHandle`/`senderHandle`) so a writer can't overwrite or re-author someone else's doc. Apply this to **every** doc type the app writes posts, comments, and image/file docs (`doc._files`) alike: a photo or upload is author-owned just like a post, and gets the same `authorHandle` create + `oldDoc` author checks so one person can't replace another's.
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 change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `wall.post` above shows never a denylist of a few named content fields).
114
125
 
115
126
  **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.
116
127
 
@@ -214,6 +225,24 @@ docs in your database, and never build follow UI state machines.
214
225
 
215
226
  **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.
216
227
 
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 require every field EXCEPT the ImgGen-owned version fields to be unchanged — an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
229
+
230
+ ```js
231
+ // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
232
+ const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
233
+ if (!oldDoc) {
234
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
235
+ } else {
236
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
237
+ const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
238
+ (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
239
+ );
240
+ if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
241
+ }
242
+ ```
243
+
244
+ Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` — it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
245
+
217
246
  **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.
218
247
 
219
248
  **Never put access function code inside an `App.jsx` block** — it will overwrite the React component. The filename line (e.g. `access.js` vs `App.jsx`) is how the system knows which file to write.
@@ -75,8 +75,19 @@ export function wall(doc, oldDoc, user, ctx) {
75
75
  }
76
76
 
77
77
  if (doc.type === "post") {
78
- if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
79
- if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
78
+ // Author fixed at create; ownership immutable. On update a non-author may
79
+ // touch ONLY the ImgGen version fields every other key must match oldDoc
80
+ // (an allowlist, so unlisted fields stay author-owned by default).
81
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
82
+ if (!oldDoc) {
83
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
84
+ } else {
85
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
86
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
87
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
88
+ );
89
+ if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
90
+ }
80
91
  return { channels: [doc.channelId] };
81
92
  }
82
93
 
@@ -111,7 +122,7 @@ export function board(doc, oldDoc, user, ctx) {
111
122
  }
112
123
  ```
113
124
 
114
- `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, also check the old author field (`oldDoc.authorHandle`/`userHandle`/`senderHandle`) so a writer can't overwrite or re-author someone else's doc. Apply this to **every** doc type the app writes posts, comments, and image/file docs (`doc._files`) alike: a photo or upload is author-owned just like a post, and gets the same `authorHandle` create + `oldDoc` author checks so one person can't replace another's.
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 change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `wall.post` above shows never a denylist of a few named content fields).
115
126
 
116
127
  **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.
117
128
 
@@ -215,6 +226,24 @@ docs in your database, and never build follow UI state machines.
215
226
 
216
227
  **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.
217
228
 
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 require every field EXCEPT the ImgGen-owned version fields to be unchanged — an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
230
+
231
+ ```js
232
+ // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
233
+ const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
234
+ if (!oldDoc) {
235
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
236
+ } else {
237
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
238
+ const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
239
+ (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
240
+ );
241
+ if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
242
+ }
243
+ ```
244
+
245
+ Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` — it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
246
+
218
247
  **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.
219
248
 
220
249
  **Never put access function code inside an `App.jsx` block** — it will overwrite the React component. The filename line (e.g. `access.js` vs `App.jsx`) is how the system knows which file to write.
package/system-prompt.md CHANGED
@@ -285,8 +285,19 @@ export function chat(doc, oldDoc, user, ctx) {
285
285
  if (!user) throw { forbidden: "authentication required" };
286
286
 
287
287
  if (doc.type === "message") {
288
- if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
289
- if (oldDoc && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
288
+ // Author fixed at create; ownership immutable. On update a non-author may
289
+ // touch ONLY the ImgGen version fields every other key must match oldDoc
290
+ // (an allowlist, so unlisted fields stay author-owned by default).
291
+ const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
292
+ if (!oldDoc) {
293
+ if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
294
+ } else {
295
+ if (doc.userHandle !== oldDoc.userHandle) throw { forbidden: "cannot change author" };
296
+ const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
297
+ (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
298
+ );
299
+ if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
300
+ }
290
301
  ctx.requireAccess(doc.channelId);
291
302
  return { channels: [doc.channelId] };
292
303
  }
@@ -295,7 +306,7 @@ export function chat(doc, oldDoc, user, ctx) {
295
306
  }
296
307
  ```
297
308
 
298
- `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, also check `oldDoc` (`if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) so a writer can't overwrite or re-author someone else's doc. See the fireproof access docs.
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 change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `chat.message` above shows — never a denylist of a few named content fields). See the fireproof access docs.
299
310
 
300
311
  **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.
301
312
 
@@ -423,6 +434,24 @@ docs in your database, and never build follow UI state machines.
423
434
 
424
435
  **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.
425
436
 
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 require every field EXCEPT the ImgGen-owned version fields to be unchanged — an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
438
+
439
+ ```js
440
+ // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
441
+ const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
442
+ if (!oldDoc) {
443
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
444
+ } else {
445
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
446
+ const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
447
+ (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
448
+ );
449
+ if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
450
+ }
451
+ ```
452
+
453
+ Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` — it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
454
+
426
455
  **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.
427
456
 
428
457
  Example streamed output for a team board app:
@@ -491,8 +520,19 @@ Example streamed output for a team board app:
491
520
  > }
492
521
  >
493
522
  > if (doc.type === "post") {
494
- > if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
495
- > if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
523
+ > // Author fixed at create; on update a non-author may touch only the ImgGen
524
+ > // version fields (every other key must match oldDoc).
525
+ > const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
526
+ > if (!oldDoc) {
527
+ > if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
528
+ > } else if (doc.authorHandle !== oldDoc.authorHandle) {
529
+ > 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
+ > ) {
534
+ > throw { forbidden: "not author" };
535
+ > }
496
536
  > return { channels: [doc.channelId] };
497
537
  > }
498
538
  >