diff --git a/apps/appservice/src/__tests__/server.test.ts b/apps/appservice/src/__tests__/server.test.ts index 6a2658a..b950025 100644 --- a/apps/appservice/src/__tests__/server.test.ts +++ b/apps/appservice/src/__tests__/server.test.ts @@ -137,6 +137,97 @@ describe('AppserviceDaemon routing', () => { expect(res.status).toBe(405); }); + it('provisions a room as the AS sender with space linking', async () => { + const calls: Array<{ url: URL; body: unknown }> = []; + const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { + const url = new URL(String(input)); + calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (url.pathname.endsWith('/createRoom')) + return jsonResponse(200, { room_id: '!new:hs.example' }); + return jsonResponse(200, {}); + }); + const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); + const res = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/provision/rooms', + authorizationHeader: 'Bearer bridge-secret', + body: { + name: 'proj-x', + alias: 'mosaic-proj-x', + invite: ['@jason.woltje:hs.example'], + space_id: '!space:hs.example', + }, + }), + ); + expect(res.status).toBe(200); + expect(res.body.room_id).toBe('!new:hs.example'); + expect(res.body.space_linked).toBe(true); + const create = calls.find((c) => c.url.pathname.endsWith('/createRoom')); + expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example'); + const body = create!.body as Record; + expect(body.room_alias_name).toBe('mosaic-proj-x'); + expect((body.power_level_content_override as Record).users).toEqual({ + '@mosaic-as:hs.example': 100, + }); + expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true); + expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true); + }); + + it('space-link failure still returns the room id (no orphan)', async () => { + const fetchMock = vi.fn(async (input: URL | string) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/createRoom')) + return jsonResponse(200, { room_id: '!new:hs.example' }); + if (url.pathname.includes('/state/m.space.child/')) + return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' }); + return jsonResponse(200, {}); + }); + const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); + const res = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/provision/rooms', + authorizationHeader: 'Bearer bridge-secret', + body: { name: 'proj-x', space_id: '!space:hs.example' }, + }), + ); + expect(res.status).toBe(200); + expect(res.body.room_id).toBe('!new:hs.example'); + expect(res.body.space_linked).toBe(false); + expect(String(res.body.space_error)).toContain('403'); + }); + + it('invite list cap enforced', async () => { + const { daemon } = makeDaemon(); + const res = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/provision/rooms', + authorizationHeader: 'Bearer bridge-secret', + body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) }, + }), + ); + expect(res.status).toBe(400); + }); + + it('provision rejects bad payloads and requires auth', async () => { + const { daemon } = makeDaemon(); + const noAuth = await daemon.handle( + request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }), + ); + expect(noAuth.status).toBe(403); + const bad = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/provision/rooms', + authorizationHeader: 'Bearer bridge-secret', + body: { name: '', alias: 'BAD ALIAS' }, + }), + ); + expect(bad.status).toBe(400); + }); + it('empty bridge token list denies everything', async () => { const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {}); const res = await daemon.handle( diff --git a/apps/appservice/src/server.ts b/apps/appservice/src/server.ts index cff0e6c..4da5417 100644 --- a/apps/appservice/src/server.ts +++ b/apps/appservice/src/server.ts @@ -5,6 +5,7 @@ import { TransactionHandler, validateBridgeMessage, validateBridgeTyping, + validateProvisionRoom, } from '@mosaicstack/appservice'; import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice'; @@ -109,6 +110,27 @@ export class AppserviceDaemon { await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing); return { status: 200, body: {} }; } + if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') { + validateProvisionRoom(req.body); + const result = await this.intent.createRoom({ + name: req.body.name, + alias: req.body.alias, + topic: req.body.topic, + invite: req.body.invite, + spaceId: req.body.space_id, + }); + this.log( + `provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`, + ); + return { + status: 200, + body: { + room_id: result.roomId, + space_linked: result.spaceLinked, + ...(result.spaceError ? { space_error: result.spaceError } : {}), + }, + }; + } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(`bridge error ${req.method} ${req.path}: ${message}`); diff --git a/packages/appservice/src/bridge.dto.ts b/packages/appservice/src/bridge.dto.ts index 52d14e6..7085e7f 100644 --- a/packages/appservice/src/bridge.dto.ts +++ b/packages/appservice/src/bridge.dto.ts @@ -50,3 +50,34 @@ export function validateBridgeTyping(input: unknown): asserts input is BridgeTyp assertAgentSlug(o.agent); if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean'); } + +export interface ProvisionRoomDto { + name: string; + alias?: string; + topic?: string; + invite?: string[]; + space_id?: string; +} + +export function validateProvisionRoom(input: unknown): asserts input is ProvisionRoomDto { + const o = input as Partial | null | undefined; + if (!o || typeof o !== 'object') throw new Error('payload must be an object'); + if (typeof o.name !== 'string' || o.name.length === 0) throw new Error('name is required'); + if (o.alias !== undefined && (!/^[a-z0-9_.-]+$/.test(o.alias) || o.alias.length > 200)) { + throw new Error('alias must match [a-z0-9_.-]+ (max 200 chars)'); + } + if (o.invite !== undefined) { + if ( + !Array.isArray(o.invite) || + o.invite.some((u) => typeof u !== 'string' || !u.startsWith('@')) + ) { + throw new Error('invite must be a list of Matrix user ids'); + } + if (o.invite.length > 50) { + throw new Error('invite list exceeds maximum of 50'); + } + } + if (o.space_id !== undefined && (typeof o.space_id !== 'string' || !o.space_id.startsWith('!'))) { + throw new Error('space_id must be a Matrix room id'); + } +} diff --git a/packages/appservice/src/index.ts b/packages/appservice/src/index.ts index 3499aa7..37c7661 100644 --- a/packages/appservice/src/index.ts +++ b/packages/appservice/src/index.ts @@ -4,8 +4,12 @@ export { TransactionHandler } from './transactions.js'; export type { TransactionHandlerOptions } from './transactions.js'; export { buildRegistration, registrationToYaml } from './registration.js'; export type { RegistrationOptions } from './registration.js'; -export { validateBridgeMessage, validateBridgeTyping } from './bridge.dto.js'; -export type { BridgeMessageDto, BridgeTypingDto } from './bridge.dto.js'; +export { + validateBridgeMessage, + validateBridgeTyping, + validateProvisionRoom, +} from './bridge.dto.js'; +export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js'; export type { AppserviceConfig, EventHandler, diff --git a/packages/appservice/src/intent.ts b/packages/appservice/src/intent.ts index 024e17f..050b3f4 100644 --- a/packages/appservice/src/intent.ts +++ b/packages/appservice/src/intent.ts @@ -172,6 +172,58 @@ export class AppserviceIntent { }); } + /** Create a room as the AS sender: agents get PL 50 by namespace via the + * sender (PL 100); humans invited at default PL. Optionally link into a + * space (m.space.child + m.space.parent). Returns the room id. */ + async createRoom(options: { + name: string; + alias?: string; + topic?: string; + invite?: string[]; + spaceId?: string; + }): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> { + const body: Record = { + name: options.name, + preset: 'private_chat', + invite: options.invite ?? [], + power_level_content_override: { + users: { [this.senderUserId]: 100 }, + // state_default 50 stays; the AS sender can grant agents as needed. + }, + }; + if (options.alias) body.room_alias_name = options.alias; + if (options.topic) body.topic = options.topic; + const res = await this.request('POST', '/_matrix/client/v3/createRoom', { + userId: this.senderUserId, + body, + }); + const roomId = res.room_id; + if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id'); + if (!options.spaceId) { + return { roomId, spaceLinked: false }; + } + // Space-link failures must NOT throw: the room already exists, and an + // exception would hide the room_id (orphaned room, no recovery path). + const encodedSpaceId = encodeURIComponent(options.spaceId); + const encodedRoomId = encodeURIComponent(roomId); + try { + await this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`, + { userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } }, + ); + await this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`, + { userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { roomId, spaceLinked: false, spaceError: message }; + } + return { roomId, spaceLinked: true }; + } + /** Set display name for an agent's virtual user. */ async setDisplayName(agent: string, displayName: string): Promise { const userId = await this.ensureRegistered(agent);