@vibes.diy/prompts 10.0.2 → 10.6.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.md +142 -1
- package/llms/fireproof.md +10 -293
- package/package.json +4 -4
- package/system-prompt-initial-oneshot.md +2 -1
- package/system-prompt-initial.md +2 -1
- package/system-prompt.md +2 -1
package/llms/access.md
CHANGED
|
@@ -4,7 +4,148 @@ You are seeing this doc because the app's prompt is **permission-shaped** — it
|
|
|
4
4
|
|
|
5
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
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.
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Reference
|
|
10
|
+
|
|
11
|
+
### Function signature
|
|
12
|
+
|
|
13
|
+
`(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor` where `doc` is the document being written, `oldDoc` is the previous version (null for new documents), `user` is the authenticated user or `null` for anonymous requests, and `ctx` provides server helpers for checking materialized state.
|
|
14
|
+
|
|
15
|
+
**UserContext:** `{ userHandle: string, displayName?: string }` — `userHandle` is stable unique id (use for all auth checks), `displayName` is display only (never use for identity checks).
|
|
16
|
+
|
|
17
|
+
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. Both helpers also throw when `user` is null: `ctx.requireAccess(channelId)` throws if user is not in the channel, `ctx.requireRole(roleName)` throws if user is not in the role.
|
|
18
|
+
|
|
19
|
+
The access function's scope is the app's own databases: a platform component's own database (a `media:*` name) carries platform-authored access rules the server evaluates separately, so your access function writes rules only for the databases the app itself creates. Within those, platform-driven writes onto the app's own docs — like an `<ImgGen>` version append (the `ctx.isImgGenVersionAppend` shape) — flow through your function like any other write.
|
|
20
|
+
|
|
21
|
+
### AccessDescriptor return type
|
|
22
|
+
|
|
23
|
+
A stored document must be routed to at least one channel (`channels`) to be readable — a result with no channels is refused at write time ("unreadable write"). To reject a write outright, `throw { forbidden: "reason" }`.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
type AccessDescriptor = {
|
|
27
|
+
channels?: string[]; // route this doc to channels
|
|
28
|
+
members?: Record<roleName, userHandle[]>; // role membership (reduced by union)
|
|
29
|
+
grant?: {
|
|
30
|
+
users?: Record<userHandle, string[]>; // direct user → channel grants (reduced by union)
|
|
31
|
+
roles?: Record<roleName, string[]>; // role → channel grants (reduced by union)
|
|
32
|
+
public?: string[]; // member-public read — any member, no channel grant needed
|
|
33
|
+
};
|
|
34
|
+
expiry?: string | number | null; // ISO date or unix seconds
|
|
35
|
+
allowAnonymous?: boolean; // opt-in for null-user writes
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### The ImgGen version-append nuance
|
|
40
|
+
|
|
41
|
+
The platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically for author-only display control. Write-once docs can simply `if (oldDoc === null) {} else throw`.
|
|
42
|
+
|
|
43
|
+
### `_id` strategy matters
|
|
44
|
+
|
|
45
|
+
Documents that represent a unique named resource (channels, user profiles, config singletons, seed/starter content) should use a deterministic `_id` with a short prefix — `"ch:" + name`, `"profile:" + handle`, `"config"`, `"seed:" + key`. This enforces uniqueness: two users creating "general" get the same doc, not two — and a first-load seed that runs again (a fresh tab, another device) overwrites the same starter docs instead of duplicating them. Documents that represent events or content (messages, posts, survey responses) should let `_id` be auto-generated — each one is unique by nature. Use `doc._id` as the channel name for resource docs; use a `channelId` foreign key on content docs.
|
|
46
|
+
|
|
47
|
+
### Grants are additive
|
|
48
|
+
|
|
49
|
+
The effective access state is the union of every current document's `AccessDescriptor` output. There is no "remove grant" operation — deleting a document drops its contribution from the union automatically. This makes revocation trivial: delete the document that granted access, and the grant disappears on next sync.
|
|
50
|
+
|
|
51
|
+
**Grant resolution order:** the server resolves per-user channel access in two passes — first expand `grant.roles` through `members`, then union with `grant.users` direct grants.
|
|
52
|
+
|
|
53
|
+
### `allowAnonymous`
|
|
54
|
+
|
|
55
|
+
If `user` is `null` and the function returns without throwing, the runtime checks `allowAnonymous`. If absent or `false`, the write is rejected. This prevents a function that never inspects `user` from silently opening anonymous writes. When `user` is not null, `allowAnonymous` has no effect. `grant.public` makes channels readable by any member (anyone through the door) without a specific channel grant — whether non-members can also read depends on the app-level public toggle. Anonymous _write_ requires `allowAnonymous: true` separately.
|
|
56
|
+
|
|
57
|
+
### Choosing channels — keep the count low
|
|
58
|
+
|
|
59
|
+
A channel is a _reusable_ unit of read access: grant a user into a channel once and they can read every document routed there. Reach for the smallest number of channels the sharing actually requires.
|
|
60
|
+
|
|
61
|
+
- **A reusable group reads many docs** (a team, a board, a project): route to one channel the _collaboration_ owns — `return { channels: [doc.channelId] }` — and grant membership once via a meta or invite doc. Many documents share the one channel.
|
|
62
|
+
- **Only the author reads it** (private notes, a user's own uploads): route to one channel the _user_ owns. `const mine = \`user:${user.userHandle}\`; return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };` — all of that user's private documents live in this single channel.
|
|
63
|
+
- **A document goes to a one-off set with no reusable group:** route to several channels at once — `return { channels: [\`user:${aHandle}\`, \`user:${bHandle}\`] }`. Mint a per-document channel (`channels: [doc._id]`) only when each document genuinely has its own disjoint audience.
|
|
64
|
+
- **Refusing a write:** `throw { forbidden: "reason" }`. Every document you store is routed to at least one channel so it can be read back.
|
|
65
|
+
|
|
66
|
+
### Multiple databases in one file
|
|
67
|
+
|
|
68
|
+
Each named export gates its own database. A single `/access.js` can gate all databases the app uses:
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
72
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
73
|
+
ctx.requireAccess(doc.channelId);
|
|
74
|
+
return { channels: [doc.channelId] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function notes(doc, oldDoc, user, ctx) {
|
|
78
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
79
|
+
// Private to the author — one channel per user.
|
|
80
|
+
const mine = `user:${user.userHandle}`;
|
|
81
|
+
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Databases without a matching named export fall through to `export default` if one exists. If there is no default export either, the database uses the default app-level permissions (no access function).
|
|
86
|
+
|
|
87
|
+
**Hyphenated database names** are rare — prefer camelCase (`useFireproof("crewChat")`). If you inherit a hyphenated name, use `export { localName as "db-name" }` to map a local function:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
function crewChat(doc, oldDoc, user, ctx) {
|
|
91
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
92
|
+
ctx.requireAccess(doc.channelId);
|
|
93
|
+
return { channels: [doc.channelId] };
|
|
94
|
+
}
|
|
95
|
+
export { crewChat as "crew-chat" };
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Catch-all with `export default`
|
|
99
|
+
|
|
100
|
+
Use `export default` to gate every database without writing a named export for each one. Named exports (including `as` exports) still take precedence for databases that need custom logic:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
104
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
105
|
+
ctx.requireAccess(doc.channelId);
|
|
106
|
+
return { channels: [doc.channelId] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Everything else: authenticated users get a private per-user channel
|
|
110
|
+
export default function (doc, oldDoc, user, ctx) {
|
|
111
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
112
|
+
const mine = `user:${user.userHandle}`;
|
|
113
|
+
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This is especially useful when an app has many databases.
|
|
118
|
+
|
|
119
|
+
### Common `oldDoc` patterns
|
|
120
|
+
|
|
121
|
+
Use `oldDoc` (the previous version of the document) to enforce invariants across updates: `if (oldDoc === null) { /* create-only logic */ }` for new documents, and `if (oldDoc && doc.version <= oldDoc.version) { throw { forbidden: "version must increase" } }` for monotonic versions.
|
|
122
|
+
|
|
123
|
+
### Per-Database Access Control (`acl` option)
|
|
124
|
+
|
|
125
|
+
On vibes.diy, `useFireproof` accepts an optional `acl` argument that declares who can read, write, or delete documents in that database. The ACL is stored server-side and enforced on every operation — no separate API call needed.
|
|
126
|
+
Only use the `acl` option when the user explicitly asks for fine-grained access control (or equivalent permission constraints).
|
|
127
|
+
|
|
128
|
+
```jsx
|
|
129
|
+
// Anyone with a grant can post; only editors can delete
|
|
130
|
+
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
131
|
+
acl: { write: ["members"], delete: ["editors"] },
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Subject groups** — who each name covers:
|
|
136
|
+
|
|
137
|
+
| Group | Who is included |
|
|
138
|
+
| ------------ | ----------------------------------------------------------- |
|
|
139
|
+
| `members` | owner + editor + viewer + submitter (anyone with any grant) |
|
|
140
|
+
| `editors` | owner + editor |
|
|
141
|
+
| `submitters` | owner + submitter |
|
|
142
|
+
| `readers` | owner + editor + viewer |
|
|
143
|
+
|
|
144
|
+
Owner is always implicitly included — never list `owner` explicitly in an ACL.
|
|
145
|
+
|
|
146
|
+
Each capability (`read`, `write`, `delete`) is independent. Omitting one falls back to the app-level role gate for that operation. The `acl` is sent once on first database open and persists across sessions (last-write-wins). Only the **app owner** can set ACLs; non-owner apps opening a database with an `acl` option have it silently ignored — the database still opens and works normally.
|
|
147
|
+
|
|
148
|
+
Other `acl` variants: `useFireproof("drafts", { acl: { read: ["editors"], write: ["editors"], delete: ["editors"] } })` for editors-only space, or omit `acl` entirely to fall back to app-level role gates (existing behavior, always safe).
|
|
8
149
|
|
|
9
150
|
## When to emit access.js, and where it goes
|
|
10
151
|
|
package/llms/fireproof.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# Fireproof Database API Guide
|
|
2
2
|
|
|
3
|
-
Fireproof is a document database with live sync, designed to make browser apps easy. On vibes.diy it runs against Firefly: each app holds a local replica (IndexedDB) that IS the database, so writes succeed locally and instantly and then sync in the background. The Firefly server validates every synced write with
|
|
3
|
+
Fireproof is a document database with live sync, designed to make browser apps easy. On vibes.diy it runs against Firefly: each app holds a local replica (IndexedDB) that IS the database, so writes succeed locally and instantly and then sync in the background. The Firefly server validates every synced write with the app's access rules on ingest and can still reject it (access denied, conflict); accepted writes stream live to every viewer. Use it in any JavaScript environment with a unified API that works both in React (with hooks) and as a standalone core API.
|
|
4
4
|
|
|
5
5
|
## Key Features
|
|
6
6
|
|
|
7
7
|
- **Apps run anywhere:** Bundle UI, data, and logic together.
|
|
8
|
-
- **Real-Time, local-first:** Writes land in the local replica instantly and sync in the background; the server validates each on ingest and streams the accepted ones live to every viewer. `useLiveQuery` keeps the UI in sync as data arrives, so you render empty states rather than loading spinners — but a synced write can still be rejected on ingest (access denied, conflicts)
|
|
8
|
+
- **Real-Time, local-first:** Writes land in the local replica instantly and sync in the background; the server validates each on ingest and streams the accepted ones live to every viewer. `useLiveQuery` keeps the UI in sync as data arrives, so you render empty states rather than loading spinners — but a synced write can still be rejected on ingest (access denied, conflicts): the local optimistic revision converges **server-wins** — and the platform surfaces the reason **only when the app's access rules provide one**; a reason-less rejection (e.g. a db-level ACL) converges silently. Keep the UI reactive to the store rather than depending on a toast; don't assume every write lands, but don't hand-roll denial errors either.
|
|
9
9
|
- **Unified API:** TypeScript works with Deno, Bun, Node.js, and the browser.
|
|
10
10
|
- **React Hooks:** Leverage `useLiveQuery` and `useDocument` for live collaboration. Note: these are NOT top-level exports — they are returned by the `useFireproof()` hook. Always destructure from `const { useLiveQuery, useDocument, database } = useFireproof("dbName")`.
|
|
11
11
|
|
|
12
|
-
**File structure:** A vibe's source is one or more files. `/App.jsx` is the entry point (React component).
|
|
12
|
+
**File structure:** A vibe's source is one or more files. `/App.jsx` is the entry point (React component).
|
|
13
13
|
|
|
14
|
-
Fireproof enforces cryptographic causal consistency and ledger integrity using hash history, providing git-like versioning with lightweight blockchain-style verification. On vibes.diy, a write commits to the local replica immediately and syncs to the Firefly server in the background; the server is the authority on acceptance: it stores each document in a per-document append-only sequence,
|
|
14
|
+
Fireproof enforces cryptographic causal consistency and ledger integrity using hash history, providing git-like versioning with lightweight blockchain-style verification. On vibes.diy, a write commits to the local replica immediately and syncs to the Firefly server in the background; the server is the authority on acceptance: it stores each document in a per-document append-only sequence, applies the app's access rules to validate and route it on ingest, and then syncs it to viewers. Because every synced write is validated server-side on ingest, it is subject to access rules and can be rejected.
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -21,7 +21,7 @@ Each document has an `_id`, which can be auto-generated or set explicitly. Auto-
|
|
|
21
21
|
|
|
22
22
|
Use granular documents, e.g. one document per user action, so saving a form or clicking a button should typically create or update a single document, or just a few documents. Avoid patterns that require a single document to grow without bound.
|
|
23
23
|
|
|
24
|
-
`useLiveQuery` populates and refreshes the UI reactively as data arrives, so you usually render empty states rather than loading spinners. Writes,
|
|
24
|
+
`useLiveQuery` populates and refreshes the UI reactively as data arrives, so you usually render empty states rather than loading spinners. Writes are optimistic and local: `put()`/`save()`/`del()` land in the local replica instantly, and transport or offline failures queue and retry automatically. If the server's access rules reject a synced write, the local optimistic revision converges **server-wins** (it is overwritten by the server's version) — and the **platform** surfaces the reason in a write-fail toast **only when the app's access rules provide one** (`forbidden("…")`); a reason-less rejection (e.g. a db-level ACL) converges silently. So do **not** wrap writes in `try/catch` to show denial errors to the user; keep the UI reactive to the store (`useLiveQuery`/`useDocument`) so the converged state is the feedback, and gate write surfaces up front with `useVibe(dbName).can.create/edit/delete` so a disallowed action never renders in the first place.
|
|
25
25
|
|
|
26
26
|
### Basic Example
|
|
27
27
|
|
|
@@ -59,19 +59,6 @@ export default function App() {
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
The access function lives in a separate file. Even simple apps include one — it's the server-side authority for who can write, and it routes each document to a channel so the author can read it back. Its scope is the app's own databases: a platform component's own database (a `media:*` name) carries platform-authored access rules the server evaluates separately, so your access function writes rules only for the databases the app itself creates. Within those, it branches on the doc types the app works with — including platform-driven writes onto the app's own docs, like an `<ImgGen>` version append (the `ctx.isImgGenVersionAppend` shape below), which flow through your function like any other write:
|
|
63
|
-
|
|
64
|
-
access.js
|
|
65
|
-
|
|
66
|
-
```js
|
|
67
|
-
export default function (doc, oldDoc, user) {
|
|
68
|
-
if (!user) throw { forbidden: "sign in to save" };
|
|
69
|
-
// Private to the author: one channel per user holds all of their documents.
|
|
70
|
-
const mine = `user:${user.userHandle}`;
|
|
71
|
-
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
62
|
### Editing Documents
|
|
76
63
|
|
|
77
64
|
Address documents by a known `_id` if you want to force conflict resolution or work with a real world resource, like a schedule slot or a user profile. In a complex app this might come from a route parameter or correspond to an outside identifier. To add a profile editor to the app above:
|
|
@@ -117,7 +104,7 @@ export default function App() {
|
|
|
117
104
|
React.useEffect(() => {
|
|
118
105
|
if (slides.length > 0) return; // already has content — don't seed
|
|
119
106
|
// Ask the access rules before writing. A doc type the currently-enforced
|
|
120
|
-
// access
|
|
107
|
+
// access rules don't allow yet (e.g. one this very edit just introduced —
|
|
121
108
|
// the update binds only after the whole turn lands) would be rejected:
|
|
122
109
|
// skip quietly now and seed on a later run instead of spamming errors.
|
|
123
110
|
if (!ready || !can.create(SEED[0]).ok) return;
|
|
@@ -131,7 +118,7 @@ export default function App() {
|
|
|
131
118
|
}
|
|
132
119
|
```
|
|
133
120
|
|
|
134
|
-
Don't stamp `Date.now()` or other changing values into seed documents — that makes each re-seed rewrite the doc with new content, churning revisions even though the `_id` is stable.
|
|
121
|
+
Don't stamp `Date.now()` or other changing values into seed documents — that makes each re-seed rewrite the doc with new content, churning revisions even though the `_id` is stable. Resource-like docs — channels, profiles, **seeds** — get deterministic `_id`s; event/content docs let `_id` auto-generate.
|
|
135
122
|
|
|
136
123
|
### Updating Documents in Event Handlers
|
|
137
124
|
|
|
@@ -341,42 +328,9 @@ App.jsx
|
|
|
341
328
|
>>>>>>> REPLACE
|
|
342
329
|
```
|
|
343
330
|
|
|
344
|
-
## Per-Database Access Control (`acl` option)
|
|
345
|
-
|
|
346
|
-
On vibes.diy, `useFireproof` accepts an optional `acl` argument that declares who can read, write, or delete documents in that database. The ACL is stored server-side and enforced on every operation — no separate API call needed.
|
|
347
|
-
Only use the `acl` option when the user explicitly asks for fine-grained access control (or equivalent permission constraints).
|
|
348
|
-
|
|
349
|
-
App.jsx
|
|
350
|
-
|
|
351
|
-
```jsx
|
|
352
|
-
<<<<<<< SEARCH
|
|
353
|
-
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
354
|
-
=======
|
|
355
|
-
// Anyone with a grant can post; only editors can delete
|
|
356
|
-
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
357
|
-
acl: { write: ["members"], delete: ["editors"] },
|
|
358
|
-
});
|
|
359
|
-
>>>>>>> REPLACE
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
**Subject groups** — who each name covers:
|
|
363
|
-
|
|
364
|
-
| Group | Who is included |
|
|
365
|
-
| ------------ | ----------------------------------------------------------- |
|
|
366
|
-
| `members` | owner + editor + viewer + submitter (anyone with any grant) |
|
|
367
|
-
| `editors` | owner + editor |
|
|
368
|
-
| `submitters` | owner + submitter |
|
|
369
|
-
| `readers` | owner + editor + viewer |
|
|
370
|
-
|
|
371
|
-
Owner is always implicitly included — never list `owner` explicitly in an ACL.
|
|
372
|
-
|
|
373
|
-
Each capability (`read`, `write`, `delete`) is independent. Omitting one falls back to the app-level role gate for that operation. The `acl` is sent once on first database open and persists across sessions (last-write-wins). Only the **app owner** can set ACLs; non-owner apps opening a database with an `acl` option have it silently ignored — the database still opens and works normally.
|
|
374
|
-
|
|
375
|
-
Other `acl` variants: `useFireproof("drafts", { acl: { read: ["editors"], write: ["editors"], delete: ["editors"] } })` for editors-only space, or omit `acl` entirely to fall back to app-level role gates (existing behavior, always safe).
|
|
376
|
-
|
|
377
331
|
## Offline writes are on by default (`offlineQueue`)
|
|
378
332
|
|
|
379
|
-
For signed-in users, writes are **local-first by default**: a `put`/`del` that fails on the network is durably queued on the device (it resolves, stays visible, and syncs to the cloud when you're back online) instead of rolling back. You don't opt in — every signed-in vibe gets this.
|
|
333
|
+
For signed-in users, writes are **local-first by default**: a `put`/`del` that fails on the network is durably queued on the device (it resolves, stays visible, and syncs to the cloud when you're back online) instead of rolling back. You don't opt in — every signed-in vibe gets this. An access-denied/validation rejection is **not** queued: it converges **server-wins** (the local optimistic revision is overwritten by the server's version); the platform surfaces the reason only when the app's access rules provide one, and a reason-less rejection (e.g. a db-level ACL) converges silently — so keep the UI reactive to the store rather than depending on a toast. Only transport failures queue and retry.
|
|
380
334
|
|
|
381
335
|
Pass `{ offlineQueue: false }` for **server-first, fail-fast** writes: a `put` resolves only when the server accepts it, and a network failure rejects immediately with nothing queued. Choose this for **collaborative multi-writer apps** where two people may edit the same doc — sync is blind last-arrival-wins (Firefly has no `_rev`), so a stale write replayed on reconnect can silently overwrite a newer one. When a lost write is safer than a surprise overwrite, opt out.
|
|
382
336
|
|
|
@@ -396,223 +350,13 @@ The old `{ anonymousLocal: true }` option (and its `migrate` hook) is **deprecat
|
|
|
396
350
|
|
|
397
351
|
## Reading Resolved Grants and worked access examples
|
|
398
352
|
|
|
399
|
-
`useFireproof()` returns an `access` property (resolved roles/channels for display)
|
|
400
|
-
|
|
401
|
-
---
|
|
402
|
-
|
|
403
|
-
## Access Function (`/access.js`)
|
|
404
|
-
|
|
405
|
-
Access functions are **the room** — they govern what members can do with data once inside the app. The per-vibe membership system is **the door** — it decides who can see the app at all. Once a user is through the door (approved as a member), the access function is the sole authority for data permissions. Access functions are server-run on every write (including deletes) before storing the document. They validate writes, route documents to channels, and declare grants that control who can read what. Only create an `/access.js` file when the user asks for per-document routing, channel-based isolation, or document-level write validation.
|
|
406
|
-
|
|
407
|
-
Access functions live in `/access.js`, a separate file in the vibe's filesystem alongside `/App.jsx`. **Always emit the access function as a block preceded by the filename `access.js` on its own line — never inside an `App.jsx` block.** Each **named export** maps to a database name — `export function chat(...)` gates `useFireproof("chat")`. An `export default` function acts as a catch-all: it gates any database that doesn't have its own named export. Named exports always take precedence over the default.
|
|
408
|
-
|
|
409
|
-
### Function signature
|
|
410
|
-
|
|
411
|
-
`(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor` where `doc` is the document being written, `oldDoc` is the previous version (null for new documents), `user` is the authenticated user or `null` for anonymous requests, and `ctx` provides server helpers for checking materialized state.
|
|
412
|
-
|
|
413
|
-
**UserContext:** `{ userHandle: string, displayName?: string }` — `userHandle` is stable unique id (use for all auth checks), `displayName` is display only (never use for identity checks).
|
|
414
|
-
|
|
415
|
-
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. Both helpers also throw when `user` is null: `ctx.requireAccess(channelId)` throws if user is not in the channel, `ctx.requireRole(roleName)` throws if user is not in the role.
|
|
416
|
-
|
|
417
|
-
**`requireAccess` checks _membership_, not public read — don't gate an open channel's writes on it.** `ctx.requireAccess(channelId)` passes only for a channel the user is a member of: granted directly through `grant.users[handle]`, or through a `grant.roles` role they hold. **`grant.public` does NOT satisfy `requireAccess`** — public is read-only ("anyone through the door can _read_"), it never confers write membership. So a channel that is only `grant.public` and gated on `ctx.requireAccess` can be written by **nobody** but the owner-in-admin-mode — every other write returns `not in channel`, silently hiding the form (`useVibe().can` faithfully reflects this). Choose by intent:
|
|
418
|
-
|
|
419
|
-
- **Open channel — any signed-in user may post** (public board, guestbook, comment wall): do **not** call `ctx.requireAccess`. Route the doc to the channel and check the author on create — `if (oldDoc === null && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` — keep ownership immutable on update (`else if (doc.authorHandle !== oldDoc.authorHandle) throw`), and let a non-author's `<ImgGen>` version append through via `ctx.isImgGenVersionAppend(doc, oldDoc)` as shown in "Author-equality gates create and ownership change" below; `return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
|
|
420
|
-
- **Restricted channel — only members may post**: gate the write on `ctx.requireAccess(doc.channelId)` **and** grant writers membership explicitly — `grant.users` (direct) or `grant.roles` + a `members`/role-grant doc. `public` alongside is read-only and is fine for letting non-members read, but it is never what lets a member write.
|
|
421
|
-
|
|
422
|
-
### AccessDescriptor return type
|
|
423
|
-
|
|
424
|
-
All fields are optional, but a stored document must be routed to at least one channel (`channels`) to be readable — a result with no channels is refused at write time ("unreadable write"). To reject a write outright, `throw { forbidden: "reason" }`.
|
|
425
|
-
|
|
426
|
-
**Grant/member/meta docs need a channel too.** A role grant, membership, or config singleton that returns only `members`/`grant` with **no `channels`** is refused exactly like any other channel-less write — so the owner can't even create it. Route these to an owner-readable **admin channel** (e.g. `channels: ["admin:grants"]` with `grant: { users: { [user.userHandle]: ["admin:grants"] } }`), not a public channel. The `members`/`grant` still take effect globally; the channel just makes the doc persist and lets the owner read the roster back.
|
|
427
|
-
|
|
428
|
-
**Author-equality gates create and ownership change, not every update.** Checking the new author field on create isn't enough — also pin ownership with `oldDoc`. But a blanket `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw` denies `<ImgGen>` version appends on a shared/public-read doc: a version append runs as the VIEWING user, and denying it after the platform already billed it arms a billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append — the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
|
|
429
|
-
|
|
430
|
-
```js
|
|
431
|
-
if (oldDoc === null) {
|
|
432
|
-
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
433
|
-
} else if (doc.authorHandle !== oldDoc.authorHandle) {
|
|
434
|
-
throw { forbidden: "cannot change author" };
|
|
435
|
-
} else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
|
|
436
|
-
throw { forbidden: "not author" };
|
|
437
|
-
}
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
`<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …). The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically for author-only display control. Write-once docs can simply `if (oldDoc === null) {} else throw`; a genuinely private per-user doc no other viewer can reach may stay author-only on update.
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
type AccessDescriptor = {
|
|
444
|
-
channels?: string[]; // route this doc to channels
|
|
445
|
-
members?: Record<roleName, userHandle[]>; // role membership (reduced by union)
|
|
446
|
-
grant?: {
|
|
447
|
-
users?: Record<userHandle, string[]>; // direct user → channel grants (reduced by union)
|
|
448
|
-
roles?: Record<roleName, string[]>; // role → channel grants (reduced by union)
|
|
449
|
-
public?: string[]; // member-public read — any member, no channel grant needed
|
|
450
|
-
};
|
|
451
|
-
expiry?: string | number | null; // ISO date or unix seconds
|
|
452
|
-
allowAnonymous?: boolean; // opt-in for null-user writes
|
|
453
|
-
};
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
### Key concepts
|
|
457
|
-
|
|
458
|
-
**Channels** route documents. A document with `channels: ["general"]` is only visible to users who have been granted access to `"general"`. Channels are the unit of read isolation.
|
|
459
|
-
|
|
460
|
-
**`_id` strategy matters.** Documents that represent a unique named resource (channels, user profiles, config singletons, **seed/starter content**) should use a deterministic `_id` with a short prefix — `"ch:" + name`, `"profile:" + handle`, `"config"`, `"seed:" + key`. This enforces uniqueness: two users creating "general" get the same doc, not two — and a first-load seed that runs again (a fresh tab, another device) overwrites the same starter docs instead of duplicating them (see "Seeding starter data" above). Documents that represent events or content (messages, posts, survey responses) should let `_id` be auto-generated — each one is unique by nature. Use `doc._id` as the channel name for resource docs; use a `channelId` foreign key on content docs.
|
|
461
|
-
|
|
462
|
-
**Grants are additive.** The effective access state is the union of every current document's `AccessDescriptor` output. There is no "remove grant" operation — deleting a document drops its contribution from the union automatically. This makes revocation trivial: delete the document that granted access, and the grant disappears on next sync.
|
|
463
|
-
|
|
464
|
-
**Grant resolution order:** The server resolves per-user channel access in two passes — first expand `grant.roles` through `members`, then union with `grant.users` direct grants.
|
|
465
|
-
|
|
466
|
-
**`allowAnonymous` prevents a footgun.** If `user` is `null` and the function returns without throwing, the runtime checks `allowAnonymous`. If absent or `false`, the write is rejected. This prevents a function that never inspects `user` from silently opening anonymous writes. When `user` is not null, `allowAnonymous` has no effect. `grant.public` makes channels readable by any member (anyone through the door) without a specific channel grant — whether non-members can also read depends on the app-level public toggle. Anonymous _write_ requires `allowAnonymous: true` separately.
|
|
467
|
-
|
|
468
|
-
**Access functions are server-enforced policy code.** Checks should be deterministic over `(doc, oldDoc, user, ctx)` and deny with `throw { forbidden: "reason" }` when violated.
|
|
469
|
-
|
|
470
|
-
### Choosing channels — keep the count low
|
|
471
|
-
|
|
472
|
-
A channel is a _reusable_ unit of read access: grant a user into a channel once and they can read every document routed there. Reach for the smallest number of channels the sharing actually requires.
|
|
473
|
-
|
|
474
|
-
- **A reusable group reads many docs** (a team, a board, a project): route to one channel the _collaboration_ owns — `return { channels: [doc.channelId] }` — and grant membership once via a meta or invite doc. Many documents share the one channel.
|
|
475
|
-
- **Only the author reads it** (private notes, a user's own uploads): route to one channel the _user_ owns. `const mine = \`user:${user.userHandle}\`; return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };` — all of that user's private documents live in this single channel.
|
|
476
|
-
- **A document goes to a one-off set with no reusable group:** route to several channels at once — `return { channels: [\`user:${aHandle}\`, \`user:${bHandle}\`] }`. Mint a per-document channel (`channels: [doc._id]`) only when each document genuinely has its own disjoint audience.
|
|
477
|
-
- **Refusing a write:** `throw { forbidden: "reason" }`. Every document you store is routed to at least one channel so it can be read back.
|
|
478
|
-
|
|
479
|
-
### Multiple databases in one file
|
|
480
|
-
|
|
481
|
-
Each named export gates its own database. A single `/access.js` can gate all databases the app uses:
|
|
482
|
-
|
|
483
|
-
access.js
|
|
484
|
-
|
|
485
|
-
```js
|
|
486
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
487
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
488
|
-
ctx.requireAccess(doc.channelId);
|
|
489
|
-
return { channels: [doc.channelId] };
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
export function notes(doc, oldDoc, user, ctx) {
|
|
493
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
494
|
-
// Private to the author — one channel per user.
|
|
495
|
-
const mine = `user:${user.userHandle}`;
|
|
496
|
-
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
497
|
-
}
|
|
498
|
-
```
|
|
499
|
-
|
|
500
|
-
Databases without a matching named export fall through to `export default` if one exists. If there is no default export either, the database uses the default app-level permissions (no access function).
|
|
501
|
-
|
|
502
|
-
**Hyphenated database names** are rare — prefer camelCase (`useFireproof("crewChat")`). If you inherit a hyphenated name, use `export { localName as "db-name" }` to map a local function:
|
|
503
|
-
|
|
504
|
-
access.js
|
|
505
|
-
|
|
506
|
-
```js
|
|
507
|
-
function crewChat(doc, oldDoc, user, ctx) {
|
|
508
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
509
|
-
ctx.requireAccess(doc.channelId);
|
|
510
|
-
return { channels: [doc.channelId] };
|
|
511
|
-
}
|
|
512
|
-
export { crewChat as "crew-chat" };
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
### Catch-all with `export default`
|
|
516
|
-
|
|
517
|
-
Use `export default` to gate every database without writing a named export for each one. Named exports (including `as` exports) still take precedence for databases that need custom logic:
|
|
518
|
-
|
|
519
|
-
access.js
|
|
520
|
-
|
|
521
|
-
```js
|
|
522
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
523
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
524
|
-
ctx.requireAccess(doc.channelId);
|
|
525
|
-
return { channels: [doc.channelId] };
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Everything else: authenticated users get a private per-user channel
|
|
529
|
-
export default function (doc, oldDoc, user, ctx) {
|
|
530
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
531
|
-
const mine = `user:${user.userHandle}`;
|
|
532
|
-
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
533
|
-
}
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
This is especially useful when an app has many databases.
|
|
537
|
-
|
|
538
|
-
### Roles via `members` reduce
|
|
539
|
-
|
|
540
|
-
Roles are not a fixed registry. They are materialized from document contributions. A team-meta doc contributes members to a role:
|
|
541
|
-
|
|
542
|
-
access.js
|
|
543
|
-
|
|
544
|
-
```js
|
|
545
|
-
<<<<<<< SEARCH
|
|
546
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
547
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
548
|
-
ctx.requireAccess(doc.channelId);
|
|
549
|
-
return { channels: [doc.channelId] };
|
|
550
|
-
}
|
|
551
|
-
=======
|
|
552
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
553
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
554
|
-
|
|
555
|
-
// Grant/meta docs must also route to a channel — a channel-less result is
|
|
556
|
-
// rejected as "unreadable write". Route them to an owner-readable admin channel.
|
|
557
|
-
if (doc.type === "team-meta") {
|
|
558
|
-
ctx.requireRole("owner");
|
|
559
|
-
return {
|
|
560
|
-
channels: ["admin:grants"],
|
|
561
|
-
members: { [doc.teamId]: doc.memberHandles },
|
|
562
|
-
grant: { users: { [user.userHandle]: ["admin:grants"] }, roles: { [doc.teamId]: doc.channels } },
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (doc.type === "membership") {
|
|
567
|
-
ctx.requireRole("owner");
|
|
568
|
-
return {
|
|
569
|
-
channels: ["admin:grants"],
|
|
570
|
-
members: { [doc.role]: [doc.userHandle] },
|
|
571
|
-
grant: { users: { [user.userHandle]: ["admin:grants"] } },
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
ctx.requireAccess(doc.channelId);
|
|
576
|
-
return { channels: [doc.channelId] };
|
|
577
|
-
}
|
|
578
|
-
>>>>>>> REPLACE
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
Both patterns produce identical reduced state. Deleting a membership doc removes the user from the role automatically.
|
|
582
|
-
|
|
583
|
-
### Common `oldDoc` patterns
|
|
584
|
-
|
|
585
|
-
Use `oldDoc` (the previous version of the document) to enforce invariants across updates. Adding update guards to an access function:
|
|
586
|
-
|
|
587
|
-
access.js
|
|
588
|
-
|
|
589
|
-
```js
|
|
590
|
-
<<<<<<< SEARCH
|
|
591
|
-
if (doc.type === "team-meta") {
|
|
592
|
-
ctx.requireRole("owner");
|
|
593
|
-
return {
|
|
594
|
-
=======
|
|
595
|
-
if (doc.type === "team-meta") {
|
|
596
|
-
ctx.requireRole("owner");
|
|
597
|
-
// Immutable-after-create fields
|
|
598
|
-
if (oldDoc && doc.createdBy !== oldDoc.createdBy) {
|
|
599
|
-
throw { forbidden: "createdBy is immutable" };
|
|
600
|
-
}
|
|
601
|
-
// Prevent unauthorized ownership transfer
|
|
602
|
-
if (oldDoc && oldDoc.ownerHandle !== user.userHandle) {
|
|
603
|
-
throw { forbidden: "not owner" };
|
|
604
|
-
}
|
|
605
|
-
return {
|
|
606
|
-
>>>>>>> REPLACE
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
Other common `oldDoc` patterns: `if (oldDoc === null) { /* create-only logic */ }` for new documents, and `if (oldDoc && doc.version <= oldDoc.version) { throw { forbidden: "version must increase" } }` for monotonic versions.
|
|
353
|
+
`useFireproof()` returns an `access` property (resolved roles/channels for display). The full worked access examples — function signature, `AccessDescriptor`, channels, grants, and the round-trip UI that reads them back — live in the **access skill**, which is included whenever the app is permission-shaped (privacy, sharing, teams, members, roles, approval). Gate every write surface with `useVibe(dbName).can`.
|
|
610
354
|
|
|
611
355
|
---
|
|
612
356
|
|
|
613
357
|
## Architecture: Where's My Data?
|
|
614
358
|
|
|
615
|
-
Data lives in a local replica (IndexedDB) that the app reads and writes instantly; that replica syncs to the Firefly server in the background. The server is the authority on acceptance — each synced write is validated by
|
|
359
|
+
Data lives in a local replica (IndexedDB) that the app reads and writes instantly; that replica syncs to the Firefly server in the background. The server is the authority on acceptance — each synced write is validated by the app's access rules on ingest, persisted, and then synced to all users who have read access. A write that fails validation or hits a conflict is rejected on ingest: the local optimistic revision converges **server-wins**, and the platform surfaces the reason **only when the app's access rules provide one** — a reason-less rejection (e.g. a db-level ACL) converges silently, so keep the UI reactive to the store rather than depending on a toast. Don't assume the write always lands, but don't hand-roll your own denial error UI — let it converge.
|
|
616
360
|
|
|
617
361
|
## Using Fireproof in JavaScript
|
|
618
362
|
|
|
@@ -768,17 +512,6 @@ App.jsx
|
|
|
768
512
|
>>>>>>> REPLACE
|
|
769
513
|
```
|
|
770
514
|
|
|
771
|
-
access.js
|
|
772
|
-
|
|
773
|
-
```js
|
|
774
|
-
export function imageUploads(doc, oldDoc, user) {
|
|
775
|
-
if (!user) throw { forbidden: "sign in to upload" };
|
|
776
|
-
// Each uploader reads their own images — one private channel per user.
|
|
777
|
-
const mine = `user:${user.userHandle}`;
|
|
778
|
-
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
779
|
-
}
|
|
780
|
-
```
|
|
781
|
-
|
|
782
515
|
### Form Validation
|
|
783
516
|
|
|
784
517
|
You can use React's `useState` to manage validation states and error messages. Validate inputs at the UI level before allowing submission. Adding validation to the uploader:
|
|
@@ -900,19 +633,3 @@ export default function App() {
|
|
|
900
633
|
```
|
|
901
634
|
|
|
902
635
|
IMPORTANT: Don't use `useState()` on form data, instead use `merge()` and `submit()` from `useDocument`. Only use `useState` for ephemeral UI state (active tabs, open/closed panels, cursor positions). Keep your data model in Fireproof.
|
|
903
|
-
|
|
904
|
-
The todo app's access function validates authorship:
|
|
905
|
-
|
|
906
|
-
access.js
|
|
907
|
-
|
|
908
|
-
```js
|
|
909
|
-
export function todoList(doc, oldDoc, user) {
|
|
910
|
-
if (!user) throw { forbidden: "sign in" };
|
|
911
|
-
if (doc.type === "todo" && doc.createdBy !== user.userHandle) {
|
|
912
|
-
throw { forbidden: "only the author can edit" };
|
|
913
|
-
}
|
|
914
|
-
// Private to the author — one channel per user.
|
|
915
|
-
const mine = `user:${user.userHandle}`;
|
|
916
|
-
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
|
|
917
|
-
}
|
|
918
|
-
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"description": "",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"license": "Apache-2.0",
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@adviser/cement": "~0.5.34",
|
|
27
|
-
"@vibes.diy/call-ai-v2": "^10.0
|
|
28
|
-
"@vibes.diy/identity": "^10.0
|
|
29
|
-
"@vibes.diy/use-vibes-types": "^10.0
|
|
27
|
+
"@vibes.diy/call-ai-v2": "^10.6.0",
|
|
28
|
+
"@vibes.diy/identity": "^10.6.0",
|
|
29
|
+
"@vibes.diy/use-vibes-types": "^10.6.0",
|
|
30
30
|
"arktype": "~2.2.3",
|
|
31
31
|
"json-schema-faker": "~0.6.2"
|
|
32
32
|
},
|
|
@@ -31,7 +31,7 @@ You are an AI assistant tasked with creating React components. You should create
|
|
|
31
31
|
- Use `callAI` to fetch AI, use schema like this: `JSON.parse(await callAI(prompt, { schema: { properties: { todos: { type: 'array', items: { type: 'string' } } } } }))` and save final responses as individual Fireproof documents.
|
|
32
32
|
- Always show loading states during genuinely-network async operations (callAI, fetch): use a useState boolean (e.g. `isLoading`), set it true before the call and false in .finally(). While loading: (1) disable the trigger button with `disabled={isLoading}`, (2) replace the button text with a spinning SVG icon using CSS animation `animate-spin` (a simple circle with a gap), (3) optionally show a short status text like 'Loading...' near the button. Never leave the user clicking a button with no visual feedback. Pattern: `setIsLoading(true); try { await callAI(...); } finally { setIsLoading(false); }`
|
|
33
33
|
- Database reads are not a network operation — never show a loading state or spinner for them. `useLiveQuery` reads a hydrated local replica, so the first render already contains the data; an empty result means the database is genuinely empty. Render an inviting empty-state instead (friendly copy that prompts the first action), never a loading state.
|
|
34
|
-
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile.
|
|
34
|
+
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile. If the server refuses a synced write, the local value converges to the server's version automatically — your code never catches it, so don't `try/catch` writes. The person may see a short explanation, but a refusal can also converge silently — so keep your UI driven by the live data instead of expecting a popup, and the current state will be right. Transport and offline failures are handled by the built-in queue, so don't hand-roll retry either. Never let a checkbox tap, toggle, or saved inline edit sit with no visible response.
|
|
35
35
|
- For file uploads use drag and drop and store using the `doc._files` API; for AI image generation use `<ImgGen prompt="..." />`
|
|
36
36
|
- Access control is decided by the runtime, not your code. Gate every write surface — forms, submit/edit/delete buttons, any mutating action — on `useVibe(dbName).can`. `const { me, can, ready } = useVibe("comments")` from `"use-vibes"`, passing the Fireproof database name you write to. Show the editor when `can.create(draft).ok` (or `can.edit(doc)` / `can.delete(doc)`); while `ready` is false show a neutral skeleton/disabled state; when denied, render `can.create(draft).reason` as the fallback copy (the sign-in or join prompt). `can.*` asks the server's permission rules — the same rules the server enforces — so NEVER derive write permission from `viewer`, `access.hasRole()`/`access.hasChannel()`, or document fields. `useViewer()` is identity/display only: `const { ViewerTag } = useViewer()` renders **other** people (`<ViewerTag userHandle={...} />` for comment authors, rosters, "added by" labels). The current viewer's own pill and the sign-in button are system chrome in the Vibes Switch (the panel the logo opens) — don't add a header pill or login button for the current user. Owner-only management UI is gated on `can.*` too (the runtime knows the owner). This applies to every app — the runtime decides sharing, not the prompt. Writes can still be rejected server-side even when `can.*` allows, so keep the optimistic-write + rollback handling. See use-vibe docs.
|
|
37
37
|
- Don't try to generate png or base64 data, use placeholder image APIs instead, like https://picsum.photos/400 where 400 is the square size
|
|
@@ -57,6 +57,7 @@ The sandbox serves raw ES modules, so `App.jsx` can import local `.js`/`.jsx` fi
|
|
|
57
57
|
- When an app would grow past ~500 lines of `App.jsx`, split it: move feature components into their own files (e.g. `components/Feed.jsx`, one feature per file, `export default`) and import them from `App.jsx`. Emit each new file as its own complete fenced code block, preceded by its file path on its own line, exactly like `App.jsx`.
|
|
58
58
|
- When editing an app whose `App.jsx` is already near or over 500 lines, add new features as NEW files instead of enlarging `App.jsx`: emit the new component file in full, then a small edit to `App.jsx` that adds the import and renders the component. When you're already rewriting an existing feature, move it out to its own file the same way.
|
|
59
59
|
- `App.jsx` stays the composition root: the default `App` export and the top-level layout live there and only there. No file defines a `:root` theme token block — the platform injects the CSS variables as globals — so each extracted file defines its own small `classNames` object routed through the same `var(--token)` values.
|
|
60
|
+
- Values used by more than one file — constants, option lists, small pure helpers, document-shape literals (e.g. a `STAGES` array or a `CATEGORIES` list) — live in their own small leaf module (e.g. `lib/stages.js`) that both `App.jsx` and the feature components import, each with the relative path from its own location (`import { STAGES } from "./lib/stages.js"` from `App.jsx` at the root, `"../lib/stages.js"` from a file in `components/`). Imports flow one direction: `App.jsx` imports the feature components, and `App.jsx` and the components both import the shared leaf modules — so every shared value has one home that the composition root and the components reach the same way.
|
|
60
61
|
|
|
61
62
|
**Write the complete app in one `App.jsx` block.** Because `App.jsx` doesn't exist yet, emit a single fenced ```jsx block containing the entire finished component — not a colored shell to fill in later. Write it all at once:
|
|
62
63
|
|
package/system-prompt-initial.md
CHANGED
|
@@ -31,7 +31,7 @@ You are an AI assistant tasked with creating React components. You should create
|
|
|
31
31
|
- Use `callAI` to fetch AI, use schema like this: `JSON.parse(await callAI(prompt, { schema: { properties: { todos: { type: 'array', items: { type: 'string' } } } } }))` and save final responses as individual Fireproof documents.
|
|
32
32
|
- Always show loading states during genuinely-network async operations (callAI, fetch): use a useState boolean (e.g. `isLoading`), set it true before the call and false in .finally(). While loading: (1) disable the trigger button with `disabled={isLoading}`, (2) replace the button text with a spinning SVG icon using CSS animation `animate-spin` (a simple circle with a gap), (3) optionally show a short status text like 'Loading...' near the button. Never leave the user clicking a button with no visual feedback. Pattern: `setIsLoading(true); try { await callAI(...); } finally { setIsLoading(false); }`
|
|
33
33
|
- Database reads are not a network operation — never show a loading state or spinner for them. `useLiveQuery` reads a hydrated local replica, so the first render already contains the data; an empty result means the database is genuinely empty. Render an inviting empty-state instead (friendly copy that prompts the first action), never a loading state.
|
|
34
|
-
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile.
|
|
34
|
+
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile. If the server refuses a synced write, the local value converges to the server's version automatically — your code never catches it, so don't `try/catch` writes. The person may see a short explanation, but a refusal can also converge silently — so keep your UI driven by the live data instead of expecting a popup, and the current state will be right. Transport and offline failures are handled by the built-in queue, so don't hand-roll retry either. Never let a checkbox tap, toggle, or saved inline edit sit with no visible response.
|
|
35
35
|
- For file uploads use drag and drop and store using the `doc._files` API; for AI image generation use `<ImgGen prompt="..." />`
|
|
36
36
|
- Access control is decided by the runtime, not your code. Gate every write surface — forms, submit/edit/delete buttons, any mutating action — on `useVibe(dbName).can`. `const { me, can, ready } = useVibe("comments")` from `"use-vibes"`, passing the Fireproof database name you write to. Show the editor when `can.create(draft).ok` (or `can.edit(doc)` / `can.delete(doc)`); while `ready` is false show a neutral skeleton/disabled state; when denied, render `can.create(draft).reason` as the fallback copy (the sign-in or join prompt). `can.*` asks the server's permission rules — the same rules the server enforces — so NEVER derive write permission from `viewer`, `access.hasRole()`/`access.hasChannel()`, or document fields. `useViewer()` is identity/display only: `const { ViewerTag } = useViewer()` renders **other** people (`<ViewerTag userHandle={...} />` for comment authors, rosters, "added by" labels). The current viewer's own pill and the sign-in button are system chrome in the Vibes Switch (the panel the logo opens) — don't add a header pill or login button for the current user. Owner-only management UI is gated on `can.*` too (the runtime knows the owner). This applies to every app — the runtime decides sharing, not the prompt. Writes can still be rejected server-side even when `can.*` allows, so keep the optimistic-write + rollback handling. See use-vibe docs.
|
|
37
37
|
- Don't try to generate png or base64 data, use placeholder image APIs instead, like https://picsum.photos/400 where 400 is the square size
|
|
@@ -57,6 +57,7 @@ The sandbox serves raw ES modules, so `App.jsx` can import local `.js`/`.jsx` fi
|
|
|
57
57
|
- When an app would grow past ~500 lines of `App.jsx`, split it: move feature components into their own files (e.g. `components/Feed.jsx`, one feature per file, `export default`) and import them from `App.jsx`. Emit each new file as its own complete fenced code block, preceded by its file path on its own line, exactly like `App.jsx`.
|
|
58
58
|
- When editing an app whose `App.jsx` is already near or over 500 lines, add new features as NEW files instead of enlarging `App.jsx`: emit the new component file in full, then a small edit to `App.jsx` that adds the import and renders the component. When you're already rewriting an existing feature, move it out to its own file the same way.
|
|
59
59
|
- `App.jsx` stays the composition root: the default `App` export and the top-level layout live there and only there. No file defines a `:root` theme token block — the platform injects the CSS variables as globals — so each extracted file defines its own small `classNames` object routed through the same `var(--token)` values.
|
|
60
|
+
- Values used by more than one file — constants, option lists, small pure helpers, document-shape literals (e.g. a `STAGES` array or a `CATEGORIES` list) — live in their own small leaf module (e.g. `lib/stages.js`) that both `App.jsx` and the feature components import, each with the relative path from its own location (`import { STAGES } from "./lib/stages.js"` from `App.jsx` at the root, `"../lib/stages.js"` from a file in `components/`). Imports flow one direction: `App.jsx` imports the feature components, and `App.jsx` and the components both import the shared leaf modules — so every shared value has one home that the composition root and the components reach the same way.
|
|
60
61
|
|
|
61
62
|
**Step 1 — Colored shell (one `create` block).** Emit a single fenced ```jsx block — `App.jsx` doesn't exist yet. The shell paints real colors and shape on the first render so the user sees the app taking form immediately. It contains:
|
|
62
63
|
|
package/system-prompt.md
CHANGED
|
@@ -31,7 +31,7 @@ You are an AI assistant tasked with creating React components. You should create
|
|
|
31
31
|
- Use `callAI` to fetch AI, use schema like this: `JSON.parse(await callAI(prompt, { schema: { properties: { todos: { type: 'array', items: { type: 'string' } } } } }))` and save final responses as individual Fireproof documents.
|
|
32
32
|
- Always show loading states during genuinely-network async operations (callAI, fetch): use a useState boolean (e.g. `isLoading`), set it true before the call and false in .finally(). While loading: (1) disable the trigger button with `disabled={isLoading}`, (2) replace the button text with a spinning SVG icon using CSS animation `animate-spin` (a simple circle with a gap), (3) optionally show a short status text like 'Loading...' near the button. Never leave the user clicking a button with no visual feedback. Pattern: `setIsLoading(true); try { await callAI(...); } finally { setIsLoading(false); }`
|
|
33
33
|
- Database reads are not a network operation — never show a loading state or spinner for them. `useLiveQuery` reads a hydrated local replica, so the first render already contains the data; an empty result means the database is genuinely empty. Render an inviting empty-state instead (friendly copy that prompts the first action), never a loading state.
|
|
34
|
-
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile.
|
|
34
|
+
- Give instant feedback for Fireproof writes too, not just for callAI/fetch. A `database.put` (toggling a checkbox, marking done, inline edits, reorder, like/vote counters) resolves against the local replica instantly — there is no visible in-flight window to spinner over — but the UI must still react the moment the user acts. Apply the change optimistically: flip the visible value immediately and let `useLiveQuery` reconcile. If a synced write is refused, the local optimistic revision converges to the server's version automatically — server-wins, and your code never catches it, so don't `try/catch` writes or hand-roll retry UI. Keep your UI reactive to the store (`useLiveQuery`/`useDocument`) so the converged state shows the truth. When `access.js` provides a reason (`forbidden("…")`) the platform surfaces it to the person; a reason-less refusal (e.g. a database ACL) converges silently, so don't strip your own UI expecting a popup — gate write surfaces up front with `useVibe(dbName).can`. Transport and offline failures are handled by the built-in queue, so don't hand-roll retry either. Never let a checkbox tap, toggle, or saved inline edit sit with no visible response.
|
|
35
35
|
- For file uploads use drag and drop and store using the `doc._files` API; for AI image generation use `<ImgGen prompt="..." />`
|
|
36
36
|
- Access control is decided by the runtime, not your code. Gate every write surface — forms, submit/edit/delete buttons, any mutating action — on `useVibe(dbName).can`. `const { me, can, ready } = useVibe("comments")` from `"use-vibes"`, passing the Fireproof database name you write to. Show the editor when `can.create(draft).ok` (or `can.edit(doc)` / `can.delete(doc)`); while `ready` is false show a neutral skeleton/disabled state; when denied, render `can.create(draft).reason` as the fallback copy (the sign-in or join prompt). `can.*` runs the app's own `access.js` — the same function the server enforces — so NEVER derive write permission from `viewer`, `access.hasRole()`/`access.hasChannel()`, or document fields. `useViewer()` is identity/display only: `const { ViewerTag } = useViewer()` renders **other** people (`<ViewerTag userHandle={...} />` for comment authors, rosters, "added by" labels). The current viewer's own pill and the sign-in button are system chrome in the Vibes Switch (the panel the logo opens) — don't add a header pill or login button for the current user. Owner-only management UI is gated on `can.*` too (the access.js encodes the owner rule). This applies to every app — the runtime decides sharing, not the prompt. Writes can still be rejected server-side even when `can.*` allows, so keep the optimistic-write + rollback handling. See use-vibe docs.
|
|
37
37
|
- Don't try to generate png or base64 data, use placeholder image APIs instead, like https://picsum.photos/400 where 400 is the square size
|
|
@@ -63,6 +63,7 @@ The sandbox serves raw ES modules, so `App.jsx` can import local `.js`/`.jsx` fi
|
|
|
63
63
|
- When an app would grow past ~500 lines of `App.jsx`, split it: move feature components into their own files (e.g. `components/Feed.jsx`, one feature per file, `export default`) and import them from `App.jsx`. Emit each new file as its own complete fenced code block, preceded by its file path on its own line, exactly like `App.jsx`.
|
|
64
64
|
- When editing an app whose `App.jsx` is already near or over 500 lines, add new features as NEW files instead of enlarging `App.jsx`: emit the new component file in full, then a small edit to `App.jsx` that adds the import and renders the component. When you're already rewriting an existing feature, move it out to its own file the same way.
|
|
65
65
|
- `App.jsx` stays the composition root: the default `App` export and the top-level layout live there and only there. No file defines a `:root` theme token block — the platform injects the CSS variables as globals — so each extracted file defines its own small `classNames` object routed through the same `var(--token)` values.
|
|
66
|
+
- Values used by more than one file — constants, option lists, small pure helpers, document-shape literals (e.g. a `STAGES` array or a `CATEGORIES` list) — live in their own small leaf module (e.g. `lib/stages.js`) that both `App.jsx` and the feature components import, each with the relative path from its own location (`import { STAGES } from "./lib/stages.js"` from `App.jsx` at the root, `"../lib/stages.js"` from a file in `components/`). Imports flow one direction: `App.jsx` imports the feature components, and `App.jsx` and the components both import the shared leaf modules — so every shared value has one home that the composition root and the components reach the same way.
|
|
66
67
|
|
|
67
68
|
**Emit a colored shell first, then access.js, then wire each feature with SEARCH/REPLACE edits.** The shell paints real colors and layout shape immediately. The access function commits to the permission model. Then each feature edit wires one component with hooks and data.
|
|
68
69
|
|