diff --git a/core/index.js b/core/index.js index 80cfc7a4e..b40658666 100644 --- a/core/index.js +++ b/core/index.js @@ -4,6 +4,7 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' +import * as memberAuth from './src/host/member-auth.js' import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' @@ -42,7 +43,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] +const MODULES = [diagnostics, store, memberAuth, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index db90b9c66..31690b0e7 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -83,7 +83,8 @@ export function install(rt) { }) // What a host that only reads the team may still answer. // Updates change the dsh profile, not the team, so a reading host may run them too. - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. + const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) function setPrefs(body) { if (body.theme !== undefined) { @@ -103,13 +104,16 @@ export function install(rt) { return rt.save() } - const handle = async (endpoint, payload, signal) => { + const handle = async (endpoint, payload, signal, sessionToken) => { try { const body = payload ?? {} await rt.refresh() const readOnly = rt.readOnly() const flybotRead = endpoint.startsWith('flybot/') && rt.flybotIsRead?.(endpoint.slice('flybot/'.length)) === true if (readOnly !== undefined && !READS.has(endpoint) && !flybotRead) return fail(readOnly, 'bot/read-only') + // Actor is resolved once from the member session token; body.actorId / payload.actorId + // grant nothing and are ignored (#48). + const actor = sessionToken ? await rt.authenticate(sessionToken) : null switch (endpoint) { case 'state': if (typeof body.locale === 'string' && /^[a-z]{2,3}(-[\w-]+)?$/i.test(body.locale)) uiLocale = body.locale @@ -323,6 +327,34 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) + case 'auth-whoami': + return ok(actor) + case 'auth-invite': { + if (!rt.authInviteAllowed(actor)) return fail('Sign in first', 'bot/unauthorized') + const invite = await rt.createInvitation({ createdByMemberId: actor?.memberId ?? null }) + return ok(invite) + } + case 'auth-revoke-invite': { + if (!actor) return fail('Sign in first', 'bot/unauthorized') + const result = await rt.revokeInvitation(String(body.invitationId ?? '')) + if (!result.ok) return fail(result.error, 'bot/auth') + return ok(result) + } + case 'auth-redeem': { + const result = await rt.redeemInvitation(body.token, { displayName: typeof body.displayName === 'string' ? body.displayName : null }) + if (!result.ok) return fail(result.error, 'bot/auth') + return ok(result) + } + case 'auth-revoke': { + if (!actor) return fail('Sign in first', 'bot/unauthorized') + return ok(await rt.revokeSession(sessionToken)) + } + case 'auth-remove-member': { + if (!actor) return fail('Sign in first', 'bot/unauthorized') + const removed = await rt.removeMember(String(body.memberId ?? '')) + if (!removed.ok) return fail(removed.error, 'bot/auth') + return ok(removed) + } default: // FlyBot plugins' endpoints (src/host/hooks.js). if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body)) @@ -344,7 +376,8 @@ export function install(rt) { fetch: async (request) => { let body try { body = await request.json() } catch { body = {} } - const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal) + const sessionToken = request.headers.get('x-flybot-member-session') + const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal, sessionToken) return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' } }) }, } diff --git a/core/src/host/member-auth.js b/core/src/host/member-auth.js new file mode 100644 index 000000000..58352bbb8 --- /dev/null +++ b/core/src/host/member-auth.js @@ -0,0 +1,187 @@ +// Member auth foundation (#48): invitations, member sessions, and the whoami that the +// channel resolves from the x-flybot-member-session header. Members are human owners +// (store #47 identity schema: id, createdAt, designatedChiefId) — Bot `role` is a job +// title and grants nothing here. Tokens never leave as hashes; only hashes are stored. +// This is not team mode: it is the sign-in surface the gates in docs/subsystems/team.md +// need, and nothing more. +import { createHash, randomBytes } from 'node:crypto' + +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +function hashToken(token) { + return createHash('sha256').update(token).digest('hex') +} + +function ensureState(state) { + if (!state.invitations) state.invitations = {} + if (!state.authSessions) state.authSessions = {} + return state +} + +// Last owner: the one member the single-human product still needs. All members are +// human owners; a Bot is not a member and its job-title role means nothing here. +function lastOwner(members) { + return Object.keys(members).length <= 1 +} + +function displayNameOf(member, displayName) { + return displayName ?? member.displayName ?? member.name ?? member.id +} + +export function install(rt) { + const state = ensureState(rt.state) + + // One mutation at a time: a check like last_owner or has_bots must not race another + // mutation that would invalidate it between the check and the save. + let authGate = Promise.resolve() + const withAuthLock = fn => { + const run = authGate.then(fn, fn) + authGate = run.then(() => {}, () => {}) + return run + } + + rt.createInvitation = async function createInvitation({ ttlMs = DEFAULT_TTL_MS, createdByMemberId = null } = {}) { + return withAuthLock(async () => { + const token = randomBytes(32).toString('base64url') + const tokenHash = hashToken(token) + const id = `inv-${randomBytes(8).toString('hex')}` + const now = Date.now() + state.invitations[id] = { + id, + tokenHash, + createdByMemberId, + createdAt: now, + expiresAt: now + ttlMs, + redeemedAt: null, + redeemedByMemberId: null, + revokedAt: null, + } + await rt.save() + return { id, token, expiresAt: now + ttlMs } + }) + } + + rt.revokeInvitation = async function revokeInvitation(invitationId) { + return withAuthLock(async () => { + const inv = state.invitations[invitationId] + if (!inv) return { ok: false, error: 'not_found' } + if (inv.redeemedAt) return { ok: false, error: 'already_redeemed' } + if (inv.revokedAt) return { ok: false, error: 'already_revoked' } + inv.revokedAt = Date.now() + await rt.save() + return { ok: true } + }) + } + + rt.redeemInvitation = async function redeemInvitation(token, { displayName = null } = {}) { + if (typeof token !== 'string' || !token) return { ok: false, error: 'invalid_token' } + const tokenHash = hashToken(token) + const now = Date.now() + return withAuthLock(async () => { + const invitation = Object.values(state.invitations).find(inv => inv.tokenHash === tokenHash) + if (!invitation) return { ok: false, error: 'invalid_token' } + if (invitation.revokedAt) return { ok: false, error: 'revoked' } + if (invitation.redeemedAt) return { ok: false, error: 'already_redeemed' } + if (now > invitation.expiresAt) return { ok: false, error: 'expired' } + + if (!rt.state.members) rt.state.members = {} + const members = rt.state.members + // Bootstrap: the first redeem binds to the existing single owner (owner-1) when one + // is there, so the human who installed FlyBot claims the session without a second + // member record. Only a later redeem creates the next owner. + let member + if (Object.keys(state.authSessions).length === 0) { + member = members['owner-1'] ?? null + } + if (!member) { + let n = 1 + while (members[`owner-${n}`]) n += 1 + const memberId = `owner-${n}` + // Persisted member is the identity schema only; displayName is returned in the + // session response, not written into the member record. + member = { id: memberId, createdAt: now, designatedChiefId: null } + members[memberId] = member + } + const sessionToken = randomBytes(32).toString('base64url') + const sessionId = `as-${randomBytes(8).toString('hex')}` + state.authSessions[sessionId] = { + id: sessionId, + tokenHash: hashToken(sessionToken), + memberId: member.id, + createdAt: now, + revokedAt: null, + } + invitation.redeemedAt = now + invitation.redeemedByMemberId = member.id + await rt.save() + return { ok: true, sessionToken, memberId: member.id, displayName: displayNameOf(member, displayName) } + }) + } + + rt.authenticate = async function authenticate(sessionToken) { + if (typeof sessionToken !== 'string' || !sessionToken) return null + const tokenHash = hashToken(sessionToken) + const session = Object.values(state.authSessions).find(s => s.tokenHash === tokenHash) + if (!session || session.revokedAt) return null + const member = (rt.state.members || {})[session.memberId] + if (!member) return null + return { memberId: member.id, sessionId: session.id, displayName: displayNameOf(member) } + } + + rt.revokeSession = async function revokeSession(sessionToken) { + if (typeof sessionToken !== 'string' || !sessionToken) return false + const tokenHash = hashToken(sessionToken) + return withAuthLock(async () => { + const session = Object.values(state.authSessions).find(s => s.tokenHash === tokenHash) + if (!session || session.revokedAt) return false + session.revokedAt = Date.now() + await rt.save() + return true + }) + } + + const revokeMemberSessionsNow = async memberId => { + const now = Date.now() + let count = 0 + for (const session of Object.values(state.authSessions)) { + if (session.memberId === memberId && !session.revokedAt) { + session.revokedAt = now + count += 1 + } + } + if (count > 0) await rt.save() + return count + } + + rt.revokeMemberSessions = async function revokeMemberSessions(memberId) { + return withAuthLock(() => revokeMemberSessionsNow(memberId)) + } + + rt.removeMember = async function removeMember(memberId) { + return withAuthLock(async () => { + const members = rt.state.members || {} + const member = members[memberId] + if (!member) return { ok: false, error: 'not_found' } + if (lastOwner(members)) return { ok: false, error: 'last_owner' } + const bots = Object.values(rt.state.bots || {}) + if (bots.some(b => b.ownerId === memberId)) return { ok: false, error: 'has_bots' } + await revokeMemberSessionsNow(memberId) + delete members[memberId] + await rt.save() + return { ok: true } + }) + } + + // Channel bootstrap: an invitation needs a signed-in member, except on a host that has + // never had a session — the first invite is how the installing human signs in. + rt.authInviteAllowed = actor => !!actor || Object.keys(state.authSessions).length === 0 + rt.authBootstrapOpen = () => Object.keys(state.authSessions).length === 0 + + rt.whoami = async function whoami(sessionToken) { + return rt.authenticate(sessionToken) + } + + return rt +} + +export { hashToken } diff --git a/core/src/host/store.js b/core/src/host/store.js index 7a7bf05e7..1815cef00 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -20,8 +20,8 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array. export function install(rt) { const { ctx, home, statePath } = rt - /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number}} */ - const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 } + /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record}} */ + const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, revision: 0 } let loaded = null let writing = Promise.resolve() const { warn } = rt @@ -296,6 +296,15 @@ export function install(rt) { state.members = {} migrated = true } + // Member auth (#48): invitations and session records; a missing map resets. + if (!isRecord(state.invitations)) { + state.invitations = {} + migrated = true + } + if (!isRecord(state.authSessions)) { + state.authSessions = {} + migrated = true + } if (ensureOwner()) migrated = true if (legacy) { const ownerId = Object.keys(state.members)[0] diff --git a/core/test/member-auth.test.js b/core/test/member-auth.test.js new file mode 100644 index 000000000..9efd41070 --- /dev/null +++ b/core/test/member-auth.test.js @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { install as installStore } from '../src/host/store.js' +import { install as installMemberAuth } from '../src/host/member-auth.js' + +// Member auth foundation (#48): invitations, member sessions, and whoami from the +// x-flybot-member-session header. Members are the #47 identity schema (human owners); +// Bot role is a job title and means nothing here. Team mode stays disabled. + +function runtime(t, saved) { + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-member-auth-')) + const statePath = join(dir, 'state.json') + if (saved !== undefined) writeFileSync(statePath, typeof saved === 'string' ? saved : JSON.stringify(saved)) + const warnings = [] + const disposers = [] + const ctx = { + logger: { warn: (...args) => warnings.push(args) }, + effect(execute) { + const dispose = execute() + if (typeof dispose === 'function') disposers.push(dispose) + }, + } + const rt = { + ctx, + home: dir, + statePath, + autoRoomName: () => 'Room', + retitleRoom: async () => {}, + warn: (...args) => warnings.push(args), + } + installStore(rt) + installMemberAuth(rt) + t.after(async () => { + await Promise.all(disposers.splice(0).map(cleanup => cleanup())) + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) + }) + return { rt, statePath } +} + +const TEAM = { + version: 2, + revision: 1, + mainBotIds: ['m1'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' } }, + bots: { m1: { id: 'm1', sessionId: 'm1', name: 'Chief', instructions: '', createdAt: 1, ownerId: 'owner-1' } }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +test('two members get different identities via invite then redeem', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const invite1 = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + assert.equal(typeof invite1.token, 'string') + assert.ok(invite1.token.length > 20) + // First redeem binds to the existing owner-1 (bootstrap), not a second record. + const first = await rt.redeemInvitation(invite1.token, { displayName: 'Alice' }) + assert.equal(first.ok, true) + assert.equal(first.memberId, 'owner-1') + assert.equal(first.displayName, 'Alice') + assert.equal(typeof first.sessionToken, 'string') + assert.notEqual(first.sessionToken, invite1.token) + // A second invite is a second human: a new owner member record. + const invite2 = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const second = await rt.redeemInvitation(invite2.token, { displayName: 'Bob' }) + assert.equal(second.ok, true) + assert.notEqual(second.memberId, 'owner-1') + assert.equal(second.displayName, 'Bob') + assert.equal(second.memberId, 'owner-2') + // Persisted members keep the identity shape: no role, no displayName field. + const member1 = rt.state.members['owner-1'] + const member2 = rt.state.members['owner-2'] + assert.deepEqual(Object.keys(member1).sort(), ['createdAt', 'designatedChiefId', 'id']) + assert.deepEqual(Object.keys(member2).sort(), ['createdAt', 'designatedChiefId', 'id']) + assert.equal(member1.designatedChiefId, 'm1') + assert.equal(member2.designatedChiefId, null) + // The two sessions authenticate to distinct members. + const who1 = await rt.authenticate(first.sessionToken) + const who2 = await rt.authenticate(second.sessionToken) + assert.equal(who1.memberId, 'owner-1') + assert.equal(who2.memberId, 'owner-2') + assert.notEqual(who1.sessionId, who2.sessionId) +}) + +test('authenticate ignores forged strings; only a live session token works', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const session = await rt.redeemInvitation(invite.token) + assert.equal((await rt.authenticate('owner-1'))?.memberId, undefined) + assert.equal(await rt.authenticate('owner-1'), null) + assert.equal(await rt.authenticate(session.memberId), null) + assert.equal(await rt.authenticate('owner-1:main'), null) + assert.equal(await rt.authenticate(''), null) + assert.equal(await rt.authenticate(null), null) + assert.equal(await rt.authenticate(undefined), null) + assert.equal(await rt.authenticate(123), null) + assert.equal(await rt.authenticate({ memberId: 'owner-1' }), null) + // A string that merely looks like a member id or session id is not a session token. + assert.equal(await rt.authenticate('as-' + '0'.repeat(16)), null) + // The real token still works. + const who = await rt.authenticate(session.sessionToken) + assert.equal(who.memberId, 'owner-1') +}) + +test('an invitation token cannot be replayed and an expired one fails', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + assert.equal((await rt.redeemInvitation(invite.token)).ok, true) + const replay = await rt.redeemInvitation(invite.token) + assert.equal(replay.ok, false) + assert.equal(replay.error, 'already_redeemed') + // Expired: a fresh invite whose ttl already elapsed. + const expired = await rt.createInvitation({ createdByMemberId: 'owner-1', ttlMs: -1 }) + const result = await rt.redeemInvitation(expired.token) + assert.equal(result.ok, false) + assert.equal(result.error, 'expired') + // An unknown token is also refused. + assert.equal((await rt.redeemInvitation('not-a-real-token')).error, 'invalid_token') +}) + +test('removeMember and revokeSession invalidate authenticate; last owner stays', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const inviteA = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const a = await rt.redeemInvitation(inviteA.token) + const inviteB = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const b = await rt.redeemInvitation(inviteB.token) + assert.equal((await rt.authenticate(b.sessionToken)).memberId, 'owner-2') + // revokeSession: that one session dies, the other lives. + assert.equal(await rt.revokeSession(b.sessionToken), true) + assert.equal(await rt.authenticate(b.sessionToken), null) + assert.equal((await rt.authenticate(a.sessionToken)).memberId, 'owner-1') + // removeMember revokes that member's remaining sessions and drops the record. + // owner-1 still owns the Main Bot m1, so the refusal comes first (has_bots). + const owned = await rt.removeMember('owner-1') + assert.equal(owned.ok, false) + assert.equal(owned.error, 'has_bots') + // Drop the Bot binding and the same remove succeeds. + delete rt.state.bots.m1 + const removed = await rt.removeMember('owner-1') + assert.equal(removed.ok, true) + assert.equal(await rt.authenticate(a.sessionToken), null) + assert.equal(rt.state.members['owner-1'], undefined) + // With only one member left, the last owner cannot be removed. + const last = await rt.removeMember('owner-2') + assert.equal(last.ok, false) + assert.equal(last.error, 'last_owner') + assert.ok(rt.state.members['owner-2']) + // A fresh single-owner team also refuses removeMember on owner-1. + const fresh = runtime(t, TEAM) + await fresh.rt.load() + const only = await fresh.rt.removeMember('owner-1') + assert.equal(only.ok, false) + assert.equal(only.error, 'last_owner') +}) + +test('tokens are stored only as hashes; plaintext never reaches state.json', async (t) => { + const { rt, statePath } = runtime(t, TEAM) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const session = await rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const saved = readFileSync(statePath, 'utf8') + assert.equal(saved.includes(invite.token), false) + assert.equal(saved.includes(session.sessionToken), false) + assert.ok(saved.includes('"tokenHash"')) + assert.ok(saved.includes('"authSessions"')) +}) + +// Two owners and nothing else: no Bot roster, no designated Chief, so a removeMember +// race is visible as member count rather than a has_bots refusal. +const FLAT = { + version: 2, + revision: 1, + mainBotIds: [], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: null } }, + bots: {}, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +async function twoMembers(rt) { + const first = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const a = await rt.redeemInvitation(first.token) + const second = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const b = await rt.redeemInvitation(second.token) + assert.equal(a.ok, true) + assert.equal(b.ok, true) + return { a, b } +} + +test('removeMember refuses while the member still owns a Bot; has_bots after delete', async (t) => { + const { rt, statePath } = runtime(t, TEAM) + await rt.load() + const { b } = await twoMembers(rt) + assert.ok(rt.state.members['owner-2']) + rt.state.bots['b1'] = { id: 'b1', sessionId: 'b1', name: 'X', instructions: '', createdAt: 2, ownerId: 'owner-2' } + await rt.save() + const refused = await rt.removeMember('owner-2') + assert.equal(refused.ok, false) + assert.equal(refused.error, 'has_bots') + assert.ok(rt.state.members['owner-2']) + // The refusal does not touch the member's live session. + assert.equal((await rt.authenticate(b.sessionToken))?.memberId, 'owner-2') + // The owner-2 member and its Bot survive a reload, and the refusal is not sticky: + // drop the Bot and the same remove succeeds. + delete rt.state.bots['b1'] + await rt.save() + const removed = await rt.removeMember('owner-2') + assert.equal(removed.ok, true) + assert.equal(rt.state.members['owner-2'], undefined) + // Restart: the saved file loads and the ownership invariants still hold. + const restart = runtime(t, readFileSync(statePath, 'utf8')) + await restart.rt.load() + assert.equal(restart.rt.state.members['owner-2'], undefined) + assert.ok(restart.rt.state.members['owner-1']) + assert.equal(restart.rt.state.bots.m1.ownerId, 'owner-1') +}) + +test('concurrent removeMember calls cannot both pass the last-owner check', async (t) => { + const { rt, statePath } = runtime(t, FLAT) + await rt.load() + await twoMembers(rt) + assert.equal(Object.keys(rt.state.members).length, 2) + const results = await Promise.all([rt.removeMember('owner-1'), rt.removeMember('owner-2')]) + const okCount = results.filter(r => r.ok).length + assert.equal(okCount, 1) + const refusal = results.find(r => !r.ok) + assert.ok(['last_owner', 'not_found'].includes(refusal.error)) + assert.equal(Object.keys(rt.state.members).length, 1) + const restart = runtime(t, readFileSync(statePath, 'utf8')) + await restart.rt.load() + assert.equal(Object.keys(restart.rt.state.members).length, 1) +}) + +test('a revoked invitation cannot be redeemed or revoked twice', async (t) => { + const { rt } = runtime(t, FLAT) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const revoked = await rt.revokeInvitation(invite.id) + assert.deepEqual(revoked, { ok: true }) + assert.equal(await rt.revokeInvitation(invite.id).then(r => r.error), 'already_revoked') + assert.equal(await rt.revokeInvitation('inv-nope').then(r => r.error), 'not_found') + const redeem = await rt.redeemInvitation(invite.token) + assert.equal(redeem.ok, false) + assert.equal(redeem.error, 'revoked') + // An invitation already redeemed cannot be revoked after the fact. + const live = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + assert.equal((await rt.redeemInvitation(live.token)).ok, true) + assert.equal(await rt.revokeInvitation(live.id).then(r => r.error), 'already_redeemed') +}) + +test('authInviteAllowed: bootstrap opens the first invite, then a session is required', async (t) => { + const { rt } = runtime(t, FLAT) + await rt.load() + // Never signed in: the channel lets an anonymous caller create the first invitation. + assert.equal(Object.keys(rt.state.authSessions).length, 0) + assert.equal(rt.authBootstrapOpen(), true) + assert.equal(rt.authInviteAllowed(null), true) + assert.equal(rt.authInviteAllowed(undefined), true) + const invite = await rt.createInvitation({ createdByMemberId: null }) + assert.equal(typeof invite.token, 'string') + // After the first session exists, bootstrap closes: an actor is required. + const session = await rt.redeemInvitation(invite.token) + assert.equal(session.ok, true) + assert.equal(rt.authBootstrapOpen(), false) + assert.equal(rt.authInviteAllowed(null), false) + assert.equal(rt.authInviteAllowed({ memberId: 'owner-1', sessionId: session.sessionId, displayName: null }), true) +}) diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 0c05ce423..f4513e55f 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -56,7 +56,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c 2. `core/` == `ds-bot/` + `patches/ds-bot/`; regenerate after every `core/` change. 3. `client.js` matches `core/src/client/`; `npm run build` + `--check`. 4. Never touch the user's `~/.dsh`. -5. The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). +5. The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). 6. Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). ## Tests diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 3e360c03b..e121e1375 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -44,7 +44,16 @@ Shipped by the core store (`core/src/host/store.js`, owned by [ds-bot-core](ds-b - The saved team (`state.json`) is **version 2**. `members` holds records with an immutable `id`, `createdAt`, and `designatedChiefId`; every Bot carries an `ownerId` binding. - Loading a legacy **version-1** team runs one migration: it creates the single initial owner (`owner-1`), binds every existing Bot to it, sets the designated Chief from the **first Main Bot** (the shipped Main role — no name matching, no new Main rules), and writes version 2. Bot ids, Session parts and private histories are preserved. The pre-migration file is backed up once as `state.json.v1` before the first version-2 write; if a backup already exists it counts only when it is a readable file holding a parseable version-1 state — a directory, an empty or truncated file, or anything else there fails the load closed instead of migrating. - Malformed ownership **fails closed**: a Bot owner that names no member, duplicate or mismatched member ids, or a designated Chief that is not a Bot of its member makes the file read-only — it is never repaired by guessing. An older binary already refuses a newer version read-only, so it cannot overwrite the schema. -- This is **not team mode**: there is no auth, no sign-in, no server-side owner scoping, no cross-owner routing, and no flag/config/env for any of them. The gates below are unchanged and unsatisfied. The schema resolves nothing about `create_child_bot` (#16 stays open) and defines no API, tool, config key or env var. +- This is **not team mode**: the gates below are unchanged and unsatisfied. The schema resolves nothing about `create_child_bot` (#16 stays open) and defines no flag/config/env for team mode. The #48 member-auth foundation (invitations, sessions, whoami) is a sign-in surface only — it does not enable team mode or cross-owner routing. + +## Member auth foundation (landed, #48) + +A thin invitation-and-session layer on top of the #47 identity schema, in `core/src/host/member-auth.js`: + +- An invitation is a one-time random token; only its **sha256 hash** is stored. Redeeming a valid, unexpired, unredeemed invite creates a **member session** (also stored as a hash) and returns the session token once. The first redeem (no sessions yet) binds to the existing `owner-1` member; later redeems create the next human owner (`owner-2`, …). A member record stays the identity shape (`id`, `createdAt`, `designatedChiefId`); a display name is returned in the session response and is never written into the member record. +- The channel resolves the actor once per request from the `x-flybot-member-session` header (`rt.authenticate`). `body.actorId` / `payload.actorId` grant nothing and are ignored. Endpoints: `auth-invite`, `auth-redeem`, `auth-revoke`, `auth-whoami`, `auth-remove-member`, `auth-revoke-invite`. `auth-invite` needs a signed-in member, except on a host that has never had a session (bootstrap — the first invite is how the installing human signs in); every other member-auth endpoint needs the actor. +- `removeMember` revokes that member's sessions and drops the record; the **last owner cannot be removed**, and a member that still **owns a Bot** cannot be removed (`has_bots`) — reassign or delete the Bots first. `revokeSession` kills one session; `revokeInvitation` kills an unredeemed invitation (`auth-revoke-invite`, redeemed or already-revoked invitations refused), and redeeming a revoked invitation fails with `revoked`. Auth mutations run one at a time (an in-process lock), so concurrent `removeMember` calls cannot both pass the last-owner check. Only hashes live in `state.json`; plaintext tokens never reach disk. +- This is still **not team mode**: no owner scoping, no cross-owner routing, no flag/config/env for any of them. The gates below remain unsatisfied; this is the sign-in surface those gates will need. ## Identity model @@ -160,4 +169,6 @@ Planned rules (fail closed by default): **Schema foundation (#47)** has store-level tests in `core/test/identity.test.js`: a fresh team and a representative legacy team load, migration idempotence and restart-safety, the `state.json.v1` backup (created once; a directory or a truncated/unusable file in its place fails closed), and malformed-ownership fail-closed cases (unknown owner, duplicate member ids, bad Chief references, version-1 files carrying ownership fields). They assert data records only — no routing, scoping or approvals, and no multi-human behavior. +**Member auth foundation (#48)** has tests in `core/test/member-auth.test.js`: two members get distinct identities via invite→redeem, forged strings never authenticate (only a live session token does), invitation replay and expiry fail, `removeMember` / `revokeSession` invalidate sessions, the last owner cannot be removed, and tokens are stored only as hashes. These cover the sign-in surface only — no owner scoping or cross-owner behavior. + The routing-matrix rows above remain untested because the behavior is not shipped. When enforcement work starts (#48+), each row becomes a test first, per [fail-without-change](../code-standards.md): same-owner delegation allowed (a), Chief-to-Chief allowed (b), cross-owner specialist denied (c), team-admin vs private-owner denied (d), forwarded approval rejected (e). Each must fail on the unbroken tip before implementation lands. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index e5d6293ef..62547ae2c 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1,10 +1,18 @@ FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 8 file(s). diff --git a/index.js b/index.js -index 9cdb56ac60dda8bb9f364eb0791908a867412210..80cfc7a4e5afd547f219513a4cdfb81c7a6bae92 100644 +index 9cdb56ac60dda8bb9f364eb0791908a867412210..b40658666ac74fbbd77d7f5396ac59f8931d3322 100644 --- a/index.js +++ b/index.js -@@ -29,6 +29,8 @@ import * as schedules from './src/host/schedules.js' +@@ -4,6 +4,7 @@ + import { join } from 'node:path' + import * as diagnostics from './src/host/diagnostics.js' + import * as store from './src/host/store.js' ++import * as memberAuth from './src/host/member-auth.js' + import * as models from './src/host/models.js' + import * as permissions from './src/host/permissions.js' + import * as turns from './src/host/turns.js' +@@ -29,6 +30,8 @@ import * as schedules from './src/host/schedules.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' import * as dshUpdate from './src/host/dsh-update.js' @@ -13,12 +21,12 @@ index 9cdb56ac60dda8bb9f364eb0791908a867412210..80cfc7a4e5afd547f219513a4cdfb81c import * as channel from './src/host/channel.js' export { COLORS } from './src/host/constants.js' -@@ -40,10 +42,30 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses +@@ -40,10 +43,30 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] ++const MODULES = [diagnostics, store, memberAuth, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -838,7 +846,7 @@ index 2c95d08090930cb59c4cc47170685ceae63355b8..4c9088eb0156e46245c45aaa78227f79 // Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks. // Past parts are archived too: a part move can leave tasks on the old Session, diff --git a/src/host/store.js b/src/host/store.js -index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8beddfad03c7 100644 +index 580ce7abbe24a8c94587745e8d23932d04bfd448..1815cef003f5c4a9f8499254976600364a00decd 100644 --- a/src/host/store.js +++ b/src/host/store.js @@ -1,18 +1,27 @@ @@ -867,8 +875,8 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8bed - /** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number}} */ - const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 } -+ /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number}} */ -+ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 } ++ /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record}} */ ++ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, revision: 0 } let loaded = null let writing = Promise.resolve() const { warn } = rt @@ -1008,7 +1016,7 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8bed // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -163,10 +280,69 @@ export function install(rt) { +@@ -163,10 +280,78 @@ export function install(rt) { // Unnamed groups from before names followed their members may still list old names. const stale = Object.values(state.rooms).filter(room => !room.named && room.name !== rt.autoRoomName(room.members)) for (const room of stale) room.name = rt.autoRoomName(room.members) @@ -1028,6 +1036,15 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8bed + state.members = {} + migrated = true + } ++ // Member auth (#48): invitations and session records; a missing map resets. ++ if (!isRecord(state.invitations)) { ++ state.invitations = {} ++ migrated = true ++ } ++ if (!isRecord(state.authSessions)) { ++ state.authSessions = {} ++ migrated = true ++ } + if (ensureOwner()) migrated = true + if (legacy) { + const ownerId = Object.keys(state.members)[0] @@ -1078,7 +1095,7 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8bed const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -335,9 +511,10 @@ export function install(rt) { +@@ -335,9 +520,10 @@ export function install(rt) { } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1091,7 +1108,7 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..7a7bf05e7738ccab6df2ff3c092e8bed } // Every group chat whose id or name matches; callers refuse an ambiguous name. const findRooms = (ref) => { -@@ -353,5 +530,6 @@ export function install(rt) { +@@ -353,5 +539,6 @@ export function install(rt) { botOf, roomOf, isMain, chatOf, selfOf, isBotSession, chatOwners, groupOwners, pastOwners, groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index f0bb307f3..5ea06f791 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1,7 +1,7 @@ FlyBot patch 0005-social-seams on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 5 file(s). diff --git a/src/host/channel.js b/src/host/channel.js -index 40d43de581abae0bcc9a8fd485816117e48e8bea..db90b9c66c4de6914d7f676e3f5ac5d25c9ebff5 100644 +index 40d43de581abae0bcc9a8fd485816117e48e8bea..31690b0e7963322dec1cc530f85aeaefd9b6b16f 100644 --- a/src/host/channel.js +++ b/src/host/channel.js @@ -47,6 +47,8 @@ export function install(rt) { @@ -23,26 +23,74 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..db90b9c66c4de6914d7f676e3f5ac5d2 defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -105,7 +108,8 @@ export function install(rt) { +@@ -80,7 +83,8 @@ export function install(rt) { + }) + // What a host that only reads the team may still answer. + // Updates change the dsh profile, not the team, so a reading host may run them too. +- const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) ++ // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. ++ const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) + + function setPrefs(body) { + if (body.theme !== undefined) { +@@ -100,12 +104,16 @@ export function install(rt) { + return rt.save() + } + +- const handle = async (endpoint, payload, signal) => { ++ const handle = async (endpoint, payload, signal, sessionToken) => { + try { const body = payload ?? {} await rt.refresh() const readOnly = rt.readOnly() - if (readOnly !== undefined && !READS.has(endpoint)) return fail(readOnly, 'bot/read-only') + const flybotRead = endpoint.startsWith('flybot/') && rt.flybotIsRead?.(endpoint.slice('flybot/'.length)) === true + if (readOnly !== undefined && !READS.has(endpoint) && !flybotRead) return fail(readOnly, 'bot/read-only') ++ // Actor is resolved once from the member session token; body.actorId / payload.actorId ++ // grant nothing and are ignored (#48). ++ const actor = sessionToken ? await rt.authenticate(sessionToken) : null switch (endpoint) { case 'state': if (typeof body.locale === 'string' && /^[a-z]{2,3}(-[\w-]+)?$/i.test(body.locale)) uiLocale = body.locale -@@ -320,6 +324,8 @@ export function install(rt) { +@@ -319,7 +327,37 @@ export function install(rt) { + case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) ++ case 'auth-whoami': ++ return ok(actor) ++ case 'auth-invite': { ++ if (!rt.authInviteAllowed(actor)) return fail('Sign in first', 'bot/unauthorized') ++ const invite = await rt.createInvitation({ createdByMemberId: actor?.memberId ?? null }) ++ return ok(invite) ++ } ++ case 'auth-revoke-invite': { ++ if (!actor) return fail('Sign in first', 'bot/unauthorized') ++ const result = await rt.revokeInvitation(String(body.invitationId ?? '')) ++ if (!result.ok) return fail(result.error, 'bot/auth') ++ return ok(result) ++ } ++ case 'auth-redeem': { ++ const result = await rt.redeemInvitation(body.token, { displayName: typeof body.displayName === 'string' ? body.displayName : null }) ++ if (!result.ok) return fail(result.error, 'bot/auth') ++ return ok(result) ++ } ++ case 'auth-revoke': { ++ if (!actor) return fail('Sign in first', 'bot/unauthorized') ++ return ok(await rt.revokeSession(sessionToken)) ++ } ++ case 'auth-remove-member': { ++ if (!actor) return fail('Sign in first', 'bot/unauthorized') ++ const removed = await rt.removeMember(String(body.memberId ?? '')) ++ if (!removed.ok) return fail(removed.error, 'bot/auth') ++ return ok(removed) ++ } default: + // FlyBot plugins' endpoints (src/host/hooks.js). + if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body)) return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } catch (error) { -@@ -331,7 +337,7 @@ export function install(rt) { +@@ -331,20 +369,52 @@ export function install(rt) { } // An exact Fetch route on the shared /api channel; the browser authenticates with its session cookie. @@ -51,8 +99,12 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..db90b9c66c4de6914d7f676e3f5ac5d2 path: ROUTE, methods: ['POST'], requestBody: 'buffered', -@@ -341,10 +347,41 @@ export function install(rt) { - const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal) + fetch: async (request) => { + let body + try { body = await request.json() } catch { body = {} } +- const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal) ++ const sessionToken = request.headers.get('x-flybot-member-session') ++ const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal, sessionToken) return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' } }) }, - }), 'ds-bot: api route') diff --git a/patches/ds-bot/0010-member-auth.patch b/patches/ds-bot/0010-member-auth.patch new file mode 100644 index 000000000..7325a0ca4 --- /dev/null +++ b/patches/ds-bot/0010-member-auth.patch @@ -0,0 +1,478 @@ +FlyBot patch 0010-member-auth on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 2 file(s). + +diff --git a/src/host/member-auth.js b/src/host/member-auth.js +new file mode 100644 +index 0000000000000000000000000000000000000000..58352bbb8a9600bd2d0ba98ee837be84dd1613be +--- /dev/null ++++ b/src/host/member-auth.js +@@ -0,0 +1,187 @@ ++// Member auth foundation (#48): invitations, member sessions, and the whoami that the ++// channel resolves from the x-flybot-member-session header. Members are human owners ++// (store #47 identity schema: id, createdAt, designatedChiefId) — Bot `role` is a job ++// title and grants nothing here. Tokens never leave as hashes; only hashes are stored. ++// This is not team mode: it is the sign-in surface the gates in docs/subsystems/team.md ++// need, and nothing more. ++import { createHash, randomBytes } from 'node:crypto' ++ ++const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 ++ ++function hashToken(token) { ++ return createHash('sha256').update(token).digest('hex') ++} ++ ++function ensureState(state) { ++ if (!state.invitations) state.invitations = {} ++ if (!state.authSessions) state.authSessions = {} ++ return state ++} ++ ++// Last owner: the one member the single-human product still needs. All members are ++// human owners; a Bot is not a member and its job-title role means nothing here. ++function lastOwner(members) { ++ return Object.keys(members).length <= 1 ++} ++ ++function displayNameOf(member, displayName) { ++ return displayName ?? member.displayName ?? member.name ?? member.id ++} ++ ++export function install(rt) { ++ const state = ensureState(rt.state) ++ ++ // One mutation at a time: a check like last_owner or has_bots must not race another ++ // mutation that would invalidate it between the check and the save. ++ let authGate = Promise.resolve() ++ const withAuthLock = fn => { ++ const run = authGate.then(fn, fn) ++ authGate = run.then(() => {}, () => {}) ++ return run ++ } ++ ++ rt.createInvitation = async function createInvitation({ ttlMs = DEFAULT_TTL_MS, createdByMemberId = null } = {}) { ++ return withAuthLock(async () => { ++ const token = randomBytes(32).toString('base64url') ++ const tokenHash = hashToken(token) ++ const id = `inv-${randomBytes(8).toString('hex')}` ++ const now = Date.now() ++ state.invitations[id] = { ++ id, ++ tokenHash, ++ createdByMemberId, ++ createdAt: now, ++ expiresAt: now + ttlMs, ++ redeemedAt: null, ++ redeemedByMemberId: null, ++ revokedAt: null, ++ } ++ await rt.save() ++ return { id, token, expiresAt: now + ttlMs } ++ }) ++ } ++ ++ rt.revokeInvitation = async function revokeInvitation(invitationId) { ++ return withAuthLock(async () => { ++ const inv = state.invitations[invitationId] ++ if (!inv) return { ok: false, error: 'not_found' } ++ if (inv.redeemedAt) return { ok: false, error: 'already_redeemed' } ++ if (inv.revokedAt) return { ok: false, error: 'already_revoked' } ++ inv.revokedAt = Date.now() ++ await rt.save() ++ return { ok: true } ++ }) ++ } ++ ++ rt.redeemInvitation = async function redeemInvitation(token, { displayName = null } = {}) { ++ if (typeof token !== 'string' || !token) return { ok: false, error: 'invalid_token' } ++ const tokenHash = hashToken(token) ++ const now = Date.now() ++ return withAuthLock(async () => { ++ const invitation = Object.values(state.invitations).find(inv => inv.tokenHash === tokenHash) ++ if (!invitation) return { ok: false, error: 'invalid_token' } ++ if (invitation.revokedAt) return { ok: false, error: 'revoked' } ++ if (invitation.redeemedAt) return { ok: false, error: 'already_redeemed' } ++ if (now > invitation.expiresAt) return { ok: false, error: 'expired' } ++ ++ if (!rt.state.members) rt.state.members = {} ++ const members = rt.state.members ++ // Bootstrap: the first redeem binds to the existing single owner (owner-1) when one ++ // is there, so the human who installed FlyBot claims the session without a second ++ // member record. Only a later redeem creates the next owner. ++ let member ++ if (Object.keys(state.authSessions).length === 0) { ++ member = members['owner-1'] ?? null ++ } ++ if (!member) { ++ let n = 1 ++ while (members[`owner-${n}`]) n += 1 ++ const memberId = `owner-${n}` ++ // Persisted member is the identity schema only; displayName is returned in the ++ // session response, not written into the member record. ++ member = { id: memberId, createdAt: now, designatedChiefId: null } ++ members[memberId] = member ++ } ++ const sessionToken = randomBytes(32).toString('base64url') ++ const sessionId = `as-${randomBytes(8).toString('hex')}` ++ state.authSessions[sessionId] = { ++ id: sessionId, ++ tokenHash: hashToken(sessionToken), ++ memberId: member.id, ++ createdAt: now, ++ revokedAt: null, ++ } ++ invitation.redeemedAt = now ++ invitation.redeemedByMemberId = member.id ++ await rt.save() ++ return { ok: true, sessionToken, memberId: member.id, displayName: displayNameOf(member, displayName) } ++ }) ++ } ++ ++ rt.authenticate = async function authenticate(sessionToken) { ++ if (typeof sessionToken !== 'string' || !sessionToken) return null ++ const tokenHash = hashToken(sessionToken) ++ const session = Object.values(state.authSessions).find(s => s.tokenHash === tokenHash) ++ if (!session || session.revokedAt) return null ++ const member = (rt.state.members || {})[session.memberId] ++ if (!member) return null ++ return { memberId: member.id, sessionId: session.id, displayName: displayNameOf(member) } ++ } ++ ++ rt.revokeSession = async function revokeSession(sessionToken) { ++ if (typeof sessionToken !== 'string' || !sessionToken) return false ++ const tokenHash = hashToken(sessionToken) ++ return withAuthLock(async () => { ++ const session = Object.values(state.authSessions).find(s => s.tokenHash === tokenHash) ++ if (!session || session.revokedAt) return false ++ session.revokedAt = Date.now() ++ await rt.save() ++ return true ++ }) ++ } ++ ++ const revokeMemberSessionsNow = async memberId => { ++ const now = Date.now() ++ let count = 0 ++ for (const session of Object.values(state.authSessions)) { ++ if (session.memberId === memberId && !session.revokedAt) { ++ session.revokedAt = now ++ count += 1 ++ } ++ } ++ if (count > 0) await rt.save() ++ return count ++ } ++ ++ rt.revokeMemberSessions = async function revokeMemberSessions(memberId) { ++ return withAuthLock(() => revokeMemberSessionsNow(memberId)) ++ } ++ ++ rt.removeMember = async function removeMember(memberId) { ++ return withAuthLock(async () => { ++ const members = rt.state.members || {} ++ const member = members[memberId] ++ if (!member) return { ok: false, error: 'not_found' } ++ if (lastOwner(members)) return { ok: false, error: 'last_owner' } ++ const bots = Object.values(rt.state.bots || {}) ++ if (bots.some(b => b.ownerId === memberId)) return { ok: false, error: 'has_bots' } ++ await revokeMemberSessionsNow(memberId) ++ delete members[memberId] ++ await rt.save() ++ return { ok: true } ++ }) ++ } ++ ++ // Channel bootstrap: an invitation needs a signed-in member, except on a host that has ++ // never had a session — the first invite is how the installing human signs in. ++ rt.authInviteAllowed = actor => !!actor || Object.keys(state.authSessions).length === 0 ++ rt.authBootstrapOpen = () => Object.keys(state.authSessions).length === 0 ++ ++ rt.whoami = async function whoami(sessionToken) { ++ return rt.authenticate(sessionToken) ++ } ++ ++ return rt ++} ++ ++export { hashToken } +diff --git a/test/member-auth.test.js b/test/member-auth.test.js +new file mode 100644 +index 0000000000000000000000000000000000000000..9efd4107014ccc8a18e60c1ec01b67f5eb2b69e0 +--- /dev/null ++++ b/test/member-auth.test.js +@@ -0,0 +1,277 @@ ++import assert from 'node:assert/strict' ++import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' ++import { tmpdir } from 'node:os' ++import { join } from 'node:path' ++import { test } from 'node:test' ++import { install as installStore } from '../src/host/store.js' ++import { install as installMemberAuth } from '../src/host/member-auth.js' ++ ++// Member auth foundation (#48): invitations, member sessions, and whoami from the ++// x-flybot-member-session header. Members are the #47 identity schema (human owners); ++// Bot role is a job title and means nothing here. Team mode stays disabled. ++ ++function runtime(t, saved) { ++ const dir = mkdtempSync(join(tmpdir(), 'ds-bot-member-auth-')) ++ const statePath = join(dir, 'state.json') ++ if (saved !== undefined) writeFileSync(statePath, typeof saved === 'string' ? saved : JSON.stringify(saved)) ++ const warnings = [] ++ const disposers = [] ++ const ctx = { ++ logger: { warn: (...args) => warnings.push(args) }, ++ effect(execute) { ++ const dispose = execute() ++ if (typeof dispose === 'function') disposers.push(dispose) ++ }, ++ } ++ const rt = { ++ ctx, ++ home: dir, ++ statePath, ++ autoRoomName: () => 'Room', ++ retitleRoom: async () => {}, ++ warn: (...args) => warnings.push(args), ++ } ++ installStore(rt) ++ installMemberAuth(rt) ++ t.after(async () => { ++ await Promise.all(disposers.splice(0).map(cleanup => cleanup())) ++ rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) ++ }) ++ return { rt, statePath } ++} ++ ++const TEAM = { ++ version: 2, ++ revision: 1, ++ mainBotIds: ['m1'], ++ members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' } }, ++ bots: { m1: { id: 'm1', sessionId: 'm1', name: 'Chief', instructions: '', createdAt: 1, ownerId: 'owner-1' } }, ++ rooms: {}, ++ exchanges: [], ++ exchangeTurns: {}, ++ questions: {}, ++} ++ ++test('two members get different identities via invite then redeem', async (t) => { ++ const { rt } = runtime(t, TEAM) ++ await rt.load() ++ const invite1 = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ assert.equal(typeof invite1.token, 'string') ++ assert.ok(invite1.token.length > 20) ++ // First redeem binds to the existing owner-1 (bootstrap), not a second record. ++ const first = await rt.redeemInvitation(invite1.token, { displayName: 'Alice' }) ++ assert.equal(first.ok, true) ++ assert.equal(first.memberId, 'owner-1') ++ assert.equal(first.displayName, 'Alice') ++ assert.equal(typeof first.sessionToken, 'string') ++ assert.notEqual(first.sessionToken, invite1.token) ++ // A second invite is a second human: a new owner member record. ++ const invite2 = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const second = await rt.redeemInvitation(invite2.token, { displayName: 'Bob' }) ++ assert.equal(second.ok, true) ++ assert.notEqual(second.memberId, 'owner-1') ++ assert.equal(second.displayName, 'Bob') ++ assert.equal(second.memberId, 'owner-2') ++ // Persisted members keep the identity shape: no role, no displayName field. ++ const member1 = rt.state.members['owner-1'] ++ const member2 = rt.state.members['owner-2'] ++ assert.deepEqual(Object.keys(member1).sort(), ['createdAt', 'designatedChiefId', 'id']) ++ assert.deepEqual(Object.keys(member2).sort(), ['createdAt', 'designatedChiefId', 'id']) ++ assert.equal(member1.designatedChiefId, 'm1') ++ assert.equal(member2.designatedChiefId, null) ++ // The two sessions authenticate to distinct members. ++ const who1 = await rt.authenticate(first.sessionToken) ++ const who2 = await rt.authenticate(second.sessionToken) ++ assert.equal(who1.memberId, 'owner-1') ++ assert.equal(who2.memberId, 'owner-2') ++ assert.notEqual(who1.sessionId, who2.sessionId) ++}) ++ ++test('authenticate ignores forged strings; only a live session token works', async (t) => { ++ const { rt } = runtime(t, TEAM) ++ await rt.load() ++ const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const session = await rt.redeemInvitation(invite.token) ++ assert.equal((await rt.authenticate('owner-1'))?.memberId, undefined) ++ assert.equal(await rt.authenticate('owner-1'), null) ++ assert.equal(await rt.authenticate(session.memberId), null) ++ assert.equal(await rt.authenticate('owner-1:main'), null) ++ assert.equal(await rt.authenticate(''), null) ++ assert.equal(await rt.authenticate(null), null) ++ assert.equal(await rt.authenticate(undefined), null) ++ assert.equal(await rt.authenticate(123), null) ++ assert.equal(await rt.authenticate({ memberId: 'owner-1' }), null) ++ // A string that merely looks like a member id or session id is not a session token. ++ assert.equal(await rt.authenticate('as-' + '0'.repeat(16)), null) ++ // The real token still works. ++ const who = await rt.authenticate(session.sessionToken) ++ assert.equal(who.memberId, 'owner-1') ++}) ++ ++test('an invitation token cannot be replayed and an expired one fails', async (t) => { ++ const { rt } = runtime(t, TEAM) ++ await rt.load() ++ const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ assert.equal((await rt.redeemInvitation(invite.token)).ok, true) ++ const replay = await rt.redeemInvitation(invite.token) ++ assert.equal(replay.ok, false) ++ assert.equal(replay.error, 'already_redeemed') ++ // Expired: a fresh invite whose ttl already elapsed. ++ const expired = await rt.createInvitation({ createdByMemberId: 'owner-1', ttlMs: -1 }) ++ const result = await rt.redeemInvitation(expired.token) ++ assert.equal(result.ok, false) ++ assert.equal(result.error, 'expired') ++ // An unknown token is also refused. ++ assert.equal((await rt.redeemInvitation('not-a-real-token')).error, 'invalid_token') ++}) ++ ++test('removeMember and revokeSession invalidate authenticate; last owner stays', async (t) => { ++ const { rt } = runtime(t, TEAM) ++ await rt.load() ++ const inviteA = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const a = await rt.redeemInvitation(inviteA.token) ++ const inviteB = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const b = await rt.redeemInvitation(inviteB.token) ++ assert.equal((await rt.authenticate(b.sessionToken)).memberId, 'owner-2') ++ // revokeSession: that one session dies, the other lives. ++ assert.equal(await rt.revokeSession(b.sessionToken), true) ++ assert.equal(await rt.authenticate(b.sessionToken), null) ++ assert.equal((await rt.authenticate(a.sessionToken)).memberId, 'owner-1') ++ // removeMember revokes that member's remaining sessions and drops the record. ++ // owner-1 still owns the Main Bot m1, so the refusal comes first (has_bots). ++ const owned = await rt.removeMember('owner-1') ++ assert.equal(owned.ok, false) ++ assert.equal(owned.error, 'has_bots') ++ // Drop the Bot binding and the same remove succeeds. ++ delete rt.state.bots.m1 ++ const removed = await rt.removeMember('owner-1') ++ assert.equal(removed.ok, true) ++ assert.equal(await rt.authenticate(a.sessionToken), null) ++ assert.equal(rt.state.members['owner-1'], undefined) ++ // With only one member left, the last owner cannot be removed. ++ const last = await rt.removeMember('owner-2') ++ assert.equal(last.ok, false) ++ assert.equal(last.error, 'last_owner') ++ assert.ok(rt.state.members['owner-2']) ++ // A fresh single-owner team also refuses removeMember on owner-1. ++ const fresh = runtime(t, TEAM) ++ await fresh.rt.load() ++ const only = await fresh.rt.removeMember('owner-1') ++ assert.equal(only.ok, false) ++ assert.equal(only.error, 'last_owner') ++}) ++ ++test('tokens are stored only as hashes; plaintext never reaches state.json', async (t) => { ++ const { rt, statePath } = runtime(t, TEAM) ++ await rt.load() ++ const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const session = await rt.redeemInvitation(invite.token, { displayName: 'Alice' }) ++ const saved = readFileSync(statePath, 'utf8') ++ assert.equal(saved.includes(invite.token), false) ++ assert.equal(saved.includes(session.sessionToken), false) ++ assert.ok(saved.includes('"tokenHash"')) ++ assert.ok(saved.includes('"authSessions"')) ++}) ++ ++// Two owners and nothing else: no Bot roster, no designated Chief, so a removeMember ++// race is visible as member count rather than a has_bots refusal. ++const FLAT = { ++ version: 2, ++ revision: 1, ++ mainBotIds: [], ++ members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: null } }, ++ bots: {}, ++ rooms: {}, ++ exchanges: [], ++ exchangeTurns: {}, ++ questions: {}, ++} ++ ++async function twoMembers(rt) { ++ const first = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const a = await rt.redeemInvitation(first.token) ++ const second = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const b = await rt.redeemInvitation(second.token) ++ assert.equal(a.ok, true) ++ assert.equal(b.ok, true) ++ return { a, b } ++} ++ ++test('removeMember refuses while the member still owns a Bot; has_bots after delete', async (t) => { ++ const { rt, statePath } = runtime(t, TEAM) ++ await rt.load() ++ const { b } = await twoMembers(rt) ++ assert.ok(rt.state.members['owner-2']) ++ rt.state.bots['b1'] = { id: 'b1', sessionId: 'b1', name: 'X', instructions: '', createdAt: 2, ownerId: 'owner-2' } ++ await rt.save() ++ const refused = await rt.removeMember('owner-2') ++ assert.equal(refused.ok, false) ++ assert.equal(refused.error, 'has_bots') ++ assert.ok(rt.state.members['owner-2']) ++ // The refusal does not touch the member's live session. ++ assert.equal((await rt.authenticate(b.sessionToken))?.memberId, 'owner-2') ++ // The owner-2 member and its Bot survive a reload, and the refusal is not sticky: ++ // drop the Bot and the same remove succeeds. ++ delete rt.state.bots['b1'] ++ await rt.save() ++ const removed = await rt.removeMember('owner-2') ++ assert.equal(removed.ok, true) ++ assert.equal(rt.state.members['owner-2'], undefined) ++ // Restart: the saved file loads and the ownership invariants still hold. ++ const restart = runtime(t, readFileSync(statePath, 'utf8')) ++ await restart.rt.load() ++ assert.equal(restart.rt.state.members['owner-2'], undefined) ++ assert.ok(restart.rt.state.members['owner-1']) ++ assert.equal(restart.rt.state.bots.m1.ownerId, 'owner-1') ++}) ++ ++test('concurrent removeMember calls cannot both pass the last-owner check', async (t) => { ++ const { rt, statePath } = runtime(t, FLAT) ++ await rt.load() ++ await twoMembers(rt) ++ assert.equal(Object.keys(rt.state.members).length, 2) ++ const results = await Promise.all([rt.removeMember('owner-1'), rt.removeMember('owner-2')]) ++ const okCount = results.filter(r => r.ok).length ++ assert.equal(okCount, 1) ++ const refusal = results.find(r => !r.ok) ++ assert.ok(['last_owner', 'not_found'].includes(refusal.error)) ++ assert.equal(Object.keys(rt.state.members).length, 1) ++ const restart = runtime(t, readFileSync(statePath, 'utf8')) ++ await restart.rt.load() ++ assert.equal(Object.keys(restart.rt.state.members).length, 1) ++}) ++ ++test('a revoked invitation cannot be redeemed or revoked twice', async (t) => { ++ const { rt } = runtime(t, FLAT) ++ await rt.load() ++ const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ const revoked = await rt.revokeInvitation(invite.id) ++ assert.deepEqual(revoked, { ok: true }) ++ assert.equal(await rt.revokeInvitation(invite.id).then(r => r.error), 'already_revoked') ++ assert.equal(await rt.revokeInvitation('inv-nope').then(r => r.error), 'not_found') ++ const redeem = await rt.redeemInvitation(invite.token) ++ assert.equal(redeem.ok, false) ++ assert.equal(redeem.error, 'revoked') ++ // An invitation already redeemed cannot be revoked after the fact. ++ const live = await rt.createInvitation({ createdByMemberId: 'owner-1' }) ++ assert.equal((await rt.redeemInvitation(live.token)).ok, true) ++ assert.equal(await rt.revokeInvitation(live.id).then(r => r.error), 'already_redeemed') ++}) ++ ++test('authInviteAllowed: bootstrap opens the first invite, then a session is required', async (t) => { ++ const { rt } = runtime(t, FLAT) ++ await rt.load() ++ // Never signed in: the channel lets an anonymous caller create the first invitation. ++ assert.equal(Object.keys(rt.state.authSessions).length, 0) ++ assert.equal(rt.authBootstrapOpen(), true) ++ assert.equal(rt.authInviteAllowed(null), true) ++ assert.equal(rt.authInviteAllowed(undefined), true) ++ const invite = await rt.createInvitation({ createdByMemberId: null }) ++ assert.equal(typeof invite.token, 'string') ++ // After the first session exists, bootstrap closes: an actor is required. ++ const session = await rt.redeemInvitation(invite.token) ++ assert.equal(session.ok, true) ++ assert.equal(rt.authBootstrapOpen(), false) ++ assert.equal(rt.authInviteAllowed(null), false) ++ assert.equal(rt.authInviteAllowed({ memberId: 'owner-1', sessionId: session.sessionId, displayName: null }), true) ++}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index b050e3df8..ad9186ac1 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -68,5 +68,12 @@ "0009-identity-schema": [ "test/identity.test.js", "test/roster.test.js" + ], + "0010-member-auth": [ + "index.js", + "src/host/member-auth.js", + "src/host/channel.js", + "src/host/store.js", + "test/member-auth.test.js" ] }