@vibes.diy/prompts 2.5.14 → 2.5.16

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
@@ -420,11 +420,22 @@ export function announcements(doc, oldDoc, user, ctx) {
420
420
 
421
421
  if (doc.type === "roleGrant") {
422
422
  if (!user.isOwner) throw { forbidden: "owner only" };
423
- return { members: { [doc.role]: [doc.userHandle] } };
423
+ // A grant doc must ALSO route to a channel — a result with no `channels`
424
+ // (only `members`/`grant`) is rejected as an "unreadable write". Route it to
425
+ // an owner-readable admin channel (not a public one) so the grant persists
426
+ // and the owner can read the roster back; the membership then applies.
427
+ return {
428
+ channels: ["admin:grants"],
429
+ members: { [doc.role]: [doc.userHandle] },
430
+ grant: { users: { [user.userHandle]: ["admin:grants"] } },
431
+ };
424
432
  }
425
433
 
426
434
  if (doc.type === "post") {
427
435
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
436
+ // On update, the original author must be preserved — never let a writer
437
+ // overwrite someone else's doc or reassign its author.
438
+ if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
428
439
  ctx.requireAccess(doc.channel);
429
440
  return { channels: [doc.channel] };
430
441
  }
@@ -510,6 +521,35 @@ export default function App() {
510
521
 
511
522
  The pattern: `useVibe().can` gates every write surface — including owner-only management, which the access function enforces via `if (!user.isOwner) throw` so `can.create({ type: "roleGrant", … })` is false for non-owners. `access.hasChannel()` reflects display-only membership, and `isOwner` is a display hint only, never the write gate. The access function is the server-side authority — `useVibe().can` is how the UI reflects its decisions for writes.
512
523
 
524
+ **Owner-management panels (appoint/revoke moderators, grant/revoke roles) gate on `can.*`, not `isOwner`.** It's tempting to wrap an admin panel in `{isOwner && <ModeratorPanel />}` and have its buttons call `database.put`/`database.del` directly. Don't let `isOwner` be what decides a write — it's the same display hint as everywhere else. Gate each mutating control on the verdict from the doc you'll actually write — appoint on `can.create({ type: "modGrant", role: "moderator", userHandle }).ok`, revoke on `can.delete(grantDoc).ok` — and render `.reason` when denied. `can.*` runs the app's own `access.js` to produce the verdict, so the control's enabled state and message track what the access function decides and stay correct as it grows beyond owner-only (a delegated admin role, say). Gate the panel's *visibility* on those same verdicts too, not `isOwner` alone — otherwise a delegated admin the access function now allows never reaches the controls or their reason.
525
+
526
+ ```jsx
527
+ // Don't: isOwner decides visibility AND the writes are ungated — a non-owner the
528
+ // access function would allow never reaches the controls, and isOwner drifts from access.js
529
+ {isOwner && (
530
+ <ModeratorPanel>
531
+ <button onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</button>
532
+ <button onClick={() => database.del(grantDoc._id)}>Revoke</button>
533
+ </ModeratorPanel>
534
+ )}
535
+
536
+ // Do: can.* decides visibility AND gates each write, and supplies the denial reason
537
+ const canAppoint = can.create({ type: "modGrant", role: "moderator", userHandle });
538
+ const canRevoke = can.delete(grantDoc);
539
+ {(canAppoint.ok || canRevoke.ok) && (
540
+ <ModeratorPanel>
541
+ {canAppoint.ok && (
542
+ <button onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</button>
543
+ )}
544
+ {canRevoke.ok ? (
545
+ <button onClick={() => database.del(grantDoc._id)}>Revoke</button>
546
+ ) : (
547
+ <p>{canRevoke.reason}</p>
548
+ )}
549
+ </ModeratorPanel>
550
+ )}
551
+ ```
552
+
513
553
  ### Example: Channel board with open channels (any member posts)
514
554
 
515
555
  Channels everyone can read, and any signed-in user can post to. The channel doc is `grant.public` (read for everyone) and the post rule checks only the author — **no `ctx.requireAccess`**, because public is read-only and would block every non-owner. (For a members-only board where the owner appoints who can post, grant a poster role and `requireAccess` it, as in the announcements example above.) The UI uses `access.hasChannel()` to filter which channels to display, and `useVibe().can` to gate writes.
@@ -529,6 +569,7 @@ export function chat(doc, oldDoc, user, ctx) {
529
569
 
530
570
  if (doc.type === "post") {
531
571
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
572
+ if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
532
573
  // Any signed-in author may post to this open channel. Do NOT call
533
574
  // ctx.requireAccess(doc.channel) here: the channel is grant.public
534
575
  // (read-only), which never satisfies requireAccess, so gating on it would
@@ -589,12 +630,16 @@ Access functions live in `/access.js`, a separate file in the vibe's filesystem
589
630
 
590
631
  **`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:
591
632
 
592
- - **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" }; return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
633
+ - **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.
593
634
  - **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.
594
635
 
595
636
  ### AccessDescriptor return type
596
637
 
597
- All fields are optional, but a stored document must be routed to at least one channel (`channels`) to be readable — a result with no channels is refused at write time. To reject a write outright, `throw { forbidden: "reason" }`.
638
+ All fields are optional, but a stored document must be routed to at least one channel (`channels`) to be readable — a result with no channels is refused at write time ("unreadable write"). To reject a write outright, `throw { forbidden: "reason" }`.
639
+
640
+ **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.
641
+
642
+ **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`.)
598
643
 
599
644
  ```ts
600
645
  type AccessDescriptor = {
@@ -654,12 +699,14 @@ export function chat(doc, oldDoc, user, ctx) {
654
699
 
655
700
  if (doc.type === "message") {
656
701
  if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
702
+ if (oldDoc && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
657
703
  ctx.requireAccess(doc.channelId);
658
704
  return { channels: [doc.channelId] };
659
705
  }
660
706
 
661
707
  if (doc.type === "channel-invite") {
662
708
  if (doc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
709
+ if (oldDoc && oldDoc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
663
710
  ctx.requireAccess(doc.channelId);
664
711
  return {
665
712
  channels: [doc.channelId],
@@ -686,11 +733,13 @@ export function survey(doc, oldDoc, user, ctx) {
686
733
 
687
734
  if (doc.type === "survey-config") {
688
735
  if (!user.isOwner) throw { forbidden: "owner only" };
736
+ // Route this grant/config doc to an owner-readable admin channel — a
737
+ // grant-only result (no `channels`) is rejected as an "unreadable write".
689
738
  return {
739
+ channels: ["admin:grants"],
690
740
  grant: {
691
- roles: {
692
- "feedback-team": ["inbound-responses"],
693
- },
741
+ users: { [user.userHandle]: ["admin:grants"] },
742
+ roles: { "feedback-team": ["inbound-responses"] },
694
743
  },
695
744
  };
696
745
  }
@@ -801,16 +850,24 @@ export function chat(doc, oldDoc, user, ctx) {
801
850
  export function chat(doc, oldDoc, user, ctx) {
802
851
  if (!user) throw { forbidden: "authentication required" };
803
852
 
853
+ // Grant/meta docs must also route to a channel — a channel-less result is
854
+ // rejected as "unreadable write". Route them to an owner-readable admin channel.
804
855
  if (doc.type === "team-meta") {
805
856
  if (!user.isOwner) throw { forbidden: "owner only" };
806
857
  return {
858
+ channels: ["admin:grants"],
807
859
  members: { [doc.teamId]: doc.memberHandles },
808
- grant: { roles: { [doc.teamId]: doc.channels } },
860
+ grant: { users: { [user.userHandle]: ["admin:grants"] }, roles: { [doc.teamId]: doc.channels } },
809
861
  };
810
862
  }
811
863
 
812
864
  if (doc.type === "membership") {
813
- return { members: { [doc.role]: [doc.userHandle] } };
865
+ if (!user.isOwner) throw { forbidden: "owner only" };
866
+ return {
867
+ channels: ["admin:grants"],
868
+ members: { [doc.role]: [doc.userHandle] },
869
+ grant: { users: { [user.userHandle]: ["admin:grants"] } },
870
+ };
814
871
  }
815
872
 
816
873
  ctx.requireAccess(doc.channelId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "2.5.14",
3
+ "version": "2.5.16",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "description": "",
@@ -30,9 +30,9 @@
30
30
  "@fireproof/core-types-base": "~0.24.19",
31
31
  "@fireproof/core-types-protocols-cloud": "~0.24.19",
32
32
  "@fireproof/use-fireproof": "~0.24.19",
33
- "@vibes.diy/call-ai-v2": "^2.5.14",
34
- "@vibes.diy/identity": "^2.5.14",
35
- "@vibes.diy/use-vibes-types": "^2.5.14",
33
+ "@vibes.diy/call-ai-v2": "^2.5.16",
34
+ "@vibes.diy/identity": "^2.5.16",
35
+ "@vibes.diy/use-vibes-types": "^2.5.16",
36
36
  "arktype": "~2.2.1",
37
37
  "json-schema-faker": "~0.6.2"
38
38
  },
@@ -68,13 +68,14 @@ Target ~40–60 lines. The shell should look like a real app with empty sections
68
68
  > }
69
69
  > if (doc.type === "message") {
70
70
  > if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
71
+ > if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
71
72
  > return { channels: [doc.channelId] };
72
73
  > }
73
74
  > throw { forbidden: "unknown document type" };
74
75
  > }
75
76
  > ```
76
77
 
77
- `ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open channel 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`.
78
+ `ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open channel 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/member doc must also return `channels` (route it to an owner-readable admin channel) — 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.
78
79
 
79
80
  **Never put access function code inside an `App.jsx` block** — it will overwrite the React component. The filename line (`access.js` vs `App.jsx`) is how the system knows which file to write.
80
81
 
package/system-prompt.md CHANGED
@@ -275,6 +275,7 @@ When the app uses channel-based read isolation or per-document write validation,
275
275
  > if (!user) throw { forbidden: "authentication required" };
276
276
  > if (doc.type === "message") {
277
277
  > if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
278
+ > if (oldDoc && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
278
279
  > ctx.requireAccess(doc.channelId);
279
280
  > return { channels: [doc.channelId] };
280
281
  > }
@@ -282,7 +283,7 @@ When the app uses channel-based read isolation or per-document write validation,
282
283
  > }
283
284
  > ```
284
285
 
285
- `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`. See the fireproof access docs.
286
+ `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.
286
287
 
287
288
  **Never put access function code inside an `App.jsx` block** — it will overwrite the React component. The filename line (`access.js` vs `App.jsx`) is how the system knows which file to write.
288
289
 
@@ -417,6 +418,7 @@ Example streamed output for a team board app:
417
418
  >
418
419
  > if (doc.type === "post") {
419
420
  > if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
421
+ > if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
420
422
  > return { channels: [doc.channelId] };
421
423
  > }
422
424
  >