@vibes.diy/prompts 14.3.120 → 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 +206 -108
- package/llms/board-games.md +1 -1
- package/llms/connections.md +1 -1
- package/llms/fireproof.md +66 -84
- package/package.json +5 -5
- package/system-prompt-initial-oneshot.md +180 -64
- package/system-prompt-initial.md +180 -64
- package/system-prompt.md +9 -8
package/llms/access.md
CHANGED
|
@@ -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 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)
|
|
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
|
|
@@ -527,7 +529,7 @@ export function notes(doc, oldDoc, user, ctx) {
|
|
|
527
529
|
// records rewrites or removes none of them.
|
|
528
530
|
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
529
531
|
if (oldDoc && (oldDoc.addedBy !== user.userHandle || doc.userHandle !== oldDoc.userHandle)) throw { forbidden: "a membership is its writer's" };
|
|
530
|
-
return { channels: [ch], grant: { users: { [doc.userHandle]: [ch] } } };
|
|
532
|
+
return { channels: [ch], grant: { users: { [user.userHandle]: [ch], [doc.userHandle]: [ch] } } };
|
|
531
533
|
}
|
|
532
534
|
|
|
533
535
|
throw { forbidden: "unknown document type" };
|
|
@@ -536,13 +538,13 @@ export function notes(doc, oldDoc, user, ctx) {
|
|
|
536
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.
|
|
537
539
|
```
|
|
538
540
|
|
|
539
|
-
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}`] } }`)
|
|
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.
|
|
540
542
|
|
|
541
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.
|
|
542
544
|
|
|
543
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:
|
|
544
546
|
|
|
545
|
-
- **A collection, shared by adding a friend.** Everything the person keeps routes to
|
|
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.
|
|
546
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.
|
|
547
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.
|
|
548
550
|
|
|
@@ -629,7 +631,7 @@ export function pieces(doc, oldDoc, user, ctx) {
|
|
|
629
631
|
|
|
630
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.
|
|
631
633
|
|
|
632
|
-
**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`.
|
|
633
635
|
|
|
634
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`.
|
|
635
637
|
|
|
@@ -949,7 +951,7 @@ Channel `_id` is the channel identifier everywhere. The access function uses `do
|
|
|
949
951
|
|
|
950
952
|
### Example: Per-object sharing (collaborate on your own objects, no admin)
|
|
951
953
|
|
|
952
|
-
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.
|
|
953
955
|
|
|
954
956
|
### `access.js`
|
|
955
957
|
```js partial
|
|
@@ -1035,11 +1037,11 @@ App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `u
|
|
|
1035
1037
|
>>>>>>> REPLACE
|
|
1036
1038
|
```
|
|
1037
1039
|
|
|
1038
|
-
####
|
|
1040
|
+
#### A second or shared object, and a member doc that names the friend
|
|
1039
1041
|
|
|
1040
|
-
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:
|
|
1041
1043
|
|
|
1042
|
-
- **
|
|
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.
|
|
1043
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] } } ``.
|
|
1044
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`.
|
|
1045
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.
|
|
@@ -1053,102 +1055,94 @@ export function boards(doc, oldDoc, user, ctx) {
|
|
|
1053
1055
|
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
|
|
1054
1056
|
return id;
|
|
1055
1057
|
};
|
|
1056
|
-
const myDefault = "default-" + user.userHandle;
|
|
1057
1058
|
const ch = (id) => "board:" + safeId(id);
|
|
1058
1059
|
|
|
1059
1060
|
if (doc.type === "board") {
|
|
1060
|
-
//
|
|
1061
|
+
// The person's own submit makes a board; its creator holds it and its admin channel.
|
|
1061
1062
|
if (oldDoc) {
|
|
1062
1063
|
if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
|
|
1063
1064
|
ctx.requireAccess(ch(doc._id) + "/admin"); // renaming is an admin act — updates only
|
|
1064
|
-
} else if (doc.creatorHandle !== user.userHandle
|
|
1065
|
+
} else if (doc.creatorHandle !== user.userHandle) {
|
|
1065
1066
|
throw { forbidden: "create your own board" };
|
|
1066
1067
|
}
|
|
1067
1068
|
return { channels: [ch(doc._id)], grant: { users: { [doc.creatorHandle]: [ch(doc._id), ch(doc._id) + "/admin"] } } };
|
|
1068
1069
|
}
|
|
1069
1070
|
|
|
1070
1071
|
if (doc.type === "card") {
|
|
1071
|
-
// The card STORES its board id from the moment it is created
|
|
1072
|
-
//
|
|
1073
|
-
// including the absent-to-present move, so a later editor's handle always finds the
|
|
1074
|
-
// 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.
|
|
1075
1074
|
if (!oldDoc && (typeof doc.boardId !== "string" || doc.boardId.length === 0)) {
|
|
1076
1075
|
throw { forbidden: "a card names its board" };
|
|
1077
1076
|
}
|
|
1078
1077
|
if (oldDoc && doc.boardId !== oldDoc.boardId) throw { forbidden: "card stays on its board" };
|
|
1079
1078
|
const chan = ch(doc.boardId);
|
|
1080
|
-
|
|
1081
|
-
// 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
|
|
1082
1080
|
return { channels: [chan] };
|
|
1083
1081
|
}
|
|
1084
1082
|
|
|
1085
1083
|
if (doc.type === "member") {
|
|
1086
1084
|
// The board's admin invites a person the picker resolved; the doc IS the grant.
|
|
1087
|
-
// Fixed once written — an identical re-put (a
|
|
1088
|
-
//
|
|
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.
|
|
1089
1087
|
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.boardId !== oldDoc.boardId || doc.addedBy !== oldDoc.addedBy))
|
|
1090
1088
|
throw { forbidden: "membership grants are fixed" };
|
|
1091
1089
|
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
1092
|
-
|
|
1090
|
+
ctx.requireAccess(ch(doc.boardId) + "/admin");
|
|
1093
1091
|
return { channels: [ch(doc.boardId)], grant: { users: { [doc.userHandle]: [ch(doc.boardId)] } } };
|
|
1094
1092
|
}
|
|
1095
1093
|
|
|
1096
1094
|
throw { forbidden: "unknown document type" };
|
|
1097
1095
|
}
|
|
1098
1096
|
|
|
1099
|
-
// 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.
|
|
1100
1098
|
```
|
|
1101
1099
|
|
|
1102
1100
|
App.jsx — the invite surface is `HandleInput` plus one `member` write, shown to whoever the access fn would accept:
|
|
1103
1101
|
|
|
1104
1102
|
```jsx
|
|
1105
1103
|
const { HandleInput } = useViewer();
|
|
1104
|
+
const { database } = useFireproof("boards");
|
|
1106
1105
|
const { me, can } = useVibe("boards");
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const ensureDefaultBoard = async (boardId) => {
|
|
1110
|
-
if (boardId !== `default-${me.userHandle}`) return;
|
|
1111
|
-
const { docs } = await database.query("_id", { key: boardId });
|
|
1112
|
-
if (docs.length === 0) {
|
|
1113
|
-
await database.put({ _id: boardId, type: "board", creatorHandle: me.userHandle });
|
|
1114
|
-
}
|
|
1115
|
-
};
|
|
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 });
|
|
1116
1108
|
const addMember = async (boardId, handle) => {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
await database.put(
|
|
1120
|
-
_id: `member:${boardId}:${handle}`,
|
|
1121
|
-
type: "member",
|
|
1122
|
-
boardId,
|
|
1123
|
-
userHandle: handle,
|
|
1124
|
-
addedBy: me.userHandle,
|
|
1125
|
-
});
|
|
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
|
|
1126
1112
|
};
|
|
1127
|
-
// Pass the displayed board's id;
|
|
1128
|
-
const addCard = async (text, boardId
|
|
1129
|
-
|
|
1130
|
-
|
|
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);
|
|
1131
1118
|
};
|
|
1132
1119
|
// …
|
|
1133
|
-
{
|
|
1120
|
+
{can.create(memberFor(board._id, "friend")).ok && <HandleInput onChange={(h) => h && addMember(board._id, h)} placeholder="Add a friend…" />}
|
|
1134
1121
|
```
|
|
1135
1122
|
|
|
1136
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.
|
|
1137
1124
|
|
|
1138
|
-
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.
|
|
1139
1126
|
|
|
1140
1127
|
### `App.jsx`
|
|
1141
1128
|
```jsx partial
|
|
1142
|
-
function BoardWorkspace(
|
|
1143
|
-
const { useLiveQuery } = useFireproof("boards");
|
|
1129
|
+
function BoardWorkspace() {
|
|
1130
|
+
const { database, useLiveQuery } = useFireproof("boards");
|
|
1144
1131
|
const { me, can, ready } = useVibe("boards");
|
|
1145
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());
|
|
1146
1136
|
const { docs: boards } = useLiveQuery("type", { key: "board" });
|
|
1147
|
-
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
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());
|
|
1152
1146
|
};
|
|
1153
1147
|
const board =
|
|
1154
1148
|
boards.find((board) => board._id === pickedBoardId) ??
|
|
@@ -1156,22 +1150,26 @@ function BoardWorkspace({ ensureDefaultBoard }) {
|
|
|
1156
1150
|
boards[0];
|
|
1157
1151
|
return (
|
|
1158
1152
|
<section>
|
|
1159
|
-
{
|
|
1160
|
-
<
|
|
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>
|
|
1161
1165
|
)}
|
|
1162
|
-
<Field label="Board">
|
|
1163
|
-
{(id, describedBy) => (
|
|
1164
|
-
<Select
|
|
1165
|
-
id={id}
|
|
1166
|
-
describedBy={describedBy}
|
|
1167
|
-
value={board?._id ?? ""}
|
|
1168
|
-
onChange={setPickedBoardId}
|
|
1169
|
-
disabled={boards.length === 0}
|
|
1170
|
-
options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
|
|
1171
|
-
/>
|
|
1172
|
-
)}
|
|
1173
|
-
</Field>
|
|
1174
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>
|
|
1175
1173
|
</section>
|
|
1176
1174
|
);
|
|
1177
1175
|
}
|
|
@@ -1179,7 +1177,7 @@ function BoardWorkspace({ ensureDefaultBoard }) {
|
|
|
1179
1177
|
|
|
1180
1178
|
#### A friend sees the items picked for them
|
|
1181
1179
|
|
|
1182
|
-
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
|
|
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").
|
|
1183
1181
|
|
|
1184
1182
|
### `access.js`
|
|
1185
1183
|
```js partial
|
|
@@ -1190,42 +1188,29 @@ export function sill(doc, oldDoc, user, ctx) {
|
|
|
1190
1188
|
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
|
|
1191
1189
|
return id;
|
|
1192
1190
|
};
|
|
1193
|
-
const
|
|
1194
|
-
const
|
|
1195
|
-
const chan = `sill:${sillId}`;
|
|
1196
|
-
const mine = sillId === myDefault; // the person's own sill, saved or not yet saved
|
|
1197
|
-
if (doc.type === "sill") {
|
|
1198
|
-
// The creator holds the sill and its admin channel, and a saved sill keeps its creator:
|
|
1199
|
-
// a rewrite is an admin act, and only the person whose handle the default id names starts it.
|
|
1200
|
-
if (oldDoc) {
|
|
1201
|
-
if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
|
|
1202
|
-
ctx.requireAccess(`${chan}/admin`);
|
|
1203
|
-
} else if (doc.creatorHandle !== user.userHandle || (doc._id.startsWith("default-") && doc._id !== myDefault)) {
|
|
1204
|
-
throw { forbidden: "start your own sill" };
|
|
1205
|
-
}
|
|
1206
|
-
return { channels: [chan], grant: { users: { [doc.creatorHandle]: [chan, `${chan}/admin`] } } };
|
|
1207
|
-
}
|
|
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
|
|
1208
1193
|
if (doc.type === "plant") {
|
|
1209
1194
|
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
1210
|
-
if (oldDoc &&
|
|
1211
|
-
|
|
1212
|
-
return { channels: [chan, `plant:${safeId(doc._id)}`] }; // on the sill, and on its own channel a member doc can grant
|
|
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)] } } };
|
|
1213
1197
|
}
|
|
1214
1198
|
if (doc.type === "member") {
|
|
1215
|
-
// The sill's keeper writes a membership; who it names
|
|
1216
|
-
//
|
|
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.
|
|
1217
1201
|
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
|
|
1218
|
-
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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 };
|
|
1224
1209
|
}
|
|
1225
1210
|
throw { forbidden: "unknown document type" };
|
|
1226
1211
|
}
|
|
1227
1212
|
|
|
1228
|
-
// 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
|
|
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.
|
|
1229
1214
|
```
|
|
1230
1215
|
|
|
1231
1216
|
### `App.jsx`
|
|
@@ -1235,8 +1220,7 @@ const { database, useLiveQuery } = useFireproof("sill");
|
|
|
1235
1220
|
const { me, can } = useVibe("sill");
|
|
1236
1221
|
const [picked, setPicked] = React.useState([]);
|
|
1237
1222
|
const addFriend = async (handle) => {
|
|
1238
|
-
|
|
1239
|
-
const member = { _id: `member:${sill._id}:${handle}`, type: "member", sillId: sill._id, userHandle: handle, plantIds: picked, addedBy: me.userHandle };
|
|
1223
|
+
const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, plantIds: picked, addedBy: me.userHandle };
|
|
1240
1224
|
if (can.create(member).ok) await database.put(member);
|
|
1241
1225
|
};
|
|
1242
1226
|
// Changing what a friend sees is the same record, saved again with the new list.
|
|
@@ -1259,7 +1243,7 @@ const repick = async (member, plantIds) => {
|
|
|
1259
1243
|
|
|
1260
1244
|
#### The list holds what the person made and what they were let into
|
|
1261
1245
|
|
|
1262
|
-
**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.
|
|
1263
1247
|
|
|
1264
1248
|
```jsx
|
|
1265
1249
|
const { ViewerTag } = useViewer();
|
|
@@ -1278,26 +1262,140 @@ const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHa
|
|
|
1278
1262
|
)}
|
|
1279
1263
|
```
|
|
1280
1264
|
|
|
1281
|
-
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
|
+
```
|
|
1282
1381
|
|
|
1283
1382
|
#### A person the ask names is let in by a record
|
|
1284
1383
|
|
|
1285
|
-
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.
|
|
1286
1385
|
|
|
1287
|
-
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.
|
|
1288
1387
|
|
|
1289
1388
|
```jsx
|
|
1290
1389
|
const { HandleInput } = useViewer();
|
|
1291
|
-
const { database } = useFireproof("
|
|
1292
|
-
const { me, can } = useVibe("
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
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 };
|
|
1297
1395
|
if (can.create(member).ok) await database.put(member);
|
|
1298
1396
|
};
|
|
1299
1397
|
// …
|
|
1300
|
-
<HandleInput onChange={(h) => h &&
|
|
1398
|
+
<HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend…" />;
|
|
1301
1399
|
```
|
|
1302
1400
|
|
|
1303
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/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.
|