Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2447-room-share-braces.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed a room-share envelope being dropped when a chat line's own text carried one unbalanced brace, which lost the sent line and the join history copied into a `join` envelope ([#2447](https://github.com/ThinkFlowLab/FlyBot/issues/2447)).
14 changes: 13 additions & 1 deletion core/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,21 @@ export function parseRoomShare(text) {
if (start < 0) return undefined
let depth = 0
let end = -1
// #2447: a brace inside a string literal is a message's own text, not structure. `encodeRoomShare`
// writes `text` with `JSON.stringify`, which leaves braces raw, so counting them dropped the whole
// envelope over one `{` in a chat line — the same scan the client's `envelopeTail` does (#855).
let inString = false
let escaped = false
for (let i = start; i < raw.length; i += 1) {
const ch = raw[i]
if (ch === '{') depth += 1
if (inString) {
if (escaped) escaped = false
else if (ch === '\\') escaped = true
else if (ch === '"') inString = false
continue
}
if (ch === '"') inString = true
else if (ch === '{') depth += 1
else if (ch === '}') {
depth -= 1
if (depth === 0) { end = i; break }
Expand Down
57 changes: 57 additions & 0 deletions core/test/room-share-braces.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// #2447: the brace scan that finds the end of a room-share envelope has to know when a brace is
// part of a chat line's own text. One unbalanced `{` or `}` in a message used to end the scan in
// the wrong place, JSON.parse then failed, and the whole envelope was dropped — a sent line never
// arrived, and one brace in a copied line killed the join that carried it.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js'

const line = (text, extra = {}) => ({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text, ...extra })

test('a line whose text opens a brace is read back, not dropped', () => {
const parsed = parseRoomShare(encodeRoomShare(line('if (x) { y()')))
assert.equal(parsed?.op, 'line')
assert.equal(parsed?.roomId, 'r1')
assert.equal(parsed?.who, 'Alice')
assert.equal(parsed?.text, 'if (x) { y()')
})

test('a line whose text closes a brace is read back, not dropped', () => {
const text = 'so the closing brace is }'
const parsed = parseRoomShare(encodeRoomShare(line(text)))
assert.equal(parsed?.op, 'line')
assert.equal(parsed?.text, text)
})

test('a brace right after an escaped quote is still inside the string', () => {
const text = 'say "{" now'
assert.equal(parseRoomShare(encodeRoomShare(line(text)))?.text, text)
const quoteThenBrace = 'a quote " then a brace }'
assert.equal(parseRoomShare(encodeRoomShare(line(quoteThenBrace)))?.text, quoteThenBrace)
})

test('a join keeps the braces of the lines it copies', () => {
const parsed = parseRoomShare(encodeRoomShare({
op: 'join',
roomId: 'r1',
name: 'Launch plan',
history: [
{ who: 'Alice', text: 'if (x) { y() }' },
{ who: 'Bob', text: 'plain line' },
],
}))
assert.equal(parsed?.op, 'join')
assert.deepEqual(parsed?.history?.map(entry => entry.text), ['if (x) { y() }', 'plain line'])
})

test('plain text and balanced braces still parse', () => {
assert.equal(parseRoomShare(encodeRoomShare(line('hello there')))?.text, 'hello there')
const balanced = 'plan {a} then {b}'
assert.equal(parseRoomShare(encodeRoomShare(line(balanced)))?.text, balanced)
})

test('a truncated envelope is still refused', () => {
const whole = encodeRoomShare(line('hello'))
assert.ok(parseRoomShare(whole))
assert.equal(parseRoomShare(whole.slice(0, -1)), undefined)
})
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi

### A local room's invite link (#852)

- A share envelope's body is found by scanning from the first `{` after the marker, and that scan tracks JSON string literals and `\` escapes (#2447): `encodeRoomShare` writes `text` with `JSON.stringify`, which leaves braces raw, so a chat line's own `{` or `}` is text and never ends the envelope early. One unbalanced brace used to fail `JSON.parse` and drop the whole envelope — a sent line never arrived, and a `join` that copied such a line lost its history with it. `parseRoomShare` is the parser the host trusts; the client's `envelopeTail` (#855) already scanned this way.
- A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r/<inviter Bot handle>/<ticket>`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit.
- The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt?, mintedAt, mintedBy, server?, url?, homes?, acceptedAt?, withdrawnAt? }]`. A link has **no deadline and is not single-use** (owner decision 2026-10-24, #892, Bot Social #65): anyone it is given to can join with it, each home once (`homes: { [home]: [seat handles] }`; a record from before keeps its one `acceptedBy` / `seats`, read through `homesOf`), until the owner withdraws it or the room is deleted. A link minted with an `expiresAt` (an older Bot Social, or before the change) keeps that deadline. Several may be live at once (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (not withdrawn, not out of date; a link someone joined with stays open) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every open one: it sets `withdrawnAt` (nobody new joins; the server ticket is revoked without `endClaims`), the seats it made stay, and a withdrawn link that seats nobody is dropped. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation.
- Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. The accepting home is added to `homes`: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home may join too (#892); an add the room refuses removes that home again. Refusals are local on the inviter's host (`unknown` / `revoked` / `expired` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852).
Expand Down
14 changes: 13 additions & 1 deletion patches/ds-bot/files/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,21 @@ export function parseRoomShare(text) {
if (start < 0) return undefined
let depth = 0
let end = -1
// #2447: a brace inside a string literal is a message's own text, not structure. `encodeRoomShare`
// writes `text` with `JSON.stringify`, which leaves braces raw, so counting them dropped the whole
// envelope over one `{` in a chat line — the same scan the client's `envelopeTail` does (#855).
let inString = false
let escaped = false
for (let i = start; i < raw.length; i += 1) {
const ch = raw[i]
if (ch === '{') depth += 1
if (inString) {
if (escaped) escaped = false
else if (ch === '\\') escaped = true
else if (ch === '"') inString = false
continue
}
if (ch === '"') inString = true
else if (ch === '{') depth += 1
else if (ch === '}') {
depth -= 1
if (depth === 0) { end = i; break }
Expand Down
57 changes: 57 additions & 0 deletions patches/ds-bot/files/test/room-share-braces.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// #2447: the brace scan that finds the end of a room-share envelope has to know when a brace is
// part of a chat line's own text. One unbalanced `{` or `}` in a message used to end the scan in
// the wrong place, JSON.parse then failed, and the whole envelope was dropped — a sent line never
// arrived, and one brace in a copied line killed the join that carried it.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js'

const line = (text, extra = {}) => ({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text, ...extra })

test('a line whose text opens a brace is read back, not dropped', () => {
const parsed = parseRoomShare(encodeRoomShare(line('if (x) { y()')))
assert.equal(parsed?.op, 'line')
assert.equal(parsed?.roomId, 'r1')
assert.equal(parsed?.who, 'Alice')
assert.equal(parsed?.text, 'if (x) { y()')
})

test('a line whose text closes a brace is read back, not dropped', () => {
const text = 'so the closing brace is }'
const parsed = parseRoomShare(encodeRoomShare(line(text)))
assert.equal(parsed?.op, 'line')
assert.equal(parsed?.text, text)
})

test('a brace right after an escaped quote is still inside the string', () => {
const text = 'say "{" now'
assert.equal(parseRoomShare(encodeRoomShare(line(text)))?.text, text)
const quoteThenBrace = 'a quote " then a brace }'
assert.equal(parseRoomShare(encodeRoomShare(line(quoteThenBrace)))?.text, quoteThenBrace)
})

test('a join keeps the braces of the lines it copies', () => {
const parsed = parseRoomShare(encodeRoomShare({
op: 'join',
roomId: 'r1',
name: 'Launch plan',
history: [
{ who: 'Alice', text: 'if (x) { y() }' },
{ who: 'Bob', text: 'plain line' },
],
}))
assert.equal(parsed?.op, 'join')
assert.deepEqual(parsed?.history?.map(entry => entry.text), ['if (x) { y() }', 'plain line'])
})

test('plain text and balanced braces still parse', () => {
assert.equal(parseRoomShare(encodeRoomShare(line('hello there')))?.text, 'hello there')
const balanced = 'plan {a} then {b}'
assert.equal(parseRoomShare(encodeRoomShare(line(balanced)))?.text, balanced)
})

test('a truncated envelope is still refused', () => {
const whole = encodeRoomShare(line('hello'))
assert.ok(parseRoomShare(whole))
assert.equal(parseRoomShare(whole.slice(0, -1)), undefined)
})
Loading