Skip to content

feat(rooms): the local room invite's plumbing and one shared preview card (#885) - #887

Merged
Birfy merged 3 commits into
mainfrom
feat/876-local-room-invite-card
Oct 23, 2026
Merged

Birfy merged 3 commits into
mainfrom
feat/876-local-room-invite-card

Conversation

@Birfy

@Birfy Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Dev3
Tip SHA: 4fafe2c
Fixes #885
Refs #876

The plumbing behind a local room's own invite link, and the one preview card both surfaces draw. This is the half that was already green: it does not close #876 — the Members card's Invite by link row and the redeem dialog stay there.

Reproduce

On 690093bc there is no way to ask a room for its link from the client:

  • grep -rn "room-invite" core/src/host/channel.js answers nothing: the host has mintRoomInvite / localInviteOf / revokeRoomInvite / the ask path, and no endpoint reaches any of them.
  • There is no way for the invitee's side to knock from the client either: rt.askLocalInvite does not exist, so pasting a /r/@handle/<ticket> link into the join box can only be refused in one sentence (which is what feat(rooms): answer a local room's invite link (#852) #864 shipped) and never becomes a request.
  • The preview card is markup inside plugins/social/client.js: grep -c "face-link-preview-name" plugins/social/client.js answers 1 on that base, so the second surface that needs a card (the member-invite dialog) would be a copy of it.

What changed

  • Host endpoints (core/src/host/channel.js): room-invite returns the room's live ticket through rt.localInviteOf (what the card copies and counts down), room-invite-mint and room-invite-revoke wrap the existing mint/withdraw, and room-invite-ask knocks at a link someone else handed over through the new rt.askLocalInvite.
  • rt.askLocalInvite (core/src/host/room-membership.js): reads the handle and ticket out of the link with parseRoomInviteLink and sends { op: 'ask', ticket } — and the same knock with accept: true for Join — from one of the actor's own Bots whose home has a Bot Social identity. The link cannot name a sender, so the transport does, exactly as on the answering side. A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; no knock ever carries a roomId.
  • Authorization (core/src/host/authz.js): room-invite is a member's read; room-invite-mint / room-invite-revoke need the room owner or a human admin; room-invite-ask needs a signed-in actor and no authority over anything here, because the ticket is the inviter's record's business.
  • One preview component (core/src/client/room-invite-preview.js): invitePreviewOf says only what a server reply can honestly say (a name cleaned at fixed point, the lead, n / m in the room, the deadline), inviteCountdown words the deadline, and RoomInvitePreview draws the tree from a caller-supplied h. Registered on __flybot__ in flybot-slots.js with invitePreviewOf / inviteCountdown, because a classic-script plugin cannot import a core module. kind renames only the data-flybot hooks, so the face-room paste flow (face-link-preview) and the member-invite dialog (room-invite-preview) draw one tree.
  • plugins/social/client.js loses its own copy of the markup: the fs-preview* inline tree is gone and the face-room dialog renders __flybot__'s component; only the fs-preview* CSS stays.
  • Docs: docs/subsystems/core-host.md (the endpoints), core-client.md and plugin-runtime.md (the shared card), social.md (the card has one owner). changelog.d/876-room-invite-endpoints.added.md.

What I ran

All on 4fafe2c5 (single commit, 27 files, base 690093bc):

  • Core full suite 1314/1314, 0 fail (node --import ./test/harness-resolve.mjs --test --test-concurrency=1 "test/*.test.js"; serial because this machine's pthread limit aborts the parallel run — not a code failure).
  • core/test/remote-room.test.js + authz.test.js + room-invite-preview.test.js → 79/79.
  • plugins/social full suite 386/386, including the hold_reason assertions Decide a hold on Bot Social's hold_reason, not a guess from reasons (#865) #869 merged and the new "no second face-link-preview-* markup in client.js" case.
  • node core/scripts/build-client.mjs --check → ok; node scripts/ds-bot-patches.mjs check → 10 patches apply cleanly, core/ equals ds-bot/ + patches; core lint 0 errors; changelog 31 fragments; arch-docs ok.

Red without the source change

removed red
the accept: true the Join knock carries (room-membership.js) #876: the invitee knocks on the link, and Join is the same knock with accept — only that one (53/54)
the room-invite-mint / room-invite-revoke authorization rule authz: owner cannot touch another owner Bot or room — only that one (19/20)

Per-file diff against 690093bc, checked by file sha rather than by eye: plugins/social/test/social.test.js +19/−0, docs/subsystems/social.md +1/−1 (one sentence of mine replaced), and the only −1 lines in core/ are the import line and the ROOM_TARGETED set line. An earlier tip of this branch carried +34/−84 on social.test.js — the hold_reason cases #869 had just merged — and this branch is the rebuild that removed that rollback.

Not run (and why)

Risk

  • room-invite-ask sends from the actor's own Bot, so it needs at least one Bot with a Bot Social identity on this home; without one it refuses with This machine has no Bot Social identity yet rather than sending anonymously.
  • The ticket stays a bearer secret in the room record for ten minutes, as in feat(rooms): mint and withdraw a local room invite link (#852) #861.
  • core/client.js is generated: the +84 there is npm run build, and build-client --check is what keeps it honest.
  • RoomInvitePreview is registered with ??=, so a plugin that loaded first and registered its own stays; nothing in this repo does.

…g calls (#876)

The Members card and the join dialog need the link to be something the host answers
for, not just the envelope layer. `room-invite` reads the live link, `room-invite-mint`
and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and
`room-invite-ask` knocks at a link someone else handed you: `rt.askLocalInvite` sends
`{ op: 'ask', ticket }` and the same knock with `accept: true` on Join, from one of the
actor's own Bots whose home has a Bot Social identity — the link cannot name a sender,
so the transport does, as on the answering side. A face-room link, a bare ticket and a
ticket outside the 16–64 shape are refused in one sentence and send nothing, and a
knock carries no `roomId`, so an invitee can never name a room it has not seen.

The invite preview card moves into core as one component: `invitePreviewOf` says only
what a server reply can honestly say (a name cleaned at fixed point, the lead, the seat
count, the deadline), `inviteCountdown` words the deadline, and `RoomInvitePreview`
draws the tree from a caller-supplied `h`. Both surfaces render it — the face-room paste
flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) — through
`__flybot__`, because a classic-script plugin cannot import a core module; `kind` renames
only the `data-flybot` hooks, so the two surfaces draw the same tree instead of two
copies of the markup.

Docs: core-host (the endpoints), core-client and plugin-runtime (the shared card), social
(the card's one owner). changelog.d/876.
@Birfy

Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 4fafe2c
Written-by: Reviewer

Fresh review of the whole diff against 690093bc (27 files), read cold in a detached worktree of the tip; the implementer's numbers were not used.

Ran:

Findings: no blocking findings.

  • Authorization (core/src/host/authz.js): room-invite is a member/owner/admin read; room-invite-mint / room-invite-revoke need the room owner or a human admin, which is the same rule as delete-room. room-invite-ask is open to any signed-in actor; the knock carries only the ticket and the inviter's record decides, so it is not authority here.
  • rt.askLocalInvite refuses a face link, a bare ticket and a malformed link in one sentence before sending, and never sends a roomId; the accepting knock adds only accept: true. Matches feat(rooms): answer a local room's invite link (#852) #864's contract.
  • One preview tree: plugins/social/client.js keeps no face-link-preview-* markup (the new test is red at base, see above), and the face paste flow renders __flybot__.RoomInvitePreview with the same hooks it had.

Non-blocking notes (follow-ups, not defects in this tip):

  1. localSpeaker falls back to any Bot on the machine with a Bot Social handle when the actor owns none, so in a multi-person home the knock could go out from another person's Bot. Single-owner homes (all that ships today) are unaffected; worth tightening when the redeem dialog lands.
  2. RoomInvitePreview falls back to "Face-to-face room" when a reply has no name; a local-room card (kind: 'room-invite-preview') should say something else. Only visible once A local room's invite link has no Members-card row and no redemption dialog, and the preview card would be copied a second time #876's dialog draws it.

This report is a Verifier result, not merge authority on its own; the merge follows AGENTS.md step 6.

@github-actions github-actions Bot added the pass label Oct 23, 2026
@Birfy
Birfy enabled auto-merge October 23, 2026 14:53
@Birfy

Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at c2746e1
Written-by: Reviewer

Re-review after main moved: #880 (b6ab4b2) conflicted with this branch in the generated patches/ds-bot/0008-client-bundle.patch only, so the queue could not take the earlier PASS head (4fafe2c). Main was merged in (no force-push; Dev3's commits are untouched), the bundle and patches were regenerated, and 0001/0003 keep main's generated text. Read cold: git diff 4fafe2c c2746e1 is exactly #880's change plus regenerated patch files; git diff --name-only b6ab4b2 c2746e1 lists the same 27 files the earlier PASS reviewed against 690093b.

Ran:

  • TZ=UTC npm run verify (repo root, Node 24.21.0) at c2746e1 → pass (ds-bot-patches check "10 patches apply cleanly", core 1316/1316, plugins/social 386/386, tree 125/125, timeline 125/125, .github scripts 332/332, interop 37/37, live-e2e 16/16)
  • node scripts/ds-bot-patches.mjs check → ok, core/ equals ds-bot/ + patches/ds-bot/
  • git diff --name-only 690093b 4fafe2c vs b6ab4b2 c2746e1 → identical file list

Findings: no blocking findings. The earlier report's two non-blocking notes still stand (they are picked up in the #884/#876 follow-up).

This report is a Verifier result, not merge authority on its own; the merge follows AGENTS.md step 6.

@Birfy
Birfy added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit d0cafd5 Oct 23, 2026
10 of 11 checks passed
@Birfy
Birfy deleted the feat/876-local-room-invite-card branch October 23, 2026 15:08
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 23, 2026
Birfy added a commit that referenced this pull request Oct 24, 2026
A pull request built on an old copy of main re-uploads whole files, and the work
main merged after the branch was cut disappears while every check stays green:
the deletion is part of the pull request. #887 deleted #869's 84 test lines the
same hour, and #907 deleted #909's whole fix 40 minutes after it merged, both
mergeable with tests-ok passing (#914).

The rule is read off git. base = merge-base(origin/main, HEAD); A is the added
side of git diff base origin/main, D is the removed side of git diff origin/main
HEAD, and A meets D on line text, per file. Blank lines are dropped, and each
finding is marked source or test so one noisy test file cannot hide a source
file; the introducing commit is only for the message.

It runs as a step of merge-gate.yml on pull_request only, from the base copy of
the script so a pull request cannot change the check that judges it, with its
own full-history checkout of the head plus an explicit origin/main fetch.
Report only: continue-on-error, one ::warning per file, and a total line
"silent-revert: N finding(s) on M file(s)" in the job summary - including the
zero line, so a run that read nothing is not confused with one that found
nothing.

Tests: .github/scripts/silent-revert.test.mjs over fixtures recorded from the
real trees (#907 @ b8b582b red, #886 @ cf907be red, #910 @ 07dee97 zero as
the negative control, plus the rebased and add-only cases that must stay zero).
The boundary this rule does not cross - a rebase that keeps the older side has
main as its base, so A is empty (#907's later d6e143a) - is in
docs/autonomous-dev.md.
Birfy added a commit that referenced this pull request Oct 24, 2026
A pull request built on an old copy of main re-uploads whole files, and the work
main merged after the branch was cut disappears while every check stays green:
the deletion is part of the pull request. #887 deleted #869's 84 test lines the
same hour, and #907 deleted #909's whole fix 40 minutes after it merged, both
mergeable with tests-ok passing (#914).

The rule is read off git. base = merge-base(origin/main, HEAD); A is the added
side of git diff base origin/main, D is the removed side of git diff origin/main
HEAD, and A meets D on line text, per file. Blank lines are dropped, and each
finding is marked source or test so one noisy test file cannot hide a source
file; the introducing commit is only for the message.

It runs as a step of merge-gate.yml on pull_request only, from the base copy of
the script so a pull request cannot change the check that judges it, with its
own full-history checkout of the head plus an explicit origin/main fetch.
Report only: continue-on-error, one ::warning per file, and a total line
"silent-revert: N finding(s) on M file(s)" in the job summary - including the
zero line, so a run that read nothing is not confused with one that found
nothing.

Tests: .github/scripts/silent-revert.test.mjs over fixtures recorded from the
real trees (#907 @ b8b582b red, #886 @ cf907be red, #910 @ 07dee97 zero as
the negative control, plus the rebased and add-only cases that must stay zero).
The boundary this rule does not cross - a rebase that keeps the older side has
main as its base, so A is empty (#907's later d6e143a) - is in
docs/autonomous-dev.md.
Birfy added a commit that referenced this pull request Oct 24, 2026
A pull request built on an old copy of main re-uploads whole files, and the work
main merged after the branch was cut disappears while every check stays green:
the deletion is part of the pull request. #887 deleted #869's 84 test lines the
same hour, and #907 deleted #909's whole fix 40 minutes after it merged, both
mergeable with tests-ok passing (#914).

The rule is read off git. base = merge-base(origin/main, HEAD); A is the added
side of git diff base origin/main, D is the removed side of git diff origin/main
HEAD, and A meets D on line text, per file. Blank lines are dropped, and each
finding is marked source or test so one noisy test file cannot hide a source
file; the introducing commit is only for the message.

It runs as a step of merge-gate.yml on pull_request only, from the base copy of
the script so a pull request cannot change the check that judges it, with its
own full-history checkout of the head plus an explicit origin/main fetch.
Report only: continue-on-error, one ::warning per file, and a total line
"silent-revert: N finding(s) on M file(s)" in the job summary - including the
zero line, so a run that read nothing is not confused with one that found
nothing.

Tests: .github/scripts/silent-revert.test.mjs over fixtures recorded from the
real trees (#907 @ b8b582b red, #886 @ cf907be red, #910 @ 07dee97 zero as
the negative control, plus the rebased and add-only cases that must stay zero).
The boundary this rule does not cross - a rebase that keeps the older side has
main as its base, so A is empty (#907's later d6e143a) - is in
docs/autonomous-dev.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant