@vibes.diy/prompts 6.2.1 → 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 +55 -10
- package/llms/image-gen.md +18 -1
- package/package.json +4 -4
- package/system-prompt-initial-oneshot.md +32 -3
- package/system-prompt-initial.md +32 -3
- package/system-prompt.md +45 -5
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
|
-
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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" }
|
|
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
|
-
**
|
|
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
|
-
|
|
850
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
28
|
-
"@vibes.diy/identity": "^6.2.
|
|
29
|
-
"@vibes.diy/use-vibes-types": "^6.2.
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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.
|
package/system-prompt-initial.md
CHANGED
|
@@ -75,8 +75,19 @@ export function wall(doc, oldDoc, user, ctx) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
if (doc.type === "post") {
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
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,
|
|
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
|
-
>
|
|
495
|
-
>
|
|
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
|
>
|