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
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 36 additions & 3 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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' } })
},
}
Expand Down
187 changes: 187 additions & 0 deletions core/src/host/member-auth.js
Original file line number Diff line number Diff line change
@@ -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 }
13 changes: 11 additions & 2 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,any>, bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, 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<string,any>, bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number, invitations:Record<string,any>, authSessions:Record<string,any>}} */
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
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading