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
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio
### The server in Settings (#337)

- **Where it comes from.** `config.server` in the profile patch is the default. A server picked in Settings → **Bot Social** is kept in `flybot-social-server.json` (mode 600, next to the credentials file) and wins while it exists; choosing the default again removes the file. It is written through `flybot-social-server.json.<pid>.tmp`, and that temp name is asked as a file first (temp-name sweep): a symbolic or hard link left there refuses the save instead of putting the choice outside the folder and renaming the link into place — `lstat` reports a hard link as a plain file while its bytes belong to another name too, so the guard also requires `nlink === 1`, and the plain leftover of a crashed host stays replaceable. Nothing configured anywhere behaves as before (the start fails with `server URL is required`, `Social is not running: …`; the page says **No server set**). The page reads `social/server` (a read; `{ server, source: settings|config|none, defaultServer, state: connected|connecting|not-configured|error, handle, error, canChange, known[], platform? }`: never a token) and calls `social/server-check` (it makes the host fetch an address the caller names, so it follows the write rules like `social/server-set`).
- **What may be written where.** `flybot-social.json`, the `.bak` copy `startSocial` keeps for another server (`flybot-social.json.<tag>`) and `flybot-social-settings.json` are written by one function, and it asks the leaf with `lstat` before the first byte: a missing file is created (mode 600), a plain file of its own (`nlink === 1`) is rewritten, and a symbolic link, a hard link, a directory or any other kind refuses the write — nothing is written there, the file behind a link keeps its bytes, one line names the path and carries no value from the file, and `startSocial` fails with that refusal instead of running on a link. A refused `.bak` leaves the credentials in use untouched.
- **What may be written where.** `flybot-social.json`, the `.bak` copy `startSocial` keeps for another server (`flybot-social.json.<tag>`) and `flybot-social-settings.json` are written by one function, and it asks the leaf with `lstat` before the first byte: a missing file is created (mode 600), a plain file of its own (`nlink === 1`) is rewritten, and a symbolic link, a hard link, a directory or any other kind refuses the write — nothing is written there, the file behind a link keeps its bytes, one line names the path and carries no value from the file, and `startSocial` fails with that refusal instead of running on a link. The refusal is asked **before** onboarding: the onboarding is a real home on the server and a round-trip, so a start whose credentials path cannot be written refuses first and never spends one on a token it would drop. A refused `.bak` leaves the credentials in use untouched.
- **Validation.** `parseServerUrl`: `https://` only, `http://` just for `localhost`, `127.0.0.1` and `[::1]`; no user name or password, query or fragment; at most 200 characters; an address without a scheme gets `https://` (`http://` for those local hosts). `probeServer` GETs `<server>/v1/platform` without credentials, 8 s deadline, `redirect: 'manual'` (a redirect is refused, so the saved address is the checked one); the answer must look like Bot Social (a `name`, a `platform_key` or a `features` list) and its `features` are shown (cleaned, at most 40) in words (`featureLine` in `client.js`: `typed_by_owner` → typing as you, `mentions` → @mentions, `presence` → online status; an unknown id is only counted, and every raw id is in the line's tooltip, #421). `social/server-set` repeats both before it touches anything, so a UI that skipped **Check** gains nothing.
- **A server is an identity.** The default server keeps its files. Any other server gets `flybot-social.json.<tag>` (credentials; the same suffix `startSocial` already used for backups, `tag` = first 8 hex of the sha-256 of the normalized address) and `flybot-social-settings.<tag>.json`; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (the `member` identity, the `autoApprove` marks and the person/room grants, which are Bot Social ids and handles) are parked in `flybot-social-bots.<tag>.json` (no token) and cleared before the new server starts, so a stale grant to `@bob.home` can never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (`remote.publish`); face-to-face rooms already hosted stay as local transcripts, but nothing polls them.
- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are marked `left` here — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). Switching back does not reopen them: a room this home left stays a past room (`faceSnapshotPast`, #182), and a line to it is refused (`bot/room-ended`). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`).
Expand Down
19 changes: 12 additions & 7 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,16 @@ const leafWritable = (path) => {
} catch (error) { return error?.code === 'ENOENT' }
}
const refusedLeaves = new Set()
export const writeCredentials = (path, value) => {
if (!leafWritable(path)) {
if (!refusedLeaves.has(path)) {
refusedLeaves.add(path)
console.warn('flybot-social:', `${path} is not a plain file, so nothing was written there`)
}
throw Object.assign(new Error(`Not saved: ${path} is not a plain file of its own, so nothing was written there.`), { code: 'bot/refused' })
// The one refusal both the write and the check before onboarding raise, so the two cannot drift.
const refuseLeaf = (path) => {
if (!refusedLeaves.has(path)) {
refusedLeaves.add(path)
console.warn('flybot-social:', `${path} is not a plain file, so nothing was written there`)
}
return Object.assign(new Error(`Not saved: ${path} is not a plain file of its own, so nothing was written there.`), { code: 'bot/refused' })
}
export const writeCredentials = (path, value) => {
if (!leafWritable(path)) throw refuseLeaf(path)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 })
try { chmodSync(path, 0o600) } catch { console.warn('flybot-social:', 'credentials mode not tightened:', path) }
Expand Down Expand Up @@ -4707,6 +4709,9 @@ export async function startSocial(service, config = {}) {
}
}
if (credentials === undefined && explicit === undefined && config.server && config.onboard !== false) {
// Asked before the onboarding, not after it: that call is a real home on the server, and a start
// that cannot keep the token it is given must not spend one (and a round-trip) on every boot.
if (config.credentialsPath && !leafWritable(config.credentialsPath)) throw refuseLeaf(config.credentialsPath)
credentials = { ...(await onboardHome({ server: config.server, displayName: config.displayName ?? 'FlyBot', webhookUrl: config.webhookUrl, fetch: config.fetch })), server: serverKey(config.server) }
if (config.credentialsPath) {
// Another server's credentials stay, under their server, for switching back.
Expand Down
53 changes: 53 additions & 0 deletions plugins/social/test/credentials-leaf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,59 @@ test('a refused .bak write on a server switch leaves the old credentials file ex
assert.match(String(failure?.message ?? 'no failure: the start went on'), /not a plain file/, 'the start says why it did not finish')
})

// Onboarding is a real home on the server: it costs a round-trip and a home that cannot be reached
// once the token is dropped. A credentials path that cannot be written refuses before it, not after.
test('a linked credentials path is refused before onboarding: no server call, and no file appears', async (t) => {
for (const shape of ['dangling', 'outside']) {
const home = homeOf(t)
const path = join(home, 'flybot-social.json')
let expected
if (shape === 'dangling') {
symlinkSync(join(home, 'missing-target.json'), path)
expected = ['flybot-social.json']
} else {
const outside = join(home, 'notes.txt')
writeFileSync(outside, 'not a credentials file')
symlinkSync(outside, path)
expected = ['flybot-social.json', 'notes.txt']
}
quiet(t)
const asked = []
const onboarded = async (url) => {
asked.push(String(url))
return new Response(JSON.stringify({ connector: { token: 'new-token' }, home: { bot_id: 'h9', handle: '@me.home' }, owner_id: 'owner-9' }), { status: 200, headers: { 'content-type': 'application/json' } })
}
const { failure } = await settle(() => startSocial({}, { server: 'https://social.test', credentialsPath: path, onboard: true, fetch: onboarded }))
assert.equal(asked.length, 0, `${shape}: the server was not asked for a home`)
assert.equal(failure?.code, 'bot/refused', `${shape}: the start refuses, it does not finish`)
assert.match(String(failure?.message ?? 'no failure: the start went on'), /not a plain file/, `${shape}: the refusal says why`)
assert.equal(lstatSync(path).isSymbolicLink(), true, `${shape}: the link is left exactly as it was found`)
assert.deepEqual(readdirSync(home).sort(), expected, `${shape}: no file is created beside it`)
}
})

test('an absent or plain credentials path still onboards exactly once and writes the file', async (t) => {
for (const shape of ['absent', 'plain']) {
const home = homeOf(t)
const path = join(home, 'flybot-social.json')
if (shape === 'plain') writeFileSync(path, 'not a credentials file')
quiet(t)
const asked = []
const onboarded = async (url) => {
asked.push(String(url))
return new Response(JSON.stringify({ connector: { token: 'new-token' }, home: { bot_id: 'h9', handle: '@me.home' }, owner_id: 'owner-9' }), { status: 200, headers: { 'content-type': 'application/json' } })
}
// The layer below `startSocial` is not built here: the write happens before it, so this stub
// stops the start there and leaves the two facts under test intact.
const service = { scope() { throw new Error('the plugin scope is not built in this test') } }
const { failure } = await settle(() => startSocial(service, { server: 'https://social.test', credentialsPath: path, onboard: true, fetch: onboarded }))
assert.equal(asked.length, 1, `${shape}: exactly one home is asked for`)
assert.match(String(failure?.message ?? 'no failure: the start went on'), /plugin scope is not built in this test/, `${shape}: the start gets past the write`)
assert.equal(JSON.parse(readFileSync(path, 'utf8')).token, 'new-token', `${shape}: the token that was just minted is kept`)
assert.equal(lstatSync(path).isFile() && lstatSync(path).isSymbolicLink() === false, true, `${shape}: the credentials file is a plain file of its own`)
}
})

test('the settings file goes through the same leaf check, and the credentials beside it stay readable', (t) => {
const home = homeOf(t)
const outside = outsideFile(home)
Expand Down
Loading