@vibes.diy/prompts 14.3.119 → 14.3.121
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 +273 -79
- package/llms/board-games.md +1 -1
- package/llms/callai.md +1 -2
- package/llms/connections.md +1 -1
- package/llms/fireproof.md +98 -106
- package/llms/use-vibe.md +2 -2
- package/llms/use-viewer.md +1 -1
- package/package.json +5 -5
- package/system-prompt-initial-oneshot.md +182 -66
- package/system-prompt-initial.md +182 -66
- package/system-prompt.md +12 -11
package/llms/access.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Access Control (`access.js`) — permission design
|
|
2
2
|
|
|
3
|
-
This doc is in view because the app is getting its `access.js`: a new app's first build writes its rules with its screens, and a later edit adds or changes them. Either way the records carry their keys (`creatorHandle` on the object, the parent id on each child, `authorHandle` on every record), and this build writes the rules that key on them. The rules and those keys are plumbing with nothing on screen of their own; the invite screen joins the build when the ask names other people, sharing, a group or a person by name, and is otherwise offered as an option after the build. Every app gets record rules, and the group shape taught below is the resting default. A person the ask names is let in by a record — see _A person the ask names is let in by a record_ below — so the build states the membership record's shape and the invite handler that writes it.
|
|
3
|
+
This doc is in view because the app is getting its `access.js`: a new app's first build writes its rules with its screens, and a later edit adds or changes them. Either way the records carry their keys (`creatorHandle` on the object, the parent id on each child, `authorHandle` on every record), and this build writes the rules that key on them. The rules and those keys are plumbing with nothing on screen of their own; the invite screen joins the build when the ask names other people, sharing, a group or a person by name, and when the app is a person's own collection it is the first screen's add-a-friend control, since adding a friend is how a collection is shared; otherwise it is offered as an option after the build. Every app gets record rules, and the group shape taught below is the resting default. A person the ask names is let in by a record — see _A person the ask names is let in by a record_ below — so the build states the membership record's shape and the invite handler that writes it.
|
|
4
4
|
|
|
5
5
|
**When an edit adds the rules to an app that already exists, the edit owes only what the rules need.** Read `App.jsx` for the object type, the field that names its creator and the parent id its children carry; name the channels from those records; write the complete `access.js`; then change `App.jsx` only where the rules require it — stable creator membership, typed content queries, any write surface not yet gated on `can`, and the invite screen when the ask names other people, sharing or a group — with SEARCH/REPLACE edits. A file the rules do not touch is not emitted, and stays as it is.
|
|
6
6
|
|
|
@@ -476,9 +476,11 @@ export function board(doc, oldDoc, user, ctx) {
|
|
|
476
476
|
|
|
477
477
|
`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, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) and let the author edit their own (as `wall.post` above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
|
|
478
478
|
|
|
479
|
+
**The sharing rule.** Each person's own records live on a channel named by their handle, granted on every write they make, so the app opens on their tree with nothing to create first; a friend joins by a `member` record the tree's owner writes. A second or shared thing is its own document, written by the person's own action (a tap, a submit, a pick), with a type `access.js` has a branch for, carrying the creator's handle and naming the channel its records route to. App-wide authority is a role the owner holds from the seed and hands out with a role record; rules check `ctx.requireRole`, never `user.isOwner`. The notes example below is the tree, _A second or shared object_ is the next node, and _App-wide authority is a role the owner hands out_ is the third. A tree's name or settings is a `settings` record (`_id: "settings-" + handle`) written only when the person changes them: its control checks `can.create` on the complete record while none is saved and `can.edit` on the saved record spread with the change once one is. A person's own action — a tap, a submit, a pick — is what starts a write; nothing writes on load or on mount.
|
|
480
|
+
|
|
479
481
|
**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 entries, 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.
|
|
480
482
|
|
|
481
|
-
**Every app ships `access.js`. When the person says "just for me", "no sharing" or "keep it single-user", each signed-in person gets their own
|
|
483
|
+
**Every app ships `access.js`. When the person says "just for me", "no sharing" or "keep it single-user", each signed-in person gets their own collection, private to them, and the way to share it is adding a friend, who then sees the collection.** That is the private per-user shape below (the notes example, whose `member` branch is the add-a-friend control's write): the person's tree is the channel named by their handle, and the add-a-friend control writes the `member` doc that grants the friend that channel. The rules key on the writer's own handle, so they read the same for the person who made the app and for anyone they invite later. The app's nature is sensitive, and who can open the app is its sharing setting, not the rules. A piece made for readers keeps its own publish step instead — a sketch drafted for a gallery, a post, an article — on the per-item channel this example shows, whose read-grant flips from a `visibility` field: a published piece is world-readable in `access.js` and reaches whoever can open the app — its members while it stays private, everyone once the owner opens it up. Publish copy says that plainly ("Published — everyone who can open this app sees it"):
|
|
482
484
|
|
|
483
485
|
### `access.js`
|
|
484
486
|
```js partial
|
|
@@ -508,6 +510,7 @@ export function sketches(doc, oldDoc, user, ctx) {
|
|
|
508
510
|
```js partial
|
|
509
511
|
export function notes(doc, oldDoc, user, ctx) {
|
|
510
512
|
if (!user) throw { forbidden: "sign in" };
|
|
513
|
+
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
|
|
511
514
|
const ch = `user:${user.userHandle}`; // this visitor's own private space
|
|
512
515
|
|
|
513
516
|
if (doc.type === "note") {
|
|
@@ -520,17 +523,32 @@ export function notes(doc, oldDoc, user, ctx) {
|
|
|
520
523
|
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
|
|
521
524
|
}
|
|
522
525
|
|
|
526
|
+
if (doc.type === "member") {
|
|
527
|
+
// the add-a-friend control's write: the named friend reads this person's whole collection.
|
|
528
|
+
// A membership stays its writer's, and who it names is fixed, so a friend who can read these
|
|
529
|
+
// records rewrites or removes none of them.
|
|
530
|
+
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
531
|
+
if (oldDoc && (oldDoc.addedBy !== user.userHandle || doc.userHandle !== oldDoc.userHandle)) throw { forbidden: "a membership is its writer's" };
|
|
532
|
+
return { channels: [ch], grant: { users: { [user.userHandle]: [ch], [doc.userHandle]: [ch] } } };
|
|
533
|
+
}
|
|
534
|
+
|
|
523
535
|
throw { forbidden: "unknown document type" };
|
|
524
536
|
}
|
|
525
537
|
|
|
526
|
-
// Summary: each person's notes are theirs alone, private from the first one they write. A note carries its writer's handle in authorHandle.
|
|
538
|
+
// Summary: each person's notes are theirs alone, private from the first one they write, and a friend they add reads their notes. A note carries its writer's handle in authorHandle; a member record names the friend in userHandle and who added them in addedBy.
|
|
527
539
|
```
|
|
528
540
|
|
|
529
|
-
Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small
|
|
541
|
+
Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small who-can-see cue beside the add-a-friend control ("Yours alone — add a friend to share your sill"), while the title, subtitle and hero say what the app is for. 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 — and the `member` doc the add-a-friend control writes grants the friend that one channel (`grant: { users: { [doc.userHandle]: [`user:${user.userHandle}`] } }`). 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.
|
|
530
542
|
|
|
531
543
|
**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 seed-swap shelf you invite your sibling 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.
|
|
532
544
|
|
|
533
|
-
**
|
|
545
|
+
**The sharing shape follows what one record is.** A record a person keeps for their own life — a check-in, an entry, a line in a list, a plant on a windowsill, a stretch in a routine — belongs to the collection it sits in: the whole collection is private to its person, and the way to share it is adding a friend, who then sees the collection. A piece made for readers — a post, an article, a gallery piece, a portfolio entry — carries its own draft-then-publish step, the ritual readers already know from blogs. A thing made to be seen the moment it exists — a meme, a caption, a guestbook line, a character in a cast — lands on an always-on shared channel. The person's own words move the dial: "let me pick which ones each friend sees" adds choosing items per friend to the add-a-friend control, and "drafts" or "publish" brings the per-item step to any record. Each shape ships with its one who-can-see line beside the control it describes, while the title, subtitle and hero say what the app is for:
|
|
546
|
+
|
|
547
|
+
- **A collection, shared by adding a friend.** Everything the person keeps routes to their tree, the channel named by their handle (`user:<handle>`), which only they read, and the first screen carries the add-a-friend control — the platform's `HandleInput` writing a `member` doc that grants that handle the collection's channel (`grant: { users: { [doc.userHandle]: [ch] } }`), the shape the notes example above works through. The friend sees the collection, and the cue beside the control reads in the app's words ("Yours alone — add a friend to share your sill"). This is the resting shape for a tracker, a log, a list, a routine, a shelf, and for an ask kept to themselves ("only I", "just me", "nobody else"). When the person asks to choose what each friend sees, the `member` doc carries the chosen ids and grants that friend each item's own channel — the worked example _A friend sees the items picked for them_ below.
|
|
548
|
+
- **A piece, drafted and then published.** Publish at the granularity of a **channel**: channels are the unit of read isolation, so the piece gets its **own** channel and only that channel's read-grant flips, from a `visibility` field the access fn reads — the sketches shape above (`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; the person's other pieces stay on their own channels, each with its own switch. 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). A publish control that names its destination — "Publish to the wall", "Share to the group" — is the best shape. Making the person's _whole_ space public is fine when that is the intent.
|
|
549
|
+
- **A thing, seen the moment it is made.** **An open-nature app — an image or meme wall, a bingo card, a party game — puts its records on an always-on `grant.public` channel from the first rules it ships, builds no publish step, and admits anonymous readers**, so everyone with the link sees what people make the moment they make it. The always-on channel is the least-surprise shape for anything playful and low-stakes (an image or meme wall, a group list, a guestbook): a `grant.public` channel, a members' channel, or an `audience` arm.
|
|
550
|
+
|
|
551
|
+
A privacy-sensitive domain on its own (a journal, a health tracker) gets the two controls from _Two controls_ above, off until the person says so — its buddy picker is this same add-a-friend control. **Every app that is neither privacy-sensitive nor an only-me ask ships with a sharing path already in it: either an always-on shared channel its records land on, a publish control, or an add-a-friend control on its first screen**, so what a person makes has a way to reach someone from the first build.
|
|
534
552
|
|
|
535
553
|
**Worked example — a cast everyone can meet: shared characters, private chats (a character chat, a creature gallery, a songbook).** Sharing is the baseline, so an app where people make things ships a way to see them on its first build, even when the ask names nobody else and the invite screen waits for an option. Here every character lands on one always-on public channel, so every visitor meets the whole cast with no step at all, and each maker edits only their own. A one-tap publish per character (the sketches shape above) is an equally good answer; this example shows the always-on one. Each chat, the thread and the story so far, is private to the person having it. Letting a friend join a chat is an option the app can offer later, as a membership on that chat. The app's code holds no notion of an owner. The canned characters the ask starts with are a fixed set of documents in `seed.json`, seeded where everyone reads them, and the app writes no copy of them for each visitor. Adding the seed is the owner's choice, so the app works without it: until then the cast opens empty, inviting the first person to make a character. The screen says who can see the cast ("Anyone with the link can meet the cast") beside a copy-link.
|
|
536
554
|
|
|
@@ -613,7 +631,7 @@ export function pieces(doc, oldDoc, user, ctx) {
|
|
|
613
631
|
|
|
614
632
|
The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own tallies and any buddy who granted them in. A practice log whose owner **asked to keep it to themselves** is instead the per-visitor shape shown above: every visitor's pieces and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
|
|
615
633
|
|
|
616
|
-
**Per-object collaboration is the
|
|
634
|
+
**Per-object collaboration is the shape of every second or shared thing** (the second worked example above), and certainly of anything that says "invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or names a board/canvas/room/whiteboard a group co-edits — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object, named from the record (`board:${doc._id}` on the object, `board:${doc.boardId}` on its children); 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). The app owner is not special in the data model: any signed-in person creates their own object and invites a friend into it, and the rule keys on `user.userHandle` or the object's creator, never on `user.isOwner`.
|
|
617
635
|
|
|
618
636
|
**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`.
|
|
619
637
|
|
|
@@ -933,7 +951,7 @@ Channel `_id` is the channel identifier everywhere. The access function uses `do
|
|
|
933
951
|
|
|
934
952
|
### Example: Per-object sharing (collaborate on your own objects, no admin)
|
|
935
953
|
|
|
936
|
-
Reach for this
|
|
954
|
+
Reach for this for a second or shared thing, and certainly whenever the prompt says **invite, join, collaborate, share with, together, with my partner/team** — a shared seed-swap shelf you invite a sibling to, a whiteboard people can join, a quilting circle's pattern queue a group builds together, a tracker one person starts and later shares with a coach. A list app where every signed-in user makes their own lists, sees only their own, and can invite anyone to collaborate on a specific list — peer to peer, with no app admin in the loop. The pattern: **a channel per object** (`list:<id>`); the creator grants themselves that channel at creation; child docs (items) gate on `ctx.requireAccess` of the list's channel, so **any member edits any item**; any current member shares the list by granting another user the same channel. Membership is direct `grant.users`, so each viewer's access scales with their own memberships. Nothing a person writes reaches anyone else until they chose it: a per-object channel starts with its creator as the only reader, and every other reader arrives through a grant that creator wrote.
|
|
937
955
|
|
|
938
956
|
### `access.js`
|
|
939
957
|
```js partial
|
|
@@ -1019,11 +1037,11 @@ App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `u
|
|
|
1019
1037
|
>>>>>>> REPLACE
|
|
1020
1038
|
```
|
|
1021
1039
|
|
|
1022
|
-
####
|
|
1040
|
+
#### A second or shared object, and a member doc that names the friend
|
|
1023
1041
|
|
|
1024
|
-
Reach for this
|
|
1042
|
+
Reach for this when the person makes a *second* thing of the same kind, or a thing several people share — a second board beside their own records, a seed-swap shelf a sibling is invited into, a beekeeping club's hive log, a trip a group plans together. The person's own records still open on their tree, the channel named by their handle (the notes example above); the object is the next node of the graph. Four parts make it feel finished:
|
|
1025
1043
|
|
|
1026
|
-
- **
|
|
1044
|
+
- **The object is a stored document written by the person's own action** — a submit that names it ("Start a board"), a tap on a template, a pick from a list. It has its own branch in `access.js`, carries `creatorHandle`, and its `_id` names the channel its records route to (`board:<_id>`). The creator holds that channel and its admin channel from the object's own write, so they remain a member when the board is empty, when a collaborator writes first, and after the last creator-authored item is deleted. Nothing creates it on load or on mount. **The child doc stores that board id rather than having each writer derive one**: the create uses the displayed object's id and the rule holds it fixed from then on, so a card stays on the board its creator put it on however many people edit it later.
|
|
1027
1045
|
- **Membership is a `member` doc, and picking the friend is `HandleInput`** (`useViewer()`, see use-viewer.md — the platform's people-picker, so the handle on the doc is one the platform resolved). The doc grants the named person the object's channel: `` grant: { users: { [doc.userHandle]: [chan] } } ``.
|
|
1028
1046
|
- **The object's creator is its admin**, held on a second channel (`` `${chan}/admin` ``) that the creator self-grants at creation. Adding a member is gated on that admin channel, so members collaborate and the creator decides who joins. The app owner holds no place in any of this: every rule reads `user.userHandle` or the object's own `creatorHandle`.
|
|
1029
1047
|
- **Recipients can select every readable object.** The ordinary object query already applies the access rules, including grants from independent membership records. Its complete result supplies the picker. Prefer the person's own object as the initial selection while keeping invited objects selectable alongside it. Resolve the selected id against the current readable results on each render, so a revoked object leaves both the picker and the content view. Use the selected object's id for child queries, new content, and invitations; `can` continues to gate each write.
|
|
@@ -1037,102 +1055,94 @@ export function boards(doc, oldDoc, user, ctx) {
|
|
|
1037
1055
|
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
|
|
1038
1056
|
return id;
|
|
1039
1057
|
};
|
|
1040
|
-
const myDefault = "default-" + user.userHandle;
|
|
1041
1058
|
const ch = (id) => "board:" + safeId(id);
|
|
1042
1059
|
|
|
1043
1060
|
if (doc.type === "board") {
|
|
1044
|
-
//
|
|
1061
|
+
// The person's own submit makes a board; its creator holds it and its admin channel.
|
|
1045
1062
|
if (oldDoc) {
|
|
1046
1063
|
if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
|
|
1047
1064
|
ctx.requireAccess(ch(doc._id) + "/admin"); // renaming is an admin act — updates only
|
|
1048
|
-
} else if (doc.creatorHandle !== user.userHandle
|
|
1065
|
+
} else if (doc.creatorHandle !== user.userHandle) {
|
|
1049
1066
|
throw { forbidden: "create your own board" };
|
|
1050
1067
|
}
|
|
1051
1068
|
return { channels: [ch(doc._id)], grant: { users: { [doc.creatorHandle]: [ch(doc._id), ch(doc._id) + "/admin"] } } };
|
|
1052
1069
|
}
|
|
1053
1070
|
|
|
1054
1071
|
if (doc.type === "card") {
|
|
1055
|
-
// The card STORES its board id from the moment it is created
|
|
1056
|
-
//
|
|
1057
|
-
// including the absent-to-present move, so a later editor's handle always finds the
|
|
1058
|
-
// id already on the doc rather than supplying one.
|
|
1072
|
+
// The card STORES its board id from the moment it is created and holds it fixed after —
|
|
1073
|
+
// including the absent-to-present move, so a later editor always finds the id already on the doc.
|
|
1059
1074
|
if (!oldDoc && (typeof doc.boardId !== "string" || doc.boardId.length === 0)) {
|
|
1060
1075
|
throw { forbidden: "a card names its board" };
|
|
1061
1076
|
}
|
|
1062
1077
|
if (oldDoc && doc.boardId !== oldDoc.boardId) throw { forbidden: "card stays on its board" };
|
|
1063
1078
|
const chan = ch(doc.boardId);
|
|
1064
|
-
|
|
1065
|
-
// The stable board record carries its creator's grant, independently of cards.
|
|
1079
|
+
ctx.requireAccess(chan); // any member edits any card on it; the creator holds it from the board record
|
|
1066
1080
|
return { channels: [chan] };
|
|
1067
1081
|
}
|
|
1068
1082
|
|
|
1069
1083
|
if (doc.type === "member") {
|
|
1070
1084
|
// The board's admin invites a person the picker resolved; the doc IS the grant.
|
|
1071
|
-
// Fixed once written — an identical re-put (a
|
|
1072
|
-
//
|
|
1085
|
+
// Fixed once written — an identical re-put (a second device replaying the same save) is
|
|
1086
|
+
// harmless and admitted; any change to who, where or by whom is refused.
|
|
1073
1087
|
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.boardId !== oldDoc.boardId || doc.addedBy !== oldDoc.addedBy))
|
|
1074
1088
|
throw { forbidden: "membership grants are fixed" };
|
|
1075
1089
|
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
1076
|
-
|
|
1090
|
+
ctx.requireAccess(ch(doc.boardId) + "/admin");
|
|
1077
1091
|
return { channels: [ch(doc.boardId)], grant: { users: { [doc.userHandle]: [ch(doc.boardId)] } } };
|
|
1078
1092
|
}
|
|
1079
1093
|
|
|
1080
1094
|
throw { forbidden: "unknown document type" };
|
|
1081
1095
|
}
|
|
1082
1096
|
|
|
1083
|
-
// Summary:
|
|
1097
|
+
// Summary: anyone signed in starts boards of their own, a board's creator lets a friend in with a member record, and members add and edit every card on it. A board carries its creator's handle in creatorHandle, a card names its board in boardId, and a member record names the friend in userHandle and its writer in addedBy.
|
|
1084
1098
|
```
|
|
1085
1099
|
|
|
1086
1100
|
App.jsx — the invite surface is `HandleInput` plus one `member` write, shown to whoever the access fn would accept:
|
|
1087
1101
|
|
|
1088
1102
|
```jsx
|
|
1089
1103
|
const { HandleInput } = useViewer();
|
|
1104
|
+
const { database } = useFireproof("boards");
|
|
1090
1105
|
const { me, can } = useVibe("boards");
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const ensureDefaultBoard = async (boardId) => {
|
|
1094
|
-
if (boardId !== `default-${me.userHandle}`) return;
|
|
1095
|
-
const { docs } = await database.query("_id", { key: boardId });
|
|
1096
|
-
if (docs.length === 0) {
|
|
1097
|
-
await database.put({ _id: boardId, type: "board", creatorHandle: me.userHandle });
|
|
1098
|
-
}
|
|
1099
|
-
};
|
|
1106
|
+
// One fixed id per (board, handle): the same person added twice is the same record.
|
|
1107
|
+
const memberFor = (boardId, handle) => ({ _id: `member:${boardId}:${handle}`, type: "member", boardId, userHandle: handle, addedBy: me?.userHandle });
|
|
1100
1108
|
const addMember = async (boardId, handle) => {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
await database.put(
|
|
1104
|
-
_id: `member:${boardId}:${handle}`,
|
|
1105
|
-
type: "member",
|
|
1106
|
-
boardId,
|
|
1107
|
-
userHandle: handle,
|
|
1108
|
-
addedBy: me.userHandle,
|
|
1109
|
-
});
|
|
1109
|
+
const member = memberFor(boardId, handle);
|
|
1110
|
+
if (!handle || !can.create(member).ok) return;
|
|
1111
|
+
await database.put(member); // the exact value the control checked
|
|
1110
1112
|
};
|
|
1111
|
-
// Pass the displayed board's id;
|
|
1112
|
-
const addCard = async (text, boardId
|
|
1113
|
-
|
|
1114
|
-
|
|
1113
|
+
// Pass the displayed board's id; the card keeps it for good.
|
|
1114
|
+
const addCard = async (text, boardId) => {
|
|
1115
|
+
const card = { type: "card", boardId, text };
|
|
1116
|
+
if (!can.create(card).ok) return;
|
|
1117
|
+
return database.put(card);
|
|
1115
1118
|
};
|
|
1116
1119
|
// …
|
|
1117
|
-
{
|
|
1120
|
+
{can.create(memberFor(board._id, "friend")).ok && <HandleInput onChange={(h) => h && addMember(board._id, h)} placeholder="Add a friend…" />}
|
|
1118
1121
|
```
|
|
1119
1122
|
|
|
1120
1123
|
An email typed into that `HandleInput` reaches `addMember` as an opaque `invite:<id>` subject, stored as `userHandle` like any handle, and `ViewerTag` shows it as invited until they arrive.
|
|
1121
1124
|
|
|
1122
|
-
App.jsx — the same board list serves the creator and the invited recipient. `BoardContents` uses `board._id` for its content queries, calls `addCard(text, board._id)` and `addMember(board._id, handle)`, and gates its write controls with `can`.
|
|
1125
|
+
App.jsx — the same board list serves the creator and the invited recipient. `BoardContents` uses `board._id` for its content queries, calls `addCard(text, board._id)` and `addMember(board._id, handle)`, and gates its write controls with `can`. The creation form stays reachable with an empty readable list, and it selects the person's new board after saving it.
|
|
1123
1126
|
|
|
1124
1127
|
### `App.jsx`
|
|
1125
1128
|
```jsx partial
|
|
1126
|
-
function BoardWorkspace(
|
|
1127
|
-
const { useLiveQuery } = useFireproof("boards");
|
|
1129
|
+
function BoardWorkspace() {
|
|
1130
|
+
const { database, useLiveQuery } = useFireproof("boards");
|
|
1128
1131
|
const { me, can, ready } = useVibe("boards");
|
|
1129
1132
|
const [pickedBoardId, setPickedBoardId] = React.useState();
|
|
1133
|
+
const [name, setName] = React.useState("");
|
|
1134
|
+
// The new board's id is made once per draft, so the gate and the put check one complete record.
|
|
1135
|
+
const [draftId, setDraftId] = React.useState(() => "board-" + crypto.randomUUID());
|
|
1130
1136
|
const { docs: boards } = useLiveQuery("type", { key: "board" });
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1137
|
+
// The person's own submit starts a board: the form checks and saves this exact record.
|
|
1138
|
+
const newBoard = { _id: draftId, type: "board", name: name.trim(), creatorHandle: me?.userHandle };
|
|
1139
|
+
const startBoard = async (event) => {
|
|
1140
|
+
event.preventDefault();
|
|
1141
|
+
if (!ready || !newBoard.name || !can.create(newBoard).ok) return;
|
|
1142
|
+
await database.put(newBoard);
|
|
1143
|
+
setName("");
|
|
1144
|
+
setPickedBoardId(newBoard._id);
|
|
1145
|
+
setDraftId("board-" + crypto.randomUUID());
|
|
1136
1146
|
};
|
|
1137
1147
|
const board =
|
|
1138
1148
|
boards.find((board) => board._id === pickedBoardId) ??
|
|
@@ -1140,30 +1150,100 @@ function BoardWorkspace({ ensureDefaultBoard }) {
|
|
|
1140
1150
|
boards[0];
|
|
1141
1151
|
return (
|
|
1142
1152
|
<section>
|
|
1143
|
-
{
|
|
1144
|
-
<
|
|
1153
|
+
{boards.length > 0 && (
|
|
1154
|
+
<Field label="Board">
|
|
1155
|
+
{(id, describedBy) => (
|
|
1156
|
+
<Select
|
|
1157
|
+
id={id}
|
|
1158
|
+
describedBy={describedBy}
|
|
1159
|
+
value={board?._id ?? ""}
|
|
1160
|
+
onChange={setPickedBoardId}
|
|
1161
|
+
options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
|
|
1162
|
+
/>
|
|
1163
|
+
)}
|
|
1164
|
+
</Field>
|
|
1145
1165
|
)}
|
|
1146
|
-
<Field label="Board">
|
|
1147
|
-
{(id, describedBy) => (
|
|
1148
|
-
<Select
|
|
1149
|
-
id={id}
|
|
1150
|
-
describedBy={describedBy}
|
|
1151
|
-
value={board?._id ?? ""}
|
|
1152
|
-
onChange={setPickedBoardId}
|
|
1153
|
-
disabled={boards.length === 0}
|
|
1154
|
-
options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
|
|
1155
|
-
/>
|
|
1156
|
-
)}
|
|
1157
|
-
</Field>
|
|
1158
1166
|
{board && <BoardContents board={board} />}
|
|
1167
|
+
<form onSubmit={startBoard}>
|
|
1168
|
+
<Field label="New board">
|
|
1169
|
+
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
|
|
1170
|
+
</Field>
|
|
1171
|
+
<Button type="submit" disabled={!ready || !newBoard.name || !can.create(newBoard).ok}>Start a board</Button>
|
|
1172
|
+
</form>
|
|
1159
1173
|
</section>
|
|
1160
1174
|
);
|
|
1161
1175
|
}
|
|
1162
1176
|
```
|
|
1163
1177
|
|
|
1178
|
+
#### A friend sees the items picked for them
|
|
1179
|
+
|
|
1180
|
+
Reach for this only when the person asks to choose what each friend sees — "when I add a friend, let me pick which plants they can see". Each plant lands on the person's tree and on its own channel, named by its keeper and its id, and the `member` doc carries the chosen ids and grants that friend each chosen item's channel: the friend sees those items and nothing else on the sill, and saving the member doc again with a different list changes what they see. Because an item's channel carries its keeper's handle, a member doc can only ever grant its writer's own items. The add-a-friend control is the same `HandleInput`, with the person's items as checkboxes beside it, and the cue beside it says what the friend will see ("Pick the plants this friend sees").
|
|
1181
|
+
|
|
1182
|
+
### `access.js`
|
|
1183
|
+
```js partial
|
|
1184
|
+
export function sill(doc, oldDoc, user, ctx) {
|
|
1185
|
+
if (!user?.userHandle) throw { forbidden: "sign in" };
|
|
1186
|
+
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
|
|
1187
|
+
const safeId = (id) => {
|
|
1188
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
|
|
1189
|
+
return id;
|
|
1190
|
+
};
|
|
1191
|
+
const mine = `user:${user.userHandle}`; // the person's own sill: the channel named by their handle
|
|
1192
|
+
const item = (id) => `plant:${user.userHandle}:${safeId(id)}`; // only ever this writer's own plants
|
|
1193
|
+
if (doc.type === "plant") {
|
|
1194
|
+
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
1195
|
+
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "a plant stays its writer's" };
|
|
1196
|
+
return { channels: [mine, item(doc._id)], grant: { users: { [user.userHandle]: [mine, item(doc._id)] } } };
|
|
1197
|
+
}
|
|
1198
|
+
if (doc.type === "member") {
|
|
1199
|
+
// The sill's keeper writes a membership; who it names and who wrote it stay fixed,
|
|
1200
|
+
// and the picked plants are the one thing a later save changes.
|
|
1201
|
+
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
1202
|
+
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.addedBy !== oldDoc.addedBy)) throw { forbidden: "who is fixed" };
|
|
1203
|
+
const picked = (doc.plantIds || []).map(item);
|
|
1204
|
+
// A first pick names at least one plant; a re-pick may clear them all, and the friend then sees none.
|
|
1205
|
+
if (!oldDoc && picked.length === 0) throw { forbidden: "pick a plant to share" };
|
|
1206
|
+
const grant = { users: { [user.userHandle]: [mine] } };
|
|
1207
|
+
if (picked.length > 0) grant.users[doc.userHandle] = picked;
|
|
1208
|
+
return { channels: [mine], grant };
|
|
1209
|
+
}
|
|
1210
|
+
throw { forbidden: "unknown document type" };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Summary: each person's plants are theirs alone, and a friend they add sees exactly the plants picked for them. A plant carries its writer's handle in authorHandle; a member record names the friend in userHandle, its writer in addedBy and the plants they see in plantIds.
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
### `App.jsx`
|
|
1217
|
+
```jsx partial
|
|
1218
|
+
const { HandleInput } = useViewer();
|
|
1219
|
+
const { database, useLiveQuery } = useFireproof("sill");
|
|
1220
|
+
const { me, can } = useVibe("sill");
|
|
1221
|
+
const [picked, setPicked] = React.useState([]);
|
|
1222
|
+
const addFriend = async (handle) => {
|
|
1223
|
+
const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, plantIds: picked, addedBy: me.userHandle };
|
|
1224
|
+
if (can.create(member).ok) await database.put(member);
|
|
1225
|
+
};
|
|
1226
|
+
// Changing what a friend sees is the same record, saved again with the new list.
|
|
1227
|
+
const repick = async (member, plantIds) => {
|
|
1228
|
+
const next = { ...member, plantIds };
|
|
1229
|
+
if (can.edit(next).ok) await database.put(next);
|
|
1230
|
+
};
|
|
1231
|
+
// …
|
|
1232
|
+
{plants.map((p) => (
|
|
1233
|
+
<Checkbox
|
|
1234
|
+
key={p._id}
|
|
1235
|
+
label={p.name}
|
|
1236
|
+
checked={picked.includes(p._id)}
|
|
1237
|
+
onChange={(checked) => setPicked(checked === true ? [...picked, p._id] : picked.filter((id) => id !== p._id))}
|
|
1238
|
+
/>
|
|
1239
|
+
))}
|
|
1240
|
+
<HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend who sees these…" />
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
|
|
1164
1244
|
#### The list holds what the person made and what they were let into
|
|
1165
1245
|
|
|
1166
|
-
**The app's list of objects reads them by their `type`, so it holds the ones the signed-in person made and the ones a membership lets them into, read through the same rules.** The `member` record grants its board's channel, so the friend's own `useLiveQuery("type", { key: "board" })` returns that board beside their own the moment the membership lands; `creatorHandle` then says who made each one. A shared board carries `ViewerTag` for the person who made it, in the same list or in a section that renders once there is at least one, so a person keeping the app to themselves sees one plain list. Opening a shared board is the same `BoardContents`: its cards are read by its id and written with that id, the rules route them to its channel, and `can` gates each write.
|
|
1246
|
+
**The app's list of objects reads them by their `type`, so it holds the ones the signed-in person made and the ones a membership lets them into, read through the same rules.** The `member` record grants its board's channel, so the friend's own `useLiveQuery("type", { key: "board" })` returns that board beside their own the moment the membership lands; `creatorHandle` then says who made each one. A friend let into someone's tree finds it the same way: read `member` records by type (`useLiveQuery("type", { key: "member" })`), and each readable one in someone else's tree names a tree they can open — dedupe by that tree's handle. Match on the type, never on the friend's own handle: a friend invited by email is stored as `invite:<id>`, and the grant reaches their handle once they arrive without rewriting the record. A shared board carries `ViewerTag` for the person who made it, in the same list or in a section that renders once there is at least one, so a person keeping the app to themselves sees one plain list. Opening a shared board is the same `BoardContents`: its cards are read by its id and written with that id, the rules route them to its channel, and `can` gates each write.
|
|
1167
1247
|
|
|
1168
1248
|
```jsx
|
|
1169
1249
|
const { ViewerTag } = useViewer();
|
|
@@ -1182,26 +1262,140 @@ const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHa
|
|
|
1182
1262
|
)}
|
|
1183
1263
|
```
|
|
1184
1264
|
|
|
1185
|
-
A new access function has no use for `user.isOwner` at all: a rule about a **person** keys on `user.userHandle` or the object's own creator field, and a rule about the app's **own automation** keys on `user.backend`.
|
|
1265
|
+
A new access function has no use for `user.isOwner` at all: a rule about a **person** keys on `user.userHandle` or the object's own creator field, a rule about **app-wide authority** keys on a role through `ctx.requireRole`, and a rule about the app's **own automation** keys on `user.backend`.
|
|
1266
|
+
|
|
1267
|
+
#### App-wide authority is a role the owner hands out
|
|
1268
|
+
|
|
1269
|
+
Some authority belongs to the whole app rather than to one person's records — who edits the menu, who posts announcements, who reviews submissions. That authority is a **role**. The owner holds the reserved `owner` role from the start, with no record needed; every other role is filled by a `roleGrant` record the owner writes, gated on `ctx.requireRole("owner")`, whose output names the member: `members: { [doc.role]: [doc.userHandle] }`. The seed carries the owner's own grant (`{ "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" }`, above the records it unlocks), so the demo opens with the owner able to edit, and the same control hands the role to a friend later. The records the role governs check `ctx.requireRole("menu-editor")`; the owner and the first visitor arrive the same way, and the rules never ask who the owner is.
|
|
1270
|
+
|
|
1271
|
+
Worked example — a cookie shop's order form: the owner adds flavors and friends place orders. The menu is app-wide, so a flavor needs the `menu-editor` role and reads for everyone. An order is a customer's own record, so it lives in their tree, and a `grant.roles` entry lets every menu editor read the orders placed with the shop.
|
|
1272
|
+
|
|
1273
|
+
### `access.js`
|
|
1274
|
+
```js partial
|
|
1275
|
+
export function shop(doc, oldDoc, user, ctx) {
|
|
1276
|
+
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
|
|
1277
|
+
|
|
1278
|
+
if (doc.type === "flavor") {
|
|
1279
|
+
// The menu is app-wide: a menu editor adds and changes flavors, and everyone reads them.
|
|
1280
|
+
ctx.requireRole("menu-editor");
|
|
1281
|
+
return { channels: ["menu"], grant: { public: ["menu"] } };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (!user?.userHandle) throw { forbidden: "sign in" };
|
|
1285
|
+
const me = user.userHandle;
|
|
1286
|
+
|
|
1287
|
+
if (doc.type === "roleGrant") {
|
|
1288
|
+
// App-wide authority: the owner holds the owner role from the start and hands out the rest.
|
|
1289
|
+
ctx.requireRole("owner");
|
|
1290
|
+
if (doc.role !== "menu-editor") throw { forbidden: "unknown role" };
|
|
1291
|
+
return { channels: ["admin:roles"], members: { [doc.role]: [doc.userHandle] }, grant: { roles: { owner: ["admin:roles"] } } };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (doc.type === "order") {
|
|
1295
|
+
// An order is its customer's own record, in their tree; every menu editor reads it.
|
|
1296
|
+
if (doc.customerHandle !== me) throw { forbidden: "order for yourself" };
|
|
1297
|
+
if (oldDoc && oldDoc.customerHandle !== me) throw { forbidden: "an order stays its customer's" };
|
|
1298
|
+
const tree = "user:" + me;
|
|
1299
|
+
return { channels: [tree], grant: { users: { [me]: [tree] }, roles: { "menu-editor": [tree] } } };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
throw { forbidden: "unknown document type" };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Summary: everyone reads the menu and only menu editors change it, the owner names the menu editors with a role grant, and each customer's orders are their own, read by the menu editors. An order carries its customer's handle in customerHandle; a role grant names its role in role and its member in userHandle.
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
### `seed.json`
|
|
1309
|
+
```json partial
|
|
1310
|
+
{
|
|
1311
|
+
"shop": [
|
|
1312
|
+
{ "key": "owner-edits-menu", "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" },
|
|
1313
|
+
{ "key": "snickerdoodle", "type": "flavor", "name": "Snickerdoodle", "price": 3 },
|
|
1314
|
+
{ "key": "brown-butter", "type": "flavor", "name": "Brown butter chocolate chip", "price": 4 }
|
|
1315
|
+
]
|
|
1316
|
+
}
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
App.jsx — every control checks `can.create` on the exact record it saves: the flavor form, each flavor's order button, and the add-a-menu-editor picker, which only the owner role passes.
|
|
1320
|
+
|
|
1321
|
+
### `App.jsx`
|
|
1322
|
+
```jsx partial
|
|
1323
|
+
function Shop() {
|
|
1324
|
+
const { database, useLiveQuery } = useFireproof("shop");
|
|
1325
|
+
const { me, can, ready } = useVibe("shop");
|
|
1326
|
+
const { HandleInput, ViewerTag, requestLogin } = useViewer();
|
|
1327
|
+
const [name, setName] = React.useState("");
|
|
1328
|
+
const { docs: flavors } = useLiveQuery("type", { key: "flavor" });
|
|
1329
|
+
// A customer reads their own orders; a menu editor reads every customer's.
|
|
1330
|
+
const { docs: orders } = useLiveQuery("type", { key: "order" });
|
|
1331
|
+
const flavor = { type: "flavor", name: name.trim() };
|
|
1332
|
+
const orderFor = (f) => ({ type: "order", flavorId: f._id, flavorName: f.name, customerHandle: me?.userHandle });
|
|
1333
|
+
const editorFor = (handle) => ({ _id: "role-menu-editor-" + handle, type: "roleGrant", role: "menu-editor", userHandle: handle });
|
|
1334
|
+
const addFlavor = async (event) => {
|
|
1335
|
+
event.preventDefault();
|
|
1336
|
+
if (!ready || !flavor.name || !can.create(flavor).ok) return;
|
|
1337
|
+
await database.put(flavor); // the exact value the control checked
|
|
1338
|
+
setName("");
|
|
1339
|
+
};
|
|
1340
|
+
const order = async (f) => {
|
|
1341
|
+
const next = orderFor(f);
|
|
1342
|
+
if (!ready || !can.create(next).ok) return;
|
|
1343
|
+
await database.put(next);
|
|
1344
|
+
};
|
|
1345
|
+
const addEditor = async (handle) => {
|
|
1346
|
+
const grant = editorFor(handle);
|
|
1347
|
+
if (!ready || !handle || !can.create(grant).ok) return;
|
|
1348
|
+
await database.put(grant);
|
|
1349
|
+
};
|
|
1350
|
+
if (!ready) return <p role="status">Opening the shop…</p>;
|
|
1351
|
+
return (
|
|
1352
|
+
<Stack gap={3}>
|
|
1353
|
+
{can.create(flavor).ok && (
|
|
1354
|
+
<form onSubmit={addFlavor}>
|
|
1355
|
+
<Field label="New flavor">
|
|
1356
|
+
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
|
|
1357
|
+
</Field>
|
|
1358
|
+
<Button type="submit" disabled={!flavor.name}>Add flavor</Button>
|
|
1359
|
+
</form>
|
|
1360
|
+
)}
|
|
1361
|
+
{flavors.map((f) => (
|
|
1362
|
+
<RowCard key={f._id}>
|
|
1363
|
+
<Text>{f.name}</Text>
|
|
1364
|
+
{can.create(orderFor(f)).ok && <Button type="button" size="sm" onClick={() => order(f)}>Order</Button>}
|
|
1365
|
+
</RowCard>
|
|
1366
|
+
))}
|
|
1367
|
+
{!me && <Button type="button" variant="quiet" onClick={() => requestLogin()}>Sign in to place an order</Button>}
|
|
1368
|
+
{orders.map((o) => (
|
|
1369
|
+
<RowCard key={o._id}>
|
|
1370
|
+
<Text>{o.flavorName}</Text>
|
|
1371
|
+
{o.customerHandle !== me?.userHandle && <ViewerTag userHandle={o.customerHandle} />}
|
|
1372
|
+
</RowCard>
|
|
1373
|
+
))}
|
|
1374
|
+
{can.create(editorFor("friend")).ok && (
|
|
1375
|
+
<HandleInput onChange={(h) => h && addEditor(h)} placeholder="Add a menu editor…" />
|
|
1376
|
+
)}
|
|
1377
|
+
</Stack>
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
```
|
|
1186
1381
|
|
|
1187
1382
|
#### A person the ask names is let in by a record
|
|
1188
1383
|
|
|
1189
|
-
When the instruction names somebody by their handle — "let my friend priya-k see and add to my seed-swap shelf", "an expedition notebook for me and theo" — **the build's part is the rule that admits a membership and the screen that writes one; the membership itself is a record.** It is the same `member` doc the rule admits and the invite surface above writes, saved for that handle
|
|
1384
|
+
When the instruction names somebody by their handle — "let my friend priya-k see and add to my seed-swap shelf", "an expedition notebook for me and theo" — **the build's part is the rule that admits a membership and the screen that writes one; the membership itself is a record.** It is the same `member` doc the rule admits and the invite surface above writes, saved for that handle in the asker's own tree — or on the second object the ask names — once the rules are live, by whoever is driving the build acting as the maker, read back, and only then spoken of as in. An app whose first build carried the rules and no invite screen still states this shape in full, in `access.js`'s member branch: the type and fields that branch admits are the record a later save writes, and an edit that adds the screen afterwards writes that same record.
|
|
1190
1385
|
|
|
1191
|
-
So the record's shape is a contract the files state plainly. The `_id` is fixed by the
|
|
1386
|
+
So the record's shape is a contract the files state plainly. The `_id` is fixed by the place and the handle — `` `member:${me.userHandle}:${userHandle}` `` in the person's tree, `` `member:${boardId}:${userHandle}` `` on a board — so a second attempt is the same record rather than a twin, and a read by that id answers "is this person already in?" on its own. The fields are the ones the rule reads: `type`, `userHandle`, `addedBy` set to the writer's own handle, and the object's id when the membership is on a second object. The `member` rule admits an identical re-put, so a replay lands quietly. The tree's channel stays its person's because their own writes grant it, and a second object keeps its creator in the collaboration through its own record, independently of every content and invitation record. One invite handler in `App.jsx` writes exactly that shape, which is what makes the app's own screen and any later save agree on one record.
|
|
1192
1387
|
|
|
1193
1388
|
```jsx
|
|
1194
1389
|
const { HandleInput } = useViewer();
|
|
1195
|
-
const { database } = useFireproof("
|
|
1196
|
-
const { me, can } = useVibe("
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
const member = { _id: `member:${boardId}:${handle}`, type: "member", boardId, userHandle: handle, addedBy: me.userHandle };
|
|
1390
|
+
const { database } = useFireproof("notes");
|
|
1391
|
+
const { me, can } = useVibe("notes");
|
|
1392
|
+
const addFriend = async (handle) => {
|
|
1393
|
+
// One fixed id per (tree, handle): the same person added twice is the same record.
|
|
1394
|
+
const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, addedBy: me.userHandle };
|
|
1201
1395
|
if (can.create(member).ok) await database.put(member);
|
|
1202
1396
|
};
|
|
1203
1397
|
// …
|
|
1204
|
-
<HandleInput onChange={(h) => h &&
|
|
1398
|
+
<HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend…" />;
|
|
1205
1399
|
```
|
|
1206
1400
|
|
|
1207
1401
|
**A handle is the thing the person typed as one — a platform handle, the same token `HandleInput` would resolve.** A plain first name ("share it with Priya") or an email address is a person the maker adds through the invite screen, which takes a handle or an email: the build has no way to know which account that name means, so the invite screen ships with this build and the narration says the maker can add that person there.
|
package/llms/board-games.md
CHANGED
|
@@ -215,7 +215,7 @@ export default function App() {
|
|
|
215
215
|
const pool = game?.squares ?? [];
|
|
216
216
|
const poolFull = pool.length >= N * N - 1;
|
|
217
217
|
|
|
218
|
-
const newGame = { _id: `
|
|
218
|
+
const newGame = { _id: `game-${handle}`, type: "game", hostHandle: handle, title: "Bingo", squares: [] };
|
|
219
219
|
const canStart = handle && can.create(newGame).ok && !games.some((g) => g._id === newGame._id);
|
|
220
220
|
const lines = (draft ?? pool.join("\n")).split("\n").map((s) => s.trim().slice(0, 40)).filter(Boolean);
|
|
221
221
|
const canEditPool = game && can.edit({ ...game, squares: lines }).ok;
|
package/llms/callai.md
CHANGED
|
@@ -96,7 +96,7 @@ function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export default function App() {
|
|
99
|
-
const { database, useLiveQuery
|
|
99
|
+
const { database, useLiveQuery } = useFireproof("tasks");
|
|
100
100
|
const { me, can, ready } = useVibe("tasks");
|
|
101
101
|
const { docs } = useLiveQuery("type", { key: "task" });
|
|
102
102
|
const [sentence, setSentence] = useState("");
|
|
@@ -182,7 +182,6 @@ export default function App() {
|
|
|
182
182
|
</form>
|
|
183
183
|
<Text>{answer}</Text>
|
|
184
184
|
<p role="status">{message}</p>
|
|
185
|
-
{writeRejection && <Row gap={2} align="center" wrap><p role="status">{writeRejection.message}</p><Button type="button" size="sm" variant="quiet" onClick={clearWriteRejection}>Dismiss</Button></Row>}
|
|
186
185
|
<Row gap={2} wrap>
|
|
187
186
|
{lastWrite !== undefined && <Button type="button" size="sm" variant="quiet" disabled={busy} onClick={undoLast}>Undo</Button>}
|
|
188
187
|
{pending !== undefined && !busy && <Button type="button" size="sm" variant="quiet" tone="down" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</Button>}
|
package/llms/connections.md
CHANGED
|
@@ -161,7 +161,7 @@ rather than land. Build on text and images; do not work around it with a raw
|
|
|
161
161
|
`ctx.connections` call, which hits the same race.
|
|
162
162
|
|
|
163
163
|
Posting on someone's behalf is a real act, so **let the person press the
|
|
164
|
-
button** — post from a `fetch` route the app calls on
|
|
164
|
+
button** — post from a `fetch` route the app calls on the person's own action, and
|
|
165
165
|
never from a `scheduled` tick that posts for a visitor who is not there. A post
|
|
166
166
|
that fails is worth telling them about, so surface the thrown error rather than
|
|
167
167
|
swallowing it.
|