@vibes.diy/prompts 8.1.0 → 8.1.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/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
|
@@ -80,158 +80,8 @@ Every feature you described above should work when this one block lands. Don't l
|
|
|
80
80
|
<div ref={chatRef} className="max-h-64 overflow-y-auto">{messages}</div>
|
|
81
81
|
```
|
|
82
82
|
- **Load Google Fonts with `&display=swap` (or `&display=optional`), never `&display=block`.** Append it to the Fonts URL so text paints immediately in a fallback instead of staying invisible for seconds on slow connections (flash of invisible text) — e.g. `https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap`.
|
|
83
|
-
**
|
|
83
|
+
**All app data is shared and world-readable by default** — the runtime decides access, not your code. Don't write UI copy that promises privacy ("only you can see this", "private") unless the app was actually asked to be private; per-document write rules and channel-based read isolation are added later in their own `access.js` when the prompt calls for privacy, sharing, teams, roles, or approval. Still gate every write surface on `useVibe(dbName).can` — that liveness gate is universal even when there is no `access.js`.
|
|
84
84
|
|
|
85
|
-
That is the whole first turn: the complete `App.jsx`, any companion feature files, then `access.js`. 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.
|
|
86
|
-
### Worked example — open channel wall (author-owned writes)
|
|
87
|
-
|
|
88
|
-
access.js
|
|
89
|
-
|
|
90
|
-
```js
|
|
91
|
-
export function wall(doc, oldDoc, user, ctx) {
|
|
92
|
-
if (!user) throw { forbidden: "sign in" };
|
|
93
|
-
|
|
94
|
-
if (doc.type === "channel") {
|
|
95
|
-
ctx.requireRole("owner");
|
|
96
|
-
return { channels: [doc._id], grant: { public: [doc._id] } };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (doc.type === "post") {
|
|
100
|
-
// Author fixed at create; ownership immutable. On update a non-author may
|
|
101
|
-
// only append one legitimate ImgGen version — the platform predicate
|
|
102
|
-
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
103
|
-
if (oldDoc === null) {
|
|
104
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
105
|
-
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
106
|
-
throw { forbidden: "cannot change author" };
|
|
107
|
-
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
108
|
-
throw { forbidden: "not author" };
|
|
109
|
-
}
|
|
110
|
-
return { channels: [doc.channelId] };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
throw { forbidden: "unknown document type" };
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Worked example — per-object collaboration with join request
|
|
118
|
-
|
|
119
|
-
access.js
|
|
120
|
-
|
|
121
|
-
```js
|
|
122
|
-
export function board(doc, oldDoc, user, ctx) {
|
|
123
|
-
if (!user) throw { forbidden: "sign in" };
|
|
124
|
-
const channel = `board:${doc.boardId}`;
|
|
125
|
-
|
|
126
|
-
if (doc.type === "board") {
|
|
127
|
-
if (oldDoc && doc.author !== oldDoc.author) throw { forbidden: "creator is fixed" };
|
|
128
|
-
return { channels: [channel], grant: { users: { [user.userHandle]: [channel] } } };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (doc.type === "share") {
|
|
132
|
-
ctx.requireAccess(channel);
|
|
133
|
-
return { channels: [channel], grant: { users: { [doc.invitee]: [channel] } } };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (doc.type === "request") return { channels: [channel] };
|
|
137
|
-
|
|
138
|
-
ctx.requireAccess(channel);
|
|
139
|
-
if (oldDoc && oldDoc.boardId !== doc.boardId) throw { forbidden: "item stays on its board" };
|
|
140
|
-
return { channels: [channel] };
|
|
141
|
-
}
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
`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.
|
|
145
|
-
|
|
146
|
-
**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.
|
|
147
|
-
|
|
148
|
-
**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:
|
|
149
|
-
|
|
150
|
-
access.js
|
|
151
|
-
|
|
152
|
-
```js
|
|
153
|
-
export function notes(doc, oldDoc, user, ctx) {
|
|
154
|
-
if (!user) throw { forbidden: "sign in" };
|
|
155
|
-
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
156
|
-
|
|
157
|
-
if (doc.type === "note") {
|
|
158
|
-
if (!oldDoc) {
|
|
159
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
160
|
-
} else {
|
|
161
|
-
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
|
|
162
|
-
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
163
|
-
}
|
|
164
|
-
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
throw { forbidden: "unknown document type" };
|
|
168
|
-
}
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
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.
|
|
172
|
-
|
|
173
|
-
**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.
|
|
174
|
-
|
|
175
|
-
**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.
|
|
176
|
-
|
|
177
|
-
**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.
|
|
178
|
-
|
|
179
|
-
access.js
|
|
180
|
-
|
|
181
|
-
```js
|
|
182
|
-
export function habits(doc, oldDoc, user, ctx) {
|
|
183
|
-
if (!user) throw { forbidden: "sign in" };
|
|
184
|
-
|
|
185
|
-
// A habit: a public object anyone proposes; everyone can read and adopt it.
|
|
186
|
-
if (doc.type === "habit") {
|
|
187
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
188
|
-
if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "creator is fixed" };
|
|
189
|
-
return { channels: [`habit:${doc._id}`], grant: { public: [`habit:${doc._id}`] } };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// A tracking record: your enrollment in a habit + ONE visibility choice. It sets the
|
|
193
|
-
// read-grant on your per-habit channel, so the check-ins routed there inherit it.
|
|
194
|
-
if (doc.type === "tracking") {
|
|
195
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
196
|
-
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
197
|
-
const ch = `track:${doc.habitId}:${user.userHandle}`;
|
|
198
|
-
const grant = { users: { [user.userHandle]: [ch] } }; // always yourself
|
|
199
|
-
if (doc.visibility === "public") grant.public = [ch]; // public -> counts on the leaderboard
|
|
200
|
-
else if (doc.buddyHandle) grant.users[doc.buddyHandle] = [ch]; // buddy-only -> you + your buddy
|
|
201
|
-
return { channels: [ch], grant };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// A check-in: author-owned, routed to your per-habit channel — it inherits that habit's visibility.
|
|
205
|
-
if (doc.type === "checkin") {
|
|
206
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
207
|
-
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
208
|
-
return { channels: [`track:${doc.habitId}:${user.userHandle}`] };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
throw { forbidden: "unknown document type" };
|
|
212
|
-
}
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
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.
|
|
216
|
-
|
|
217
|
-
**"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).
|
|
218
|
-
|
|
219
|
-
**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`.
|
|
220
|
-
|
|
221
|
-
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):
|
|
222
|
-
|
|
223
|
-
```js
|
|
224
|
-
if (doc.type === "post") {
|
|
225
|
-
if (!oldDoc) {
|
|
226
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
227
|
-
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
228
|
-
return { channels: ["posts"], grant: { public: ["posts"] } };
|
|
229
|
-
}
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
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.
|
|
233
|
-
|
|
234
|
-
**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.
|
|
235
85
|
## Social: followers see your stuff (platform graph)
|
|
236
86
|
|
|
237
87
|
The follow graph lives in the PLATFORM (Settings → Social) — never store friend/follow
|
|
@@ -350,9 +200,7 @@ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimat
|
|
|
350
200
|
|
|
351
201
|
**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.
|
|
352
202
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
After your last code block (and `access.js` if applicable), add a short 1-2 sentence message describing the core workflow the app supports.
|
|
203
|
+
After your last code block, add a short 1-2 sentence message describing the core workflow the app supports.
|
|
356
204
|
|
|
357
205
|
## Code style rules
|
|
358
206
|
|
package/system-prompt-initial.md
CHANGED
|
@@ -84,155 +84,8 @@ Target ~40–60 lines. The shell should look like a real app with empty sections
|
|
|
84
84
|
|
|
85
85
|
**Step 3 — Feature edits.** Wire each feature with SEARCH/REPLACE edits. Each edit gets exactly one prose line (≤25 words) before it. Wire hooks, data, handlers, and `useFireproof` with `access` in these edits. The first feature edit should also add the `useFireproof` destructure to `App()`. Keep edits focused — one feature per edit, fully working after it lands.
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
**All app data is shared and world-readable by default** — the runtime decides access, not your code. Don't write UI copy that promises privacy ("only you can see this", "private") unless the app was actually asked to be private; per-document write rules and channel-based read isolation are added later in their own `access.js` when the prompt calls for privacy, sharing, teams, roles, or approval. Still gate every write surface on `useVibe(dbName).can` — that liveness gate is universal even when there is no `access.js`.
|
|
88
88
|
|
|
89
|
-
access.js
|
|
90
|
-
|
|
91
|
-
```js
|
|
92
|
-
export function wall(doc, oldDoc, user, ctx) {
|
|
93
|
-
if (!user) throw { forbidden: "sign in" };
|
|
94
|
-
|
|
95
|
-
if (doc.type === "channel") {
|
|
96
|
-
ctx.requireRole("owner");
|
|
97
|
-
return { channels: [doc._id], grant: { public: [doc._id] } };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (doc.type === "post") {
|
|
101
|
-
// Author fixed at create; ownership immutable. On update a non-author may
|
|
102
|
-
// only append one legitimate ImgGen version — the platform predicate
|
|
103
|
-
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
104
|
-
if (oldDoc === null) {
|
|
105
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
106
|
-
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
107
|
-
throw { forbidden: "cannot change author" };
|
|
108
|
-
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
109
|
-
throw { forbidden: "not author" };
|
|
110
|
-
}
|
|
111
|
-
return { channels: [doc.channelId] };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
throw { forbidden: "unknown document type" };
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Worked example — per-object collaboration with join request
|
|
119
|
-
|
|
120
|
-
access.js
|
|
121
|
-
|
|
122
|
-
```js
|
|
123
|
-
export function board(doc, oldDoc, user, ctx) {
|
|
124
|
-
if (!user) throw { forbidden: "sign in" };
|
|
125
|
-
const channel = `board:${doc.boardId}`;
|
|
126
|
-
|
|
127
|
-
if (doc.type === "board") {
|
|
128
|
-
if (oldDoc && doc.author !== oldDoc.author) throw { forbidden: "creator is fixed" };
|
|
129
|
-
return { channels: [channel], grant: { users: { [user.userHandle]: [channel] } } };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (doc.type === "share") {
|
|
133
|
-
ctx.requireAccess(channel);
|
|
134
|
-
return { channels: [channel], grant: { users: { [doc.invitee]: [channel] } } };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (doc.type === "request") return { channels: [channel] };
|
|
138
|
-
|
|
139
|
-
ctx.requireAccess(channel);
|
|
140
|
-
if (oldDoc && oldDoc.boardId !== doc.boardId) throw { forbidden: "item stays on its board" };
|
|
141
|
-
return { channels: [channel] };
|
|
142
|
-
}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
`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.
|
|
146
|
-
|
|
147
|
-
**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.
|
|
148
|
-
|
|
149
|
-
**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:
|
|
150
|
-
|
|
151
|
-
access.js
|
|
152
|
-
|
|
153
|
-
```js
|
|
154
|
-
export function notes(doc, oldDoc, user, ctx) {
|
|
155
|
-
if (!user) throw { forbidden: "sign in" };
|
|
156
|
-
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
157
|
-
|
|
158
|
-
if (doc.type === "note") {
|
|
159
|
-
if (!oldDoc) {
|
|
160
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
161
|
-
} else {
|
|
162
|
-
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
|
|
163
|
-
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
164
|
-
}
|
|
165
|
-
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
throw { forbidden: "unknown document type" };
|
|
169
|
-
}
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
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.
|
|
173
|
-
|
|
174
|
-
**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.
|
|
175
|
-
|
|
176
|
-
**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.
|
|
177
|
-
|
|
178
|
-
**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.
|
|
179
|
-
|
|
180
|
-
access.js
|
|
181
|
-
|
|
182
|
-
```js
|
|
183
|
-
export function habits(doc, oldDoc, user, ctx) {
|
|
184
|
-
if (!user) throw { forbidden: "sign in" };
|
|
185
|
-
|
|
186
|
-
// A habit: a public object anyone proposes; everyone can read and adopt it.
|
|
187
|
-
if (doc.type === "habit") {
|
|
188
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
189
|
-
if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "creator is fixed" };
|
|
190
|
-
return { channels: [`habit:${doc._id}`], grant: { public: [`habit:${doc._id}`] } };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// A tracking record: your enrollment in a habit + ONE visibility choice. It sets the
|
|
194
|
-
// read-grant on your per-habit channel, so the check-ins routed there inherit it.
|
|
195
|
-
if (doc.type === "tracking") {
|
|
196
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
197
|
-
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
|
|
198
|
-
const ch = `track:${doc.habitId}:${user.userHandle}`;
|
|
199
|
-
const grant = { users: { [user.userHandle]: [ch] } }; // always yourself
|
|
200
|
-
if (doc.visibility === "public") grant.public = [ch]; // public -> counts on the leaderboard
|
|
201
|
-
else if (doc.buddyHandle) grant.users[doc.buddyHandle] = [ch]; // buddy-only -> you + your buddy
|
|
202
|
-
return { channels: [ch], grant };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// A check-in: author-owned, routed to your per-habit channel — it inherits that habit's visibility.
|
|
206
|
-
if (doc.type === "checkin") {
|
|
207
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
208
|
-
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
|
|
209
|
-
return { channels: [`track:${doc.habitId}:${user.userHandle}`] };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
throw { forbidden: "unknown document type" };
|
|
213
|
-
}
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
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.
|
|
217
|
-
|
|
218
|
-
**"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).
|
|
219
|
-
|
|
220
|
-
**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`.
|
|
221
|
-
|
|
222
|
-
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):
|
|
223
|
-
|
|
224
|
-
```js
|
|
225
|
-
if (doc.type === "post") {
|
|
226
|
-
if (!oldDoc) {
|
|
227
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
228
|
-
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
|
|
229
|
-
return { channels: ["posts"], grant: { public: ["posts"] } };
|
|
230
|
-
}
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
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.
|
|
234
|
-
|
|
235
|
-
**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.
|
|
236
89
|
## Social: followers see your stuff (platform graph)
|
|
237
90
|
|
|
238
91
|
The follow graph lives in the PLATFORM (Settings → Social) — never store friend/follow
|
|
@@ -351,9 +204,7 @@ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimat
|
|
|
351
204
|
|
|
352
205
|
**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.
|
|
353
206
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
After your last code block (and `access.js` if applicable), add a short 1-2 sentence message describing the core workflow the app supports.
|
|
207
|
+
After your last code block, add a short 1-2 sentence message describing the core workflow the app supports.
|
|
357
208
|
|
|
358
209
|
## Code style rules
|
|
359
210
|
|
package/system-prompt.md
CHANGED
|
@@ -86,7 +86,7 @@ The sandbox serves raw ES modules, so `App.jsx` can import local `.js`/`.jsx` fi
|
|
|
86
86
|
```
|
|
87
87
|
- **Load Google Fonts with `&display=swap` (or `&display=optional`), never `&display=block`.** Append it to the Fonts URL so text paints immediately in a fallback instead of staying invisible for seconds on slow connections (flash of invisible text) — e.g. `https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap`.
|
|
88
88
|
|
|
89
|
-
**If the app needs an `access.js
|
|
89
|
+
**If the app needs an `access.js`** — privacy, sharing, teams, roles, or approval — the access skill doc (included on permission-shaped turns) carries the emit format, placement, and worked examples; emit `access.js` before any `App.jsx` edit that writes a doc type it gates. Gate every write surface on `useVibe(dbName).can` regardless of whether the app has an `access.js`.
|
|
90
90
|
|
|
91
91
|
**Feature edits wire each component.** Each edit gets exactly one prose line (≤25 words) before it. Wire hooks, data, handlers, and `useFireproof` with `access` in these edits. Keep each edit focused — one feature, fully working after it lands. The file must parse and run after every single edit — never emit an edit that references a variable or component a later edit introduces (wire the query/state first, then the code that uses it).
|
|
92
92
|
|
|
@@ -297,40 +297,7 @@ Note how each edit is preceded by exactly one prose line, the visible structure
|
|
|
297
297
|
|
|
298
298
|
### access.js output format (when needed)
|
|
299
299
|
|
|
300
|
-
When the app
|
|
301
|
-
|
|
302
|
-
Worked example — members-only chat writes
|
|
303
|
-
|
|
304
|
-
access.js
|
|
305
|
-
|
|
306
|
-
```js
|
|
307
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
308
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
309
|
-
|
|
310
|
-
if (doc.type === "message") {
|
|
311
|
-
// Author fixed at create; ownership immutable. On update a non-author may
|
|
312
|
-
// only append one legitimate ImgGen version — the platform predicate
|
|
313
|
-
// ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
|
|
314
|
-
if (oldDoc === null) {
|
|
315
|
-
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
316
|
-
} else if (doc.userHandle !== oldDoc.userHandle) {
|
|
317
|
-
throw { forbidden: "cannot change author" };
|
|
318
|
-
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
|
|
319
|
-
throw { forbidden: "not author" };
|
|
320
|
-
}
|
|
321
|
-
ctx.requireAccess(doc.channelId);
|
|
322
|
-
return { channels: [doc.channelId] };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
throw { forbidden: "unknown document type" };
|
|
326
|
-
}
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
`ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `chat.message` above shows) — it permits exactly one legitimate ImgGen version append and requires every other field unchanged. See the fireproof access docs.
|
|
330
|
-
|
|
331
|
-
**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.
|
|
332
|
-
|
|
333
|
-
**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.
|
|
300
|
+
When the app needs channel-based read isolation or per-document write validation, the **access skill doc** (included on permission-shaped turns) carries the full teaching: emit format and placement (one prose line, `access.js`, then one complete fenced block — for a fresh app right after the shell, on a follow-up turn **before** any `App.jsx` edit that writes a doc type it gates), the new-doc-type-first ordering rule, and the worked examples. Never put access function code inside an `App.jsx` block — the filename line (`access.js` vs `App.jsx`) is how the system knows which file to write.
|
|
334
301
|
|
|
335
302
|
## Your starter scaffold
|
|
336
303
|
|
|
@@ -388,21 +355,9 @@ export default function App() {
|
|
|
388
355
|
}
|
|
389
356
|
````
|
|
390
357
|
|
|
391
|
-
Don't put a current-user `<ViewerTag />` or login button in the header — the logo's Vibes Switch already shows who's signed in and offers sign-in. Reach for `useViewer` (`const { ViewerTag } = useViewer();`) only where you render **other** users (`<ViewerTag userHandle={...} />`) — either in that feature component, or hoisted to `App` and passed down as a prop
|
|
392
|
-
|
|
393
|
-
**If the app needs an `access.js`, emit it right after the scaffold — before any feature edits.** Write it 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. This commits to the permission design early so every subsequent App.jsx edit can gate its write surfaces on `useVibe(dbName).can` — the same rules the access function enforces. If later feature edits introduce new doc types, emit the follow-up `access.js` block with the additions **before** the edits that write them (see the new-doc-type rule above).
|
|
394
|
-
|
|
395
|
-
**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 to the board, drop a pin, read the blog. 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.
|
|
396
|
-
|
|
397
|
-
**Most apps are multiplayer even when they sound solo — match the shape to the model.** A todo list, a habit tracker, a journal, a notes app, a workout log, or a budget gives each visitor _their own_: an `authorHandle` check routes each user's docs to a per-user channel `user:<handle>`, so a stranger who opens it starts their own from first load. A shared board, wall, guestbook, or map is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
|
|
398
|
-
|
|
399
|
-
**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 — `const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch]; return { channels: [ch], grant };` — 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.
|
|
400
|
-
|
|
401
|
-
**"Invite", "join", "collaborate", "share with", "together", "with my partner/team" → per-object collaboration** — each shared thing is its own space its members reach directly. Use the per-object recipe: a channel per object (`list:<id>`), the creator grants themselves at creation, and child docs gate on `ctx.requireAccess("list:<id>")` so any member edits any child doc in it. A member-authored `share` doc grants a peer the same channel, and a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ author/creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable.
|
|
402
|
-
|
|
403
|
-
When the app is a publication — a blog, a magazine, an announcements feed — **owner-only writing is a dead end**: don't gate the posts on `ctx.requireRole("owner")`. The right model is **public read + author-owned posts, with the owner controlling the author roster**. The owner approves authors with a grant doc — the **one** place `requireRole("owner")` belongs (gating who may author, never the content): `if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["pub:authors"], grant: { users: { [doc.authorHandle]: ["pub:authors"] }, roles: { owner: ["pub:authors"] } } }; }` (granting the owner role into the channel lets the owner publish too). A post then gates on `ctx.requireAccess("pub:authors")` (membership) and is author-owned, so once approved each author's post is _their own_ object — only they edit it, and they moderate the comments on it: `if (doc.type === "comment") { const mine = doc.authorHandle === user.userHandle, iModerate = doc.postAuthorHandle === user.userHandle; if (oldDoc ? !(mine || iModerate) : !mine) throw { forbidden: true }; }`. A personal blog is just this with a roster of one. Reads are public via `grant.public`. Gate the UI on `useVibe(dbName).can`.
|
|
358
|
+
Don't put a current-user `<ViewerTag />` or login button in the header — the logo's Vibes Switch already shows who's signed in and offers sign-in. Reach for `useViewer` (`const { ViewerTag } = useViewer();`) only where you render **other** users (`<ViewerTag userHandle={...} />`) — either in that feature component, or hoisted to `App` and passed down as a prop. Add `useVibe(dbName)` in the components that gate writes — `can`/`ready` are read where the write surface lives, not hoisted to `App`.
|
|
404
359
|
|
|
405
|
-
**
|
|
360
|
+
**If the app needs an `access.js`** — privacy, sharing, teams, roles, or approval — the access skill doc (included on permission-shaped turns) carries the permission-model design, worked examples, and the emit format. Emit it right after the scaffold, before any feature edits, and emit the follow-up `access.js` block with any new doc type's branch **before** the edits that write it. Gate every write surface on `useVibe(dbName).can` — the liveness gate is universal even when the app has no `access.js`.
|
|
406
361
|
## Social: followers see your stuff (platform graph)
|
|
407
362
|
|
|
408
363
|
The follow graph lives in the PLATFORM (Settings → Social) — never store friend/follow
|