diff --git a/packages/core/src/devices/relationship-http.ts b/packages/core/src/devices/relationship-http.ts index dca453fac..757b53989 100644 --- a/packages/core/src/devices/relationship-http.ts +++ b/packages/core/src/devices/relationship-http.ts @@ -20,6 +20,10 @@ const command = z.discriminatedUnion('action', [ z.object({ action: z.literal('cancel'), id, confirm: z.literal(true) }), ]); +export const RELATIONSHIP_ACTIONS: readonly string[] = command.options.map( + (option) => option.shape.action.value, +); + export function createRelationshipHttp(control: RelationshipControl) { return async (request: Request): Promise => { const reply = (body: unknown, status = 200) => diff --git a/packages/core/test/device-gateway.test.ts b/packages/core/test/device-gateway.test.ts index fc97c653f..0bca33d0b 100644 --- a/packages/core/test/device-gateway.test.ts +++ b/packages/core/test/device-gateway.test.ts @@ -52,6 +52,7 @@ async function fakeDsh( token: 'launch-token', seen: [] as Record[], upgrades: [] as string[], + mints: 0, }; const cookie = () => `dsh=${String(state.secret)}`; let gateway: ReturnType | undefined; @@ -59,6 +60,7 @@ async function fakeDsh( const url = new URL(req.url ?? '/', 'http://x'); if (url.pathname.startsWith(DEVICE_GATEWAY_PREFIX)) return void gateway!.handle(req, res); if (url.pathname === '/' && url.searchParams.get('token') === state.token) { + state.mints++; res.writeHead(303, { 'set-cookie': `${cookie()}; HttpOnly`, location: '/' }); return res.end(); } @@ -317,6 +319,54 @@ it('does not let a paired device manage or inherit Host relationships', async () expect(host.pairing.admit(`Device ${paired.credential}`).ok).toBe(true); }); +it('never uses the Owner session for a grant that is not bound to the current Owner', async () => { + let ownerHumanId = OWNER_HUMAN_CONTEXT.humanId; + const host = await fakeDsh( + undefined, + undefined, + undefined, + undefined, + undefined, + () => ownerHumanId, + ); + const paired = await pair(host.base, host.pairing.createOffer().offer); + const authorization = `Device ${paired.body.credential}`; + const ticket = (await ( + await fetch(`${host.base}/botharness-device/stream-ticket`, { + method: 'POST', + headers: { authorization }, + }) + ).json()) as { ticket: string }; + ownerHumanId = 'not-the-owner'; + const refused = { code: 'device-revoked' }; + const unary = await fetch(`${host.base}/botharness-device/api/botharness/list`, { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: '{"type":"client-request"}', + }); + expect([unary.status, await unary.json()]).toEqual([401, refused]); + const avatar = await fetch( + `${host.base}/botharness-device/api/botharness/bot-avatar?slug=ada&v=portrait-1`, + { headers: { authorization } }, + ); + expect([avatar.status, await avatar.json()]).toEqual([401, refused]); + const issued = await fetch(`${host.base}/botharness-device/stream-ticket`, { + method: 'POST', + headers: { authorization }, + }); + expect([issued.status, await issued.json()]).toEqual([401, refused]); + expect(await stream(host.port, { authorization })).toEqual({ + status: 401, + body: JSON.stringify(refused), + }); + expect( + await stream(host.port, {}, `/botharness-device/api/remote.mux?ticket=${ticket.ticket}`), + ).toEqual({ status: 401, body: JSON.stringify(refused) }); + expect(host.state.mints).toBe(0); + expect(host.state.seen).toEqual([]); + expect(host.state.upgrades).toEqual([]); +}); + it('attributes Feed acknowledgement to the authenticated device and refuses revoked or missing credentials', async () => { const host = await fakeDsh(); const paired = host.pairing.redeem({ offer: host.pairing.createOffer().offer }); diff --git a/packages/core/test/team-mode.test.ts b/packages/core/test/team-mode.test.ts index d058817b8..3cff35f69 100644 --- a/packages/core/test/team-mode.test.ts +++ b/packages/core/test/team-mode.test.ts @@ -4,6 +4,7 @@ import { createAttachmentStore } from '../src/attachments/store.js'; import { DatabaseSync } from 'node:sqlite'; import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; import { createDevicePairing } from '../src/devices/pairing.js'; +import { RELATIONSHIP_ACTIONS } from '../src/devices/relationship-http.js'; import { createServer, IncomingMessage, request as httpRequest } from 'node:http'; import { Socket } from 'node:net'; import { readFileSync } from 'node:fs'; @@ -1185,7 +1186,10 @@ describe('member HTTP carrier', { timeout: 60_000 }, () => { const http = await memberHttp(f); const cookie = (await http.signIn('bob', 'member-password-1')).headers.get('set-cookie') ?? ''; - for (const action of ['list', 'initiate', 'approve', 'sync', 'enroll', 'disconnect']) { + expect(RELATIONSHIP_ACTIONS).toEqual( + expect.arrayContaining(['initiate', 'approve', 'enroll', 'disconnect']), + ); + for (const action of ['list', ...RELATIONSHIP_ACTIONS]) { const response = await fetch(`${http.origin}${TEAM_PATH}/api/botharness/relationships`, { method: action === 'list' ? 'GET' : 'POST', headers: { cookie, 'content-type': 'application/json' },