diff --git a/changelog.d/2140-social-bak-precheck.fixed.md b/changelog.d/2140-social-bak-precheck.fixed.md new file mode 100644 index 000000000..ae0bba274 --- /dev/null +++ b/changelog.d/2140-social-bak-precheck.fixed.md @@ -0,0 +1 @@ +A start that moves to another server now asks the backup name before it asks the server for a home: the `.bak` copy (`flybot-social.json.`) is named from the credentials file already on disk, so a link left at that name refuses the start instead of spending a home on the server first and refusing afterwards. A free name still keeps the old server's credentials there, and a refused `.bak` leaves the credentials in use byte for byte ([#2140](https://github.com/ThinkFlowLab/FlyBot/issues/2140)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 83df3d27d..eead5a857 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -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..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.`) 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. +- **What may be written where.** `flybot-social.json`, the `.bak` copy `startSocial` keeps for another server (`flybot-social.json.`) 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, and its name is asked in that same pre-check: the backup name follows from the server in the credentials file already on disk (`flybot-social.json.`), a local read, so a link left there refuses the start before the onboarding rather than after it, and a free name still keeps the old credentials under their server. `flybot-social-settings.json` is a different shape and stays as it is: it is read (a link at that name is followed, like any read) and written only when a setting changes, where a refusal is caught and warned as `settings not saved` — no onboarding depends on it, so it never fails a start. - **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 `/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.` (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..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..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`). diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 91031baf0..885791993 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -4699,7 +4699,8 @@ export async function startSocial(service, config = {}) { // server (a file from before this check is bound to the configured server on first // use), and an explicit token (config or FLYBOT_SOCIAL_TOKEN) always wins. const explicit = config.token ?? process.env.FLYBOT_SOCIAL_TOKEN - let credentials = config.credentialsPath ? readCredentials(config.credentialsPath) : undefined + const saved = config.credentialsPath ? readCredentials(config.credentialsPath) : undefined + let credentials = saved if (credentials && config.server) { if (credentials.server === undefined) { credentials = { ...credentials, server: serverKey(config.server) } @@ -4711,14 +4712,18 @@ 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. + // Both leaves this start writes are named from what is already on disk, a local read before the + // call: the credentials file itself, and the `.bak` copy another server's credentials are kept + // under. if (config.credentialsPath && !leafWritable(config.credentialsPath)) throw refuseLeaf(config.credentialsPath) + const backup = config.credentialsPath && saved?.token && saved.server && serverKey(saved.server) !== serverKey(config.server) + ? `${config.credentialsPath}.${createHash('sha256').update(serverKey(saved.server)).digest('hex').slice(0, 8)}` + : undefined + if (backup && !leafWritable(backup)) throw refuseLeaf(backup) 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. - const previous = readCredentials(config.credentialsPath) - if (previous?.token && previous.server && serverKey(previous.server) !== serverKey(config.server)) { - writeCredentials(`${config.credentialsPath}.${createHash('sha256').update(serverKey(previous.server)).digest('hex').slice(0, 8)}`, previous) - } + if (backup) writeCredentials(backup, saved) writeCredentials(config.credentialsPath, credentials) } } diff --git a/plugins/social/test/credentials-leaf.test.js b/plugins/social/test/credentials-leaf.test.js index f1068d75d..25799927a 100644 --- a/plugins/social/test/credentials-leaf.test.js +++ b/plugins/social/test/credentials-leaf.test.js @@ -169,6 +169,57 @@ test('a linked credentials path is refused before onboarding: no server call, an } }) +// The `.bak` copy kept for another server is a leaf of its own, and its name follows from the file +// already on disk — a local read — so it can be asked before the onboarding too, not after it. +test('a linked .bak path is refused before onboarding: no server call, and no home is created', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + const old = JSON.stringify({ token: 'old-token', home: { id: 'h1', handle: '@me.home' }, server: 'https://old.example' }) + writeFileSync(path, old) + const sha8 = createHash('sha256').update('https://old.example').digest('hex').slice(0, 8) + const outside = outsideFile(home) + const before = statSync(outside) + symlinkSync(outside, `${path}.${sha8}`) + 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://new.test', credentialsPath: path, onboard: true, fetch: onboarded })) + assert.equal(asked.length, 0, 'the server was not asked for a home') + assert.equal(failure?.code, 'bot/refused', 'the start refuses, it does not finish') + assert.match(String(failure?.message ?? 'no failure: the start went on'), /not a plain file/, 'the refusal says why') + assert.equal(readFileSync(path, 'utf8'), old, 'the credentials in use are byte for byte what they were') + assert.equal(readFileSync(outside, 'utf8'), CREDENTIALS, 'nothing lands in the file behind the backup link') + assert.equal(statSync(outside).mtimeMs, before.mtimeMs, 'and the file behind it is not touched at all') + assert.deepEqual(readdirSync(home).sort(), ['flybot-social.json', `flybot-social.json.${sha8}`, 'outside.json'], 'no file is created beside them') +}) + +// The other side of the same shape: with the `.bak` name free, the home is asked once and the old +// credentials land under their server, so the check above cannot pass by refusing every switch. +test('a free .bak name still onboards once, and the old credentials are kept under their server', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + const old = JSON.stringify({ token: 'old-token', home: { id: 'h1', handle: '@me.home' }, server: 'https://old.example' }) + writeFileSync(path, old) + const sha8 = createHash('sha256').update('https://old.example').digest('hex').slice(0, 8) + 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://new.test', credentialsPath: path, onboard: true, fetch: onboarded })) + assert.equal(asked.length, 1, 'exactly one home is asked for') + assert.deepEqual(JSON.parse(readFileSync(`${path}.${sha8}`, 'utf8')), JSON.parse(old), 'the old credentials are kept under their server') + assert.equal(JSON.parse(readFileSync(path, 'utf8')).token, 'new-token', 'the token that was just minted is kept') + assert.match(String(failure?.message ?? 'no failure: the start went on'), /plugin scope is not built in this test/, 'the start gets past the write') +}) + 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)