@vibes.diy/prompts 8.1.1 → 8.2.0
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/access.d.ts +2 -0
- package/llms/access.js +57 -0
- package/llms/access.js.map +1 -0
- package/llms/access.md +788 -0
- package/llms/fireproof.md +2 -584
- package/llms/index.d.ts +2 -1
- package/llms/index.js +3 -0
- package/llms/index.js.map +1 -1
- package/package.json +4 -4
- package/system-prompt-initial-oneshot.md +2 -154
- package/system-prompt-initial.md +2 -151
- package/system-prompt.md +4 -49
package/llms/access.md
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
# Access Control (`access.js`) — permission design
|
|
2
|
+
|
|
3
|
+
You are seeing this doc because the app's prompt is **permission-shaped** — it asks about privacy, sharing, teams, members, roles, DMs, approval, or who-can-see-what. Use it to design the app's `access.js`.
|
|
4
|
+
|
|
5
|
+
**Honest default first:** all app data is shared and world-readable by default; the runtime, not your code, decides access. Add an `access.js` only when the app genuinely needs per-document write validation or channel-based read isolation. Never write UI copy that promises privacy the access model doesn't enforce — describe what the access rules actually do.
|
|
6
|
+
|
|
7
|
+
`access.js` is a separate file alongside `App.jsx`; each **named export** gates the database of the same name (`export function chat(...)` gates `useFireproof("chat")`), and an `export default` acts as a catch-all. The `App.jsx` you write gates its write surfaces on `useVibe(dbName).can` — the same function this access.js enforces server-side. See the fireproof docs for the `access.js` function signature, `AccessDescriptor` return type, and channel/grant reference.
|
|
8
|
+
|
|
9
|
+
## When to emit access.js, and where it goes
|
|
10
|
+
|
|
11
|
+
The **placement** of `access.js` depends on which turn shape you are in — one-shot whole-app generation vs. incremental scaffold/follow-up edits. Follow the subsection that matches your turn; the other one's ordering is wrong for you and will strand writes.
|
|
12
|
+
|
|
13
|
+
**Variant-neutral rules (always):** write `access.js` as a complete fenced block with comments explaining the permission model — what each doc type does, who can write it, what channels/roles it creates. **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. Whatever the turn shape, `App.jsx` gates its write surfaces on `useVibe(dbName).can` — the same rules this access function enforces.
|
|
14
|
+
|
|
15
|
+
**One-shot generation (the whole app in one full-file block):** emit `access.js` as the **last file of the turn** — after the complete `App.jsx` and any companion feature files. You are NOT emitting incremental `SEARCH`/`REPLACE` edits here — write the finished files in full. The worked examples below show the access-function shapes; ignore any edit-by-edit cadence in them and emit the access function as one complete block.
|
|
16
|
+
|
|
17
|
+
**Scaffold or follow-up turns (incremental `SEARCH`/`REPLACE` edits):** emit `access.js` **early** — for a fresh scaffold, right after the shell and before any feature edits; on a follow-up turn, **before** any `App.jsx` edit that writes a doc type it gates. Ordering matters here because the app runs live while edits stream in: a `db.put` of a doc type the in-force access.js still rejects fails immediately with `unknown document type`, so the access branch must land before the write. Emitting it first also commits the permission design so every subsequent edit can gate its write surfaces on `useVibe(dbName).can`.
|
|
18
|
+
|
|
19
|
+
Worked example — members-only chat writes
|
|
20
|
+
|
|
21
|
+
access.js
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
25
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
26
|
+
|
|
27
|
+
if (doc.type === "message") {
|
|
28
|
+
// Author fixed at create; ownership immutable. On update a non-author may
|
|
29
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
30
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
31
|
+
if (oldDoc === null) {
|
|
32
|
+
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
33
|
+
} else if (doc.userHandle !== oldDoc.userHandle) {
|
|
34
|
+
throw { forbidden: "cannot change author" };
|
|
35
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
|
|
36
|
+
throw { forbidden: "not author" };
|
|
37
|
+
}
|
|
38
|
+
ctx.requireAccess(doc.channelId);
|
|
39
|
+
return { channels: [doc.channelId] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw { forbidden: "unknown document type" };
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `chat.message` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
|
|
47
|
+
|
|
48
|
+
**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.
|
|
49
|
+
|
|
50
|
+
## Worked examples — permission design
|
|
51
|
+
|
|
52
|
+
### Worked example — open channel wall (author-owned writes)
|
|
53
|
+
|
|
54
|
+
access.js
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
export function wall(doc, oldDoc, user, ctx) {
|
|
58
|
+
if (!user) throw { forbidden: "sign in" };
|
|
59
|
+
|
|
60
|
+
if (doc.type === "channel") {
|
|
61
|
+
ctx.requireRole("owner");
|
|
62
|
+
return { channels: [doc._id], grant: { public: [doc._id] } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (doc.type === "post") {
|
|
66
|
+
// Author fixed at create; ownership immutable. On update a non-author may
|
|
67
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
68
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
69
|
+
if (oldDoc === null) {
|
|
70
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
71
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
72
|
+
throw { forbidden: "cannot change author" };
|
|
73
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
74
|
+
throw { forbidden: "not author" };
|
|
75
|
+
}
|
|
76
|
+
return { channels: [doc.channelId] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw { forbidden: "unknown document type" };
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Worked example — per-object collaboration with join request
|
|
84
|
+
|
|
85
|
+
access.js
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
export function board(doc, oldDoc, user, ctx) {
|
|
89
|
+
if (!user) throw { forbidden: "sign in" };
|
|
90
|
+
const channel = `board:${doc.boardId}`;
|
|
91
|
+
|
|
92
|
+
if (doc.type === "board") {
|
|
93
|
+
if (oldDoc && doc.author !== oldDoc.author) throw { forbidden: "creator is fixed" };
|
|
94
|
+
return { channels: [channel], grant: { users: { [user.userHandle]: [channel] } } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (doc.type === "share") {
|
|
98
|
+
ctx.requireAccess(channel);
|
|
99
|
+
return { channels: [channel], grant: { users: { [doc.invitee]: [channel] } } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (doc.type === "request") return { channels: [channel] };
|
|
103
|
+
|
|
104
|
+
ctx.requireAccess(channel);
|
|
105
|
+
if (oldDoc && oldDoc.boardId !== doc.boardId) throw { forbidden: "item stays on its board" };
|
|
106
|
+
return { channels: [channel] };
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
|
|
111
|
+
|
|
112
|
+
**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.
|
|
113
|
+
|
|
114
|
+
**A personal list, tracker, journal, or notes app — one the prompt frames as the user's own, with no sharing asked for — gives every visitor their own private space on a single per-user channel.** A todo list, a daily habit tracker, a reading list, a diary, a notes app, a workout log, or a budget where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle — `user:${user.userHandle}` — self-granted so only they read it, with `authorHandle: user.userHandle` fixed at create and held immutable on update (`oldDoc.authorHandle === user.userHandle`). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
|
|
115
|
+
|
|
116
|
+
access.js
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
export function notes(doc, oldDoc, user, ctx) {
|
|
120
|
+
if (!user) throw { forbidden: "sign in" };
|
|
121
|
+
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
122
|
+
|
|
123
|
+
if (doc.type === "note") {
|
|
124
|
+
if (!oldDoc) {
|
|
125
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
126
|
+
} else {
|
|
127
|
+
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
|
|
128
|
+
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
129
|
+
}
|
|
130
|
+
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw { forbidden: "unknown document type" };
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small "Only you can see this" cue near their content. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way. The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
|
|
138
|
+
|
|
139
|
+
**When the prompt asks to share, invite, or collaborate — bring in a partner, a buddy, a friend, a team, or a group that co-edits — each shared thing becomes its own object others can be invited into.** A shopping list you invite your partner to, a board a group co-edits, a document you open to a collaborator: route that shared thing to its **own object channel** (`list:<id>`/`board:<id>`) and self-grant it at creation (`grant: { users: { [user.userHandle]: [ch] } }`), with the `authorHandle` create + `oldDoc` author checks so each item stays on its object. Then let the creator invite a chosen friend in: a `share` doc the creator authors grants that friend the same channel (`grant: { users: { [doc.invitee]: [ch] } }`), so they collaborate on that one list — sharing a single list or a whole space is the same grant at a different node of the object graph. A wall, guestbook, or map where each visitor adds their _own_ items is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
|
|
140
|
+
|
|
141
|
+
**When a user's work is private by default, show it — and offer a way to publish.** If everything a user does routes to a channel only they can read (a per-user `user:<handle>`, a private journal/notes/tracker with no `grant.public`), the UI must say so: a small, persistent "Only you can see this" / "Private to you" cue near their content, so no one wonders who's watching their unfinished work. Then, where sharing fits the app, give them a publish control — but publish at the granularity of a **channel**, not a doc: channels are the unit of read isolation, so adding `grant.public` to the shared `user:<handle>` channel would expose _every_ private item on it, not just the one they meant to share. To publish a single item, route it to its **own** channel and flip only that channel's read-grant from a `visibility` field the access fn reads — exactly the per-item-channel shape the worked example below uses (`const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];`) — for anonymous visitors, or grant a shared app channel for all granted members. Gate the control on `useVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). Making the user's _whole_ space public is fine when that's the intent; silently leaking their other private items by publishing one is the trap to avoid. **If the app brief is private-only/confidential — a journal, private notes, a health tracker — do not add publish/share controls.** Keep publish opt-in: for private-only apps, omit the publish UI entirely; for share-capable apps, make publish one tap for user-selected items.
|
|
142
|
+
|
|
143
|
+
**Worked example — a shared catalog people track against, with per-thing visibility (a social habit app, a reading challenge, a fitness ladder).** The catalog items are public objects anyone proposes; each person's progress is their own; and each person chooses — _once per item, never per entry_ — whether their streak is public (on the leaderboard) or buddy-only. The visibility choice lives on a per-`(person, item)` **tracking** record that sets the read-grant the entries routed to it inherit.
|
|
144
|
+
|
|
145
|
+
access.js
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
export function habits(doc, oldDoc, user, ctx) {
|
|
149
|
+
if (!user) throw { forbidden: "sign in" };
|
|
150
|
+
|
|
151
|
+
// A habit: a public object anyone proposes; everyone can read and adopt it.
|
|
152
|
+
if (doc.type === "habit") {
|
|
153
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
154
|
+
if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "creator is fixed" };
|
|
155
|
+
return { channels: [`habit:${doc._id}`], grant: { public: [`habit:${doc._id}`] } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A tracking record: your enrollment in a habit + ONE visibility choice. It sets the
|
|
159
|
+
// read-grant on your per-habit channel, so the check-ins routed there inherit it.
|
|
160
|
+
if (doc.type === "tracking") {
|
|
161
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
162
|
+
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
163
|
+
const ch = `track:${doc.habitId}:${user.userHandle}`;
|
|
164
|
+
const grant = { users: { [user.userHandle]: [ch] } }; // always yourself
|
|
165
|
+
if (doc.visibility === "public") grant.public = [ch]; // public -> counts on the leaderboard
|
|
166
|
+
else if (doc.buddyHandle) grant.users[doc.buddyHandle] = [ch]; // buddy-only -> you + your buddy
|
|
167
|
+
return { channels: [ch], grant };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A check-in: author-owned, routed to your per-habit channel — it inherits that habit's visibility.
|
|
171
|
+
if (doc.type === "checkin") {
|
|
172
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
173
|
+
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
174
|
+
return { channels: [`track:${doc.habitId}:${user.userHandle}`] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw { forbidden: "unknown document type" };
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. A **plain daily habit tracker** — one framed as the user's own, with no catalog, leaderboard, or buddy asked for — is instead the per-visitor shape shown above: every visitor's habits and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
|
|
182
|
+
|
|
183
|
+
**"Invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or a board/canvas/room/whiteboard a group co-edits → per-object collaboration** (the second worked example above) — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object (`board:<id>`/`list:<id>`); the creator self-grants at creation (`grant: { users: { [user.userHandle]: [ch] } }`); child docs gate on `ctx.requireAccess(ch)` so any member edits any child in it (not just their own); a member-authored `share` doc grants a peer the same channel; a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable. **Two traps to avoid:** don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request).
|
|
184
|
+
|
|
185
|
+
**Ownership is just the object graph** — whoever authored or created a doc owns it (`doc.authorHandle === user.userHandle`, checked against `oldDoc` on updates). There's no broadcaster shape to reach for by default, and **owner-only publishing is a dead end** — never gate the content itself on `requireRole("owner")`. **A blog, magazine, or publication is public read + author-owned posts, with the owner controlling the _author roster_:** the owner approves authors with a grant doc (`if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["blog:authors"], grant: { users: { [doc.authorHandle]: ["blog:authors"] }, roles: { owner: ["blog:authors"] } } }; }` — the **one** place `requireRole("owner")` belongs, gating who may author, never the posts); a post then gates on `ctx.requireAccess("blog:authors")` (membership) and is author-owned (`doc.authorHandle === user.userHandle` + the `oldDoc` check), so once approved each author's post is _their own_ object — only they edit it, and they moderate the comments on it (a comment is allowed if it's your comment **or** you own the post: `doc.authorHandle === user.userHandle || doc.postAuthorHandle === user.userHandle`). A personal blog is just this with a roster of one. Always gate write UI on `useVibe(dbName).can`.
|
|
186
|
+
|
|
187
|
+
The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
if (doc.type === "post") {
|
|
191
|
+
if (!oldDoc) {
|
|
192
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
193
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
194
|
+
return { channels: ["posts"], grant: { public: ["posts"] } };
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (`draft:<handle>`, granted just to the author) — publishing moves the doc to the public `posts` channel.
|
|
199
|
+
|
|
200
|
+
**Sharing objects: channels carry objects, roles carry types.** A channel is one shareable thing (`list:<id>`) — membership in it means "can reach this object." A role is a _kind_ of participant (`author`, `editor`), a small reusable vocabulary. To let people collaborate on _their own_ objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on `ctx.requireAccess("list:<id>")`. The reserved `owner` role is the only one auto-seeded — every _other_ role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a `request` doc a not-yet-member authors (taking **no** `requireRole`) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
|
|
201
|
+
|
|
202
|
+
## Reading Resolved Grants (`access`)
|
|
203
|
+
|
|
204
|
+
`useFireproof()` returns an `access` property — the viewer's resolved roles and channels for that database, computed server-side from the access function's `members` and `grant` declarations. Use `access.roles` (ReadonlySet), `access.channels` (ReadonlySet), `access.hasRole(name)`, and `access.hasChannel(name)`. Use these to reflect roles/channels in the UI; gate writes with `useVibe(dbName).can`.
|
|
205
|
+
|
|
206
|
+
For databases without an access function export, `access` has empty roles and channels. No separate pending flag — grants arrive alongside the viewer identity, so `useViewer().isViewerPending` covers both.
|
|
207
|
+
|
|
208
|
+
App.jsx
|
|
209
|
+
|
|
210
|
+
```jsx
|
|
211
|
+
<<<<<<< SEARCH
|
|
212
|
+
import { useFireproof } from "use-fireproof";
|
|
213
|
+
=======
|
|
214
|
+
import { useFireproof } from "use-fireproof";
|
|
215
|
+
import { useVibe } from "use-vibes";
|
|
216
|
+
>>>>>>> REPLACE
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
App.jsx
|
|
220
|
+
|
|
221
|
+
```jsx
|
|
222
|
+
<<<<<<< SEARCH
|
|
223
|
+
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
224
|
+
acl: { write: ["members"], delete: ["editors"] },
|
|
225
|
+
});
|
|
226
|
+
=======
|
|
227
|
+
const { database, useLiveQuery, access } = useFireproof("comments");
|
|
228
|
+
const { can, me } = useVibe("comments");
|
|
229
|
+
>>>>>>> REPLACE
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
App.jsx
|
|
233
|
+
|
|
234
|
+
```jsx
|
|
235
|
+
<<<<<<< SEARCH
|
|
236
|
+
<h3>Recent Documents</h3>
|
|
237
|
+
<ul>
|
|
238
|
+
{docs.map((doc) => (
|
|
239
|
+
<li key={doc._id}>
|
|
240
|
+
{doc.text}
|
|
241
|
+
<button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
|
|
242
|
+
{doc.favorite ? "★" : "☆"}
|
|
243
|
+
</button>
|
|
244
|
+
</li>
|
|
245
|
+
))}
|
|
246
|
+
</ul>
|
|
247
|
+
=======
|
|
248
|
+
{/* gate writes with useVibe().can, not access.* — and gate the SAME db you write to */}
|
|
249
|
+
{can.create({ type: "comment", authorHandle: me?.userHandle }).ok && <CommentForm database={database} />}
|
|
250
|
+
{access.hasRole("moderator") && <ModToolsBadge />}
|
|
251
|
+
{access.hasChannel("announcements") && <Announcements />}
|
|
252
|
+
>>>>>>> REPLACE
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The AI agent writes the access function (so it knows the role names) and writes the UI (so it knows which roles gate which components). The `access` object is the bridge — it lets the UI reflect server-enforced permissions without duplicating the logic.
|
|
256
|
+
|
|
257
|
+
The access function is the single source of truth for permissions. Gate write surfaces with `useVibe(dbName).can`, which runs this same access function. The `access` object (`access.hasRole(name)`, `access.hasChannel(name)`) reflects the viewer's resolved roles/channels for DISPLAY — role badges, showing/hiding read-only sections — not as the write gate.
|
|
258
|
+
|
|
259
|
+
`access.hasChannel()` covers every grant path — public channels, restricted channels, role-expanded channels. The access function decides who gets access and how; the UI uses `access.hasChannel(name)` to reflect membership for display.
|
|
260
|
+
|
|
261
|
+
### Complete example: Team announcements with channels
|
|
262
|
+
|
|
263
|
+
This example shows the full round-trip — access.js declares channels and grants; App.jsx reads them back via `access`. Key details:
|
|
264
|
+
|
|
265
|
+
- **Owner bootstrap:** the vibe owner is auto-seeded into the reserved `owner` role, so gate management operations (channel setup, role grants, moderation) on `ctx.requireRole("owner")` — **never on a display flag** (the UI gates on `can.*`). No bootstrap problem — the seed means the owner can manage without a prior grant. Default content, though, should be author-owned (anyone signed-in creates and edits their own); reserve owner-gating for shared admin surfaces.
|
|
266
|
+
- **Channel identity:** Channel docs use `_id: "ch:" + name` so names are unique. The `_id` is the channel identifier everywhere — in `channels`, `grant`, and `ctx.requireAccess()`.
|
|
267
|
+
- **Channel grant:** A channel document grants the creator (`grant.users`), adds `grant.public` so all members can read, and `grant.roles` so posters can write.
|
|
268
|
+
- **Write surfaces** are gated with `useVibe(dbName).can.create/edit/delete` — it runs this same access function, so the UI verdict matches the server. Render `.reason` when denied. (See use-vibe docs.)
|
|
269
|
+
- **`ViewerTag`** takes `userHandle` to render another user (authors, rosters). The current viewer's own pill and sign-in button are system chrome in the Vibes Switch (the logo) — don't add one to the app's UI, except a guarded no-prop `{viewer && <ViewerTag />}` when you want inline avatar self-edit for any signed-in member (see use-viewer docs).
|
|
270
|
+
|
|
271
|
+
access.js
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
export function announcements(doc, oldDoc, user, ctx) {
|
|
275
|
+
if (!user) throw { forbidden: "sign in" };
|
|
276
|
+
|
|
277
|
+
if (doc.type === "channel") {
|
|
278
|
+
ctx.requireRole("owner");
|
|
279
|
+
return {
|
|
280
|
+
channels: [doc._id],
|
|
281
|
+
grant: {
|
|
282
|
+
users: { [user.userHandle]: [doc._id] },
|
|
283
|
+
public: [doc._id],
|
|
284
|
+
roles: { poster: [doc._id] },
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (doc.type === "roleGrant") {
|
|
290
|
+
ctx.requireRole("owner");
|
|
291
|
+
// A grant doc must ALSO route to a channel — a result with no `channels`
|
|
292
|
+
// (only `members`/`grant`) is rejected as an "unreadable write". Route it to
|
|
293
|
+
// an owner-readable admin channel (not a public one) so the grant persists
|
|
294
|
+
// and the owner can read the roster back; the membership then applies.
|
|
295
|
+
return {
|
|
296
|
+
channels: ["admin:grants"],
|
|
297
|
+
members: { [doc.role]: [doc.userHandle] },
|
|
298
|
+
grant: { users: { [user.userHandle]: ["admin:grants"] } },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (doc.type === "post") {
|
|
303
|
+
// Author fixed at create; ownership immutable. On update a non-author may
|
|
304
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
305
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
306
|
+
if (oldDoc === null) {
|
|
307
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
308
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
309
|
+
throw { forbidden: "cannot change author" };
|
|
310
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
311
|
+
throw { forbidden: "not author" };
|
|
312
|
+
}
|
|
313
|
+
ctx.requireAccess(doc.channel);
|
|
314
|
+
return { channels: [doc.channel] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
throw { forbidden: "unknown document type" };
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
App.jsx — `useVibe().can` gates every write surface (posts AND owner-only management); `access.hasChannel()` reflects display-only membership:
|
|
322
|
+
|
|
323
|
+
```jsx
|
|
324
|
+
import React from "react";
|
|
325
|
+
import { useFireproof } from "use-fireproof";
|
|
326
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
327
|
+
|
|
328
|
+
export default function App() {
|
|
329
|
+
const { viewer, isViewerPending, ViewerTag } = useViewer();
|
|
330
|
+
const { database, useLiveQuery, access } = useFireproof("announcements");
|
|
331
|
+
const { can } = useVibe("announcements");
|
|
332
|
+
|
|
333
|
+
const { docs: posts } = useLiveQuery("type", { key: "post" });
|
|
334
|
+
const [draft, setDraft] = React.useState("");
|
|
335
|
+
const [channel, setChannel] = React.useState("general");
|
|
336
|
+
// Build each candidate from the doc you'll actually write — the access function
|
|
337
|
+
// checks authorHandle/channel (and owner-only for roleGrant), so a bare partial
|
|
338
|
+
// would be denied and hide the control even from users who can act.
|
|
339
|
+
const canPost = can.create({ type: "post", channel, authorHandle: viewer?.userHandle });
|
|
340
|
+
// Owner-only management gates on can.* too — the access fn calls
|
|
341
|
+
// ctx.requireRole("owner"), so this verdict is false for everyone but the owner.
|
|
342
|
+
const canGrant = can.create({ type: "roleGrant", role: "poster", userHandle: "newUser" });
|
|
343
|
+
|
|
344
|
+
if (isViewerPending) return null;
|
|
345
|
+
|
|
346
|
+
async function submitPost() {
|
|
347
|
+
if (!draft.trim() || !viewer) return;
|
|
348
|
+
await database.put({
|
|
349
|
+
type: "post",
|
|
350
|
+
channel,
|
|
351
|
+
body: draft.trim(),
|
|
352
|
+
authorHandle: viewer.userHandle,
|
|
353
|
+
createdAt: Date.now(),
|
|
354
|
+
});
|
|
355
|
+
setDraft("");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div>
|
|
360
|
+
{/* No current-user pill / sign-in button here — that's system chrome in the
|
|
361
|
+
Vibes Switch (the panel the logo opens). ViewerTag below renders post authors. */}
|
|
362
|
+
|
|
363
|
+
{/* gate the write surface on useVibe().can — it runs the access function */}
|
|
364
|
+
{canPost.ok ? (
|
|
365
|
+
<form
|
|
366
|
+
onSubmit={(e) => {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
submitPost();
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
372
|
+
<button type="submit">Post</button>
|
|
373
|
+
</form>
|
|
374
|
+
) : (
|
|
375
|
+
viewer && <p style={{ color: "var(--muted, #888)" }}>{canPost.reason}</p>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* owner-only management — gated on can.* */}
|
|
379
|
+
{canGrant.ok && (
|
|
380
|
+
<button onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
|
|
381
|
+
Grant poster role
|
|
382
|
+
</button>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
{posts.map((p) => (
|
|
386
|
+
<div key={p._id}>
|
|
387
|
+
<ViewerTag userHandle={p.authorHandle} />
|
|
388
|
+
<p>{p.body}</p>
|
|
389
|
+
{can.delete(p).ok && <button onClick={() => database.del(p._id)}>Delete</button>}
|
|
390
|
+
</div>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The pattern: `useVibe().can` gates every write surface — including owner-only management, which the access function enforces via `ctx.requireRole("owner")` so `can.create({ type: "roleGrant", … })` is false for non-owners. `access.hasChannel()` reflects display-only membership; the write gate is always `can.*`. The access function is the server-side authority — `useVibe().can` is how the UI reflects its decisions for writes.
|
|
398
|
+
|
|
399
|
+
**Owner-management panels (appoint/revoke moderators, grant/revoke roles) gate on `can.*`.** Gate each mutating control — and the panel's visibility — on the `can.*` 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 the rule grows beyond owner-only (a delegated admin role, say).
|
|
400
|
+
|
|
401
|
+
```jsx
|
|
402
|
+
// Gate visibility AND each write on the can.* verdict, and supply the denial reason
|
|
403
|
+
const canAppoint = can.create({ type: "modGrant", role: "moderator", userHandle });
|
|
404
|
+
const canRevoke = can.delete(grantDoc);
|
|
405
|
+
{(canAppoint.ok || canRevoke.ok) && (
|
|
406
|
+
<ModeratorPanel>
|
|
407
|
+
{canAppoint.ok && (
|
|
408
|
+
<button onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</button>
|
|
409
|
+
)}
|
|
410
|
+
{canRevoke.ok ? (
|
|
411
|
+
<button onClick={() => database.del(grantDoc._id)}>Revoke</button>
|
|
412
|
+
) : (
|
|
413
|
+
<p>{canRevoke.reason}</p>
|
|
414
|
+
)}
|
|
415
|
+
</ModeratorPanel>
|
|
416
|
+
)}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Example: Channel board with open channels (any member posts)
|
|
420
|
+
|
|
421
|
+
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.
|
|
422
|
+
|
|
423
|
+
access.js
|
|
424
|
+
|
|
425
|
+
```js
|
|
426
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
427
|
+
if (!user) throw { forbidden: "sign in" };
|
|
428
|
+
|
|
429
|
+
if (doc.type === "channel") {
|
|
430
|
+
ctx.requireRole("owner");
|
|
431
|
+
// Open channel: public READ for everyone. No write-membership grant is
|
|
432
|
+
// needed — any signed-in user may post (see the post rule below).
|
|
433
|
+
return { channels: [doc._id], grant: { public: [doc._id] } };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (doc.type === "post") {
|
|
437
|
+
// Author fixed at create; ownership immutable. On update a non-author may
|
|
438
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
439
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
440
|
+
if (oldDoc === null) {
|
|
441
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
442
|
+
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
443
|
+
throw { forbidden: "cannot change author" };
|
|
444
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
445
|
+
throw { forbidden: "not author" };
|
|
446
|
+
}
|
|
447
|
+
// Any signed-in author may post to this open channel. Do NOT call
|
|
448
|
+
// ctx.requireAccess(doc.channel) here: the channel is grant.public
|
|
449
|
+
// (read-only), which never satisfies requireAccess, so gating on it would
|
|
450
|
+
// block every non-owner from posting. requireAccess is for members-only
|
|
451
|
+
// channels whose writers were granted membership (see announcements above).
|
|
452
|
+
return { channels: [doc.channel] };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
throw { forbidden: "unknown document type" };
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
App.jsx — `access.hasChannel()` filters which channels are visible (display); `useVibe().can` gates the write surface:
|
|
460
|
+
|
|
461
|
+
```jsx
|
|
462
|
+
<<<<<<< SEARCH
|
|
463
|
+
const { database, useLiveQuery, access } = useFireproof("announcements");
|
|
464
|
+
=======
|
|
465
|
+
const { database, useLiveQuery, access } = useFireproof("chat");
|
|
466
|
+
const { docs: channels } = useLiveQuery("type", { key: "channel" });
|
|
467
|
+
// Build the candidate from the doc you'll write — the access fn checks
|
|
468
|
+
// authorHandle, so a bare { type: "post" } would be denied and hide the form.
|
|
469
|
+
const canPost = useVibe("chat").can.create({ type: "post", channel, authorHandle: viewer?.userHandle });
|
|
470
|
+
>>>>>>> REPLACE
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
```jsx
|
|
474
|
+
<<<<<<< SEARCH
|
|
475
|
+
{viewer && access.hasChannel(channel) && (
|
|
476
|
+
=======
|
|
477
|
+
{/* filter to channels the viewer can see — use _id as channel identifier (display only) */}
|
|
478
|
+
{channels.filter((ch) => access.hasChannel(ch._id)).map((ch) => (
|
|
479
|
+
<button key={ch._id} onClick={() => setChannel(ch._id)}>{ch.name}</button>
|
|
480
|
+
))}
|
|
481
|
+
|
|
482
|
+
{/* gate the write surface on useVibe().can */}
|
|
483
|
+
{canPost.ok ? (
|
|
484
|
+
>>>>>>> REPLACE
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Channel `_id` is the channel identifier everywhere. The access function uses `doc._id` for routing and grants. A deterministic `_id` like `"ch:" + name` enforces uniqueness — two users can't create duplicate channels.
|
|
488
|
+
|
|
489
|
+
### Example: Per-object sharing (collaborate on your own objects, no admin)
|
|
490
|
+
|
|
491
|
+
Reach for this whenever the prompt says **invite, join, collaborate, share with, together, with my partner/team** — a shared shopping list you invite a partner to, a whiteboard people can join, a trip a group plans together. A list app where every signed-in user makes their own lists, sees only their own, and can invite anyone to collaborate on a specific list — peer to peer, with no app admin in the loop. The pattern: **a channel per object** (`list:<id>`); the creator grants themselves that channel at creation; child docs (items) gate on `ctx.requireAccess` of the list's channel, so **any member edits any item**; any current member shares the list by granting another user the same channel. Membership is direct `grant.users`, so each viewer's access scales with their own memberships.
|
|
492
|
+
|
|
493
|
+
access.js
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
export default function (doc, oldDoc, user, ctx) {
|
|
497
|
+
if (!user) throw { forbidden: "sign in" };
|
|
498
|
+
const ch = (id) => `list:${id}`;
|
|
499
|
+
|
|
500
|
+
if (doc.type === "list") {
|
|
501
|
+
// Creator owns the list doc; route it to its own channel and grant self.
|
|
502
|
+
const author = oldDoc ? oldDoc.author : doc.author;
|
|
503
|
+
if (author !== user.userHandle) throw { forbidden: "not your list" };
|
|
504
|
+
// author is write-once: an update must not re-author the list (which would
|
|
505
|
+
// change who can edit it and hand control to someone never granted).
|
|
506
|
+
if (oldDoc && doc.author !== oldDoc.author) throw { forbidden: "cannot change author" };
|
|
507
|
+
return { channels: [ch(doc._id)], grant: { users: { [user.userHandle]: [ch(doc._id)] } } };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (doc.type === "item") {
|
|
511
|
+
// Any member of the list may add/edit items in it. listId is immutable —
|
|
512
|
+
// without this, a member of list X could re-point an existing item from a
|
|
513
|
+
// list they don't belong to into X (it would still pass requireAccess(X)).
|
|
514
|
+
if (oldDoc && oldDoc.listId !== doc.listId) throw { forbidden: "cannot move item" };
|
|
515
|
+
ctx.requireAccess(ch(doc.listId));
|
|
516
|
+
return { channels: [ch(doc.listId)] };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (doc.type === "share") {
|
|
520
|
+
// Any current member invites a peer by handle — grants them the list channel.
|
|
521
|
+
// Route the share doc to the list channel itself (every member already holds
|
|
522
|
+
// it), so members see who was added without a second channel to grant.
|
|
523
|
+
ctx.requireAccess(ch(doc.listId));
|
|
524
|
+
return { channels: [ch(doc.listId)], grant: { users: { [doc.invitee]: [ch(doc.listId)] } } };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
throw { forbidden: "unknown document type" };
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
To invite someone who isn't a member yet without knowing their handle in advance, invert the flow with a request doc: a `request` type takes **no** `ctx.requireAccess` (any signed-in user may create one — their handle is `user.userHandle`, unforgeable) and routes to the list channel `ch(doc.listId)`, where current members read it (the requester can't read their own request back — they aren't a member yet — which is fine; they just wait to be granted). A member then writes the `share` above to approve.
|
|
532
|
+
|
|
533
|
+
App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `useVibe("lists").can` gates each write surface (create list, add item, invite peer):
|
|
534
|
+
|
|
535
|
+
```jsx
|
|
536
|
+
<<<<<<< SEARCH
|
|
537
|
+
const { database, useLiveQuery, access } = useFireproof("notes");
|
|
538
|
+
=======
|
|
539
|
+
const { database, useLiveQuery, access } = useFireproof("lists");
|
|
540
|
+
const { me, can } = useVibe("lists");
|
|
541
|
+
const { docs: lists } = useLiveQuery("type", { key: "list" });
|
|
542
|
+
const visible = lists.filter((l) => access.hasChannel(`list:${l._id}`));
|
|
543
|
+
// Each member edits any item — items gate on requireAccess(list:<id>), not authorHandle.
|
|
544
|
+
const canAddItem = (list) => can.create({ type: "item", listId: list._id, authorHandle: me?.userHandle }).ok;
|
|
545
|
+
const canShare = (list) => can.create({ type: "share", listId: list._id, invitee: "x" }).ok;
|
|
546
|
+
>>>>>>> REPLACE
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
```jsx
|
|
550
|
+
<<<<<<< SEARCH
|
|
551
|
+
{viewer && <button onClick={addNote}>+ note</button>}
|
|
552
|
+
=======
|
|
553
|
+
{/* create list — any signed-in visitor can start their own */}
|
|
554
|
+
{can.create({ type: "list", author: me?.userHandle }).ok && (
|
|
555
|
+
<button onClick={() => database.put({ type: "list", author: me.userHandle, name: "new list" })}>
|
|
556
|
+
+ new list
|
|
557
|
+
</button>
|
|
558
|
+
)}
|
|
559
|
+
|
|
560
|
+
{visible.map((list) => (
|
|
561
|
+
<section key={list._id}>
|
|
562
|
+
<h3>{list.name}</h3>
|
|
563
|
+
{/* add-item form is shown only when the access fn would accept the write */}
|
|
564
|
+
{canAddItem(list) && <AddItemForm listId={list._id} />}
|
|
565
|
+
{/* invite peer — only shown to members of this list */}
|
|
566
|
+
{canShare(list) && <ShareForm listId={list._id} />}
|
|
567
|
+
</section>
|
|
568
|
+
))}
|
|
569
|
+
>>>>>>> REPLACE
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
## More worked round-trip examples
|
|
573
|
+
|
|
574
|
+
### Example: Workspace chat with channels
|
|
575
|
+
|
|
576
|
+
access.js
|
|
577
|
+
|
|
578
|
+
```js
|
|
579
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
580
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
581
|
+
|
|
582
|
+
if (doc.type === "channel-meta") {
|
|
583
|
+
if (doc.ownerHandle !== user.userHandle) throw { forbidden: "not owner" };
|
|
584
|
+
if (oldDoc && oldDoc.ownerHandle !== user.userHandle) throw { forbidden: "not owner" };
|
|
585
|
+
return {
|
|
586
|
+
channels: [doc._id],
|
|
587
|
+
grant: {
|
|
588
|
+
users: Object.fromEntries([[doc.ownerHandle, [doc._id]], ...doc.memberHandles.map((h) => [h, [doc._id]])]),
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (doc.type === "message") {
|
|
594
|
+
// Author fixed at create; ownership immutable. On update a non-author may
|
|
595
|
+
// only append one legitimate ImgGen version — the platform predicate
|
|
596
|
+
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
597
|
+
if (oldDoc === null) {
|
|
598
|
+
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
599
|
+
} else if (doc.userHandle !== oldDoc.userHandle) {
|
|
600
|
+
throw { forbidden: "cannot change author" };
|
|
601
|
+
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
|
|
602
|
+
throw { forbidden: "not author" };
|
|
603
|
+
}
|
|
604
|
+
ctx.requireAccess(doc.channelId);
|
|
605
|
+
return { channels: [doc.channelId] };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (doc.type === "channel-invite") {
|
|
609
|
+
if (doc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
|
|
610
|
+
if (oldDoc && oldDoc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
|
|
611
|
+
ctx.requireAccess(doc.channelId);
|
|
612
|
+
return {
|
|
613
|
+
channels: [doc.channelId],
|
|
614
|
+
grant: { users: { [doc.inviteeHandle]: [doc.channelId] } },
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
throw { forbidden: "unknown document type" };
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
This single access function handles three document types: **channel-meta** — owner creates a channel and grants access to listed members, **message** — only the author can post, must already have channel access, **channel-invite** — any channel member can invite others; deleting the invite revokes the grant.
|
|
623
|
+
|
|
624
|
+
App.jsx — show only channels the viewer is in (`access.hasChannel`), gate the compose box and invite form on `useVibe().can`:
|
|
625
|
+
|
|
626
|
+
```jsx
|
|
627
|
+
<<<<<<< SEARCH
|
|
628
|
+
const { database, useLiveQuery, access } = useFireproof("notes");
|
|
629
|
+
=======
|
|
630
|
+
const { database, useLiveQuery, access } = useFireproof("chat");
|
|
631
|
+
const { me, can } = useVibe("chat");
|
|
632
|
+
const { docs: channels } = useLiveQuery("type", { key: "channel-meta" });
|
|
633
|
+
// Filter to channels the viewer has been granted into — non-members never see them in the list.
|
|
634
|
+
const myChannels = channels.filter((ch) => access.hasChannel(ch._id));
|
|
635
|
+
const [channelId, setChannelId] = React.useState(null);
|
|
636
|
+
const canPost = can.create({ type: "message", channelId, userHandle: me?.userHandle });
|
|
637
|
+
const canInvite = can.create({ type: "channel-invite", channelId, senderHandle: me?.userHandle, inviteeHandle: "x" });
|
|
638
|
+
>>>>>>> REPLACE
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
```jsx
|
|
642
|
+
<<<<<<< SEARCH
|
|
643
|
+
{viewer && <Compose onSend={send} />}
|
|
644
|
+
=======
|
|
645
|
+
{/* sidebar lists only the channels the viewer is in */}
|
|
646
|
+
<nav>{myChannels.map((ch) => <button key={ch._id} onClick={() => setChannelId(ch._id)}>{ch.name}</button>)}</nav>
|
|
647
|
+
|
|
648
|
+
{/* compose is shown only when the viewer is a member of the selected channel */}
|
|
649
|
+
{channelId && canPost.ok ? (
|
|
650
|
+
<Compose onSend={(text) => database.put({ type: "message", channelId, userHandle: me.userHandle, text })} />
|
|
651
|
+
) : (
|
|
652
|
+
<p>{canPost.reason || "Pick a channel"}</p>
|
|
653
|
+
)}
|
|
654
|
+
|
|
655
|
+
{/* any member of this channel may invite a peer */}
|
|
656
|
+
{channelId && canInvite.ok && <InviteForm channelId={channelId} senderHandle={me.userHandle} />}
|
|
657
|
+
>>>>>>> REPLACE
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### Example: Anonymous survey with role-gated results
|
|
661
|
+
|
|
662
|
+
access.js
|
|
663
|
+
|
|
664
|
+
```js
|
|
665
|
+
export function survey(doc, oldDoc, user, ctx) {
|
|
666
|
+
if (doc.type === "survey-response") {
|
|
667
|
+
if (oldDoc) throw { forbidden: "responses are write-once" };
|
|
668
|
+
return { channels: ["inbound-responses"], allowAnonymous: true };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (doc.type === "survey-config") {
|
|
672
|
+
ctx.requireRole("owner");
|
|
673
|
+
// Route this grant/config doc to an owner-readable admin channel — a
|
|
674
|
+
// grant-only result (no `channels`) is rejected as an "unreadable write".
|
|
675
|
+
return {
|
|
676
|
+
channels: ["admin:grants"],
|
|
677
|
+
grant: {
|
|
678
|
+
users: { [user.userHandle]: ["admin:grants"] },
|
|
679
|
+
roles: { "feedback-team": ["inbound-responses"] },
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (doc.type === "final-results") {
|
|
685
|
+
ctx.requireRole("feedback-team");
|
|
686
|
+
return { channels: [doc._id], grant: { public: [doc._id] } };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
throw { forbidden: "unknown document type" };
|
|
690
|
+
}
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
Key patterns: `allowAnonymous: true` on survey-response lets unauthenticated visitors submit, `grant.public` on final-results makes them readable by any member without a specific channel grant, and the **singleton grant doc** pattern (survey-config) wires role-to-channel access in one place.
|
|
694
|
+
|
|
695
|
+
App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated results view:
|
|
696
|
+
|
|
697
|
+
```jsx
|
|
698
|
+
<<<<<<< SEARCH
|
|
699
|
+
const { database } = useFireproof("notes");
|
|
700
|
+
=======
|
|
701
|
+
const { database, useLiveQuery, access } = useFireproof("survey");
|
|
702
|
+
const { me, can } = useVibe("survey");
|
|
703
|
+
// Submit form is shown for anyone — allowAnonymous makes this can.create return ok for null user.
|
|
704
|
+
const canSubmit = can.create({ type: "survey-response", question: "q1", answer: "" });
|
|
705
|
+
// Owner-only — survey-config rule calls ctx.requireRole("owner").
|
|
706
|
+
const canConfigure = can.create({ type: "survey-config" });
|
|
707
|
+
const { docs: results } = useLiveQuery("type", { key: "final-results" });
|
|
708
|
+
>>>>>>> REPLACE
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
```jsx
|
|
712
|
+
<<<<<<< SEARCH
|
|
713
|
+
<input value={answer} onChange={(e) => setAnswer(e.target.value)} />
|
|
714
|
+
=======
|
|
715
|
+
{/* anyone (signed in OR anonymous) can submit — only stamp authorHandle when present */}
|
|
716
|
+
{canSubmit.ok ? (
|
|
717
|
+
<form onSubmit={(e) => {
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
database.put({ type: "survey-response", question: "q1", answer, ...(me && { authorHandle: me.userHandle }) });
|
|
720
|
+
}}>
|
|
721
|
+
<input value={answer} onChange={(e) => setAnswer(e.target.value)} />
|
|
722
|
+
<button type="submit">Submit</button>
|
|
723
|
+
</form>
|
|
724
|
+
) : <p>{canSubmit.reason}</p>}
|
|
725
|
+
|
|
726
|
+
{/* owner-only admin to wire up the feedback-team role */}
|
|
727
|
+
{canConfigure.ok && <ConfigPanel />}
|
|
728
|
+
|
|
729
|
+
{/* results render only for users with the feedback-team role — access.hasChannel filters them in */}
|
|
730
|
+
{results.filter((r) => access.hasChannel(r._id)).map((r) => <ResultCard key={r._id} doc={r} />)}
|
|
731
|
+
>>>>>>> REPLACE
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Example: Public guestbook / contact form (anonymous writes)
|
|
735
|
+
|
|
736
|
+
When the prompt says **anyone can sign / submit without logging in** (a guestbook, a contact form, an RSVP), do **not** throw on `!user` — return `allowAnonymous: true` so the write is accepted for anonymous visitors. `useVibe().can.create(...)` then returns `ok` for an anonymous viewer, and the form shows instead of a sign-in wall. Stamp `authorHandle` only when there is a user.
|
|
737
|
+
|
|
738
|
+
access.js
|
|
739
|
+
|
|
740
|
+
```js
|
|
741
|
+
export function guestbook(doc, oldDoc, user, ctx) {
|
|
742
|
+
if (doc.type === "entry") {
|
|
743
|
+
if (oldDoc) throw { forbidden: "entries are write-once" };
|
|
744
|
+
// No `if (!user) throw` — anyone may sign. allowAnonymous opts the write in.
|
|
745
|
+
return { channels: ["public"], grant: { public: ["public"] }, allowAnonymous: true };
|
|
746
|
+
}
|
|
747
|
+
throw { forbidden: "unknown document type" };
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
In `App.jsx`, gate the form on `useVibe("guestbook").can.create({ type: "entry" }).ok` (true for anon here) and stamp `authorHandle: me?.userHandle` only when signed in. Without `allowAnonymous: true` the runtime rejects the null-user write even though the function didn't throw — so the guestbook would silently require login, the exact miss to avoid.
|
|
752
|
+
|
|
753
|
+
App.jsx — gate the form on `can.create({ type: "entry" }).ok`, which is true even when nobody is signed in:
|
|
754
|
+
|
|
755
|
+
```jsx
|
|
756
|
+
<<<<<<< SEARCH
|
|
757
|
+
const { database } = useFireproof("notes");
|
|
758
|
+
=======
|
|
759
|
+
const { database, useLiveQuery } = useFireproof("guestbook");
|
|
760
|
+
const { me, can } = useVibe("guestbook");
|
|
761
|
+
const { docs: entries } = useLiveQuery("type", { key: "entry" });
|
|
762
|
+
// allowAnonymous: true on the access fn → this returns ok for an anon viewer.
|
|
763
|
+
// The form is visible from first load, no sign-in wall.
|
|
764
|
+
const canSign = can.create({ type: "entry", message: "" });
|
|
765
|
+
>>>>>>> REPLACE
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
```jsx
|
|
769
|
+
<<<<<<< SEARCH
|
|
770
|
+
<SignInGate>
|
|
771
|
+
<input value={message} onChange={(e) => setMessage(e.target.value)} />
|
|
772
|
+
</SignInGate>
|
|
773
|
+
=======
|
|
774
|
+
{canSign.ok ? (
|
|
775
|
+
<form onSubmit={(e) => {
|
|
776
|
+
e.preventDefault();
|
|
777
|
+
// Stamp authorHandle only when signed in — anon entries simply omit it.
|
|
778
|
+
database.put({ type: "entry", message, createdAt: Date.now(), ...(me && { authorHandle: me.userHandle }) });
|
|
779
|
+
setMessage("");
|
|
780
|
+
}}>
|
|
781
|
+
<input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Leave a note" />
|
|
782
|
+
<button type="submit">Sign</button>
|
|
783
|
+
</form>
|
|
784
|
+
) : <p>{canSign.reason}</p>}
|
|
785
|
+
|
|
786
|
+
{entries.map((e) => <li key={e._id}>{e.message} — {e.authorHandle || "anonymous"}</li>)}
|
|
787
|
+
>>>>>>> REPLACE
|
|
788
|
+
```
|