diff --git a/assistant/src/__tests__/ingress-routes-http.test.ts b/assistant/src/__tests__/ingress-routes-http.test.ts new file mode 100644 index 00000000000..082768f6672 --- /dev/null +++ b/assistant/src/__tests__/ingress-routes-http.test.ts @@ -0,0 +1,443 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +const testDir = mkdtempSync(join(tmpdir(), 'ingress-routes-http-test-')); + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +import { getSqlite, initializeDb, resetDb } from '../memory/db.js'; +import { + handleBlockMember, + handleCreateInvite, + handleListInvites, + handleListMembers, + handleRedeemInvite, + handleRevokeInvite, + handleRevokeMember, + handleUpsertMember, +} from '../runtime/routes/ingress-routes.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +function resetTables() { + getSqlite().run('DELETE FROM assistant_ingress_members'); + getSqlite().run('DELETE FROM assistant_ingress_invites'); +} + +// --------------------------------------------------------------------------- +// Member routes +// --------------------------------------------------------------------------- + +describe('ingress member HTTP routes', () => { + beforeEach(resetTables); + + test('POST /v1/ingress/members — upsert creates a member', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + externalUserId: 'user-1', + displayName: 'Test User', + policy: 'allow', + status: 'active', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.member).toBeDefined(); + const member = body.member as Record; + expect(member.sourceChannel).toBe('telegram'); + expect(member.externalUserId).toBe('user-1'); + expect(member.displayName).toBe('Test User'); + expect(member.policy).toBe('allow'); + expect(member.status).toBe('active'); + }); + + test('POST /v1/ingress/members — missing sourceChannel returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + externalUserId: 'user-1', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('sourceChannel'); + }); + + test('POST /v1/ingress/members — missing identity returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('externalUserId'); + }); + + test('GET /v1/ingress/members — lists members', async () => { + // Create two members + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-2', status: 'active' }), + })); + + const url = new URL('http://localhost/v1/ingress/members'); + const res = handleListMembers(url); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(Array.isArray(body.members)).toBe(true); + expect((body.members as unknown[]).length).toBe(2); + }); + + test('GET /v1/ingress/members — filters by sourceChannel', async () => { + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'sms', externalUserId: 'user-2', status: 'active' }), + })); + + const url = new URL('http://localhost/v1/ingress/members?sourceChannel=telegram'); + const res = handleListMembers(url); + const body = await res.json() as Record; + + expect((body.members as unknown[]).length).toBe(1); + }); + + test('DELETE /v1/ingress/members/:id — revokes a member', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + const req = new Request('http://localhost/v1/ingress/members/' + created.member.id, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'test revoke' }), + }); + const res = await handleRevokeMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const member = body.member as Record; + expect(member.status).toBe('revoked'); + }); + + test('DELETE /v1/ingress/members/:id — not found returns 404', async () => { + const req = new Request('http://localhost/v1/ingress/members/nonexistent', { + method: 'DELETE', + }); + const res = await handleRevokeMember(req, 'nonexistent'); + const body = await res.json() as Record; + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + }); + + test('POST /v1/ingress/members/:id/block — blocks a member', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + const req = new Request('http://localhost/v1/ingress/members/' + created.member.id + '/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'spam' }), + }); + const res = await handleBlockMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const member = body.member as Record; + expect(member.status).toBe('blocked'); + }); + + test('POST /v1/ingress/members/:id/block — already blocked returns 404', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + // Block first time + await handleBlockMember( + new Request('http://localhost/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }), + created.member.id, + ); + + // Block second time + const req = new Request('http://localhost/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const res = await handleBlockMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Invite routes +// --------------------------------------------------------------------------- + +describe('ingress invite HTTP routes', () => { + beforeEach(resetTables); + + test('POST /v1/ingress/invites — creates an invite', async () => { + const req = new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + note: 'Test invite', + maxUses: 5, + }), + }); + + const res = await handleCreateInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(201); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.sourceChannel).toBe('telegram'); + expect(invite.note).toBe('Test invite'); + expect(invite.maxUses).toBe(5); + expect(invite.status).toBe('active'); + // Raw token should be returned on create + expect(typeof invite.token).toBe('string'); + expect((invite.token as string).length).toBeGreaterThan(0); + }); + + test('POST /v1/ingress/invites — missing sourceChannel returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: 'No channel' }), + }); + + const res = await handleCreateInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('sourceChannel'); + }); + + test('GET /v1/ingress/invites — lists invites', async () => { + // Create two invites + await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + + const url = new URL('http://localhost/v1/ingress/invites'); + const res = handleListInvites(url); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(Array.isArray(body.invites)).toBe(true); + expect((body.invites as unknown[]).length).toBe(2); + }); + + test('DELETE /v1/ingress/invites/:id — revokes an invite', async () => { + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + const created = await createRes.json() as { invite: { id: string } }; + + const res = handleRevokeInvite(created.invite.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.status).toBe('revoked'); + }); + + test('DELETE /v1/ingress/invites/:id — not found returns 404', () => { + const res = handleRevokeInvite('nonexistent-id'); + expect(res.status).toBe(404); + }); + + test('POST /v1/ingress/invites/redeem — redeems an invite', async () => { + // Create an invite first + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', maxUses: 1 }), + })); + const created = await createRes.json() as { invite: { token: string } }; + + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: created.invite.token, + externalUserId: 'redeemer-1', + sourceChannel: 'telegram', + }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.useCount).toBe(1); + // Single-use invite should be fully redeemed + expect(invite.status).toBe('redeemed'); + }); + + test('POST /v1/ingress/invites/redeem — missing token returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ externalUserId: 'redeemer-1' }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('token'); + }); + + test('POST /v1/ingress/invites/redeem — invalid token returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 'invalid-token' }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// IPC backward compatibility — shared logic produces same results +// --------------------------------------------------------------------------- + +describe('ingress service shared logic', () => { + beforeEach(resetTables); + + test('member upsert + list round-trip through shared service', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + externalUserId: 'user-rt', + displayName: 'Round Trip', + policy: 'allow', + status: 'active', + }), + })); + const created = await createRes.json() as { member: { id: string; displayName: string } }; + expect(created.member.displayName).toBe('Round Trip'); + + const listRes = handleListMembers(new URL('http://localhost/v1/ingress/members')); + const listed = await listRes.json() as { members: Array<{ id: string; displayName: string }> }; + expect(listed.members.length).toBe(1); + expect(listed.members[0].id).toBe(created.member.id); + }); + + test('invite create + revoke round-trip through shared service', async () => { + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + const created = await createRes.json() as { invite: { id: string; status: string } }; + expect(created.invite.status).toBe('active'); + + const revokeRes = handleRevokeInvite(created.invite.id); + const revoked = await revokeRes.json() as { invite: { id: string; status: string } }; + expect(revoked.invite.status).toBe('revoked'); + expect(revoked.invite.id).toBe(created.invite.id); + }); +}); diff --git a/assistant/src/daemon/handlers/config-inbox.ts b/assistant/src/daemon/handlers/config-inbox.ts index ed39e1d2fd2..3dbc97d6c13 100644 --- a/assistant/src/daemon/handlers/config-inbox.ts +++ b/assistant/src/daemon/handlers/config-inbox.ts @@ -12,21 +12,17 @@ import { } from '../../memory/channel-guardian-store.js'; import { addMessage, getMessages } from '../../memory/conversation-store.js'; import { getBindingByConversation } from '../../memory/external-conversation-store.js'; -import { - createInvite, - type InviteStatus, - listInvites, - redeemInvite, - revokeInvite, -} from '../../memory/ingress-invite-store.js'; -import { - blockMember, - type IngressMember, - listMembers, - revokeMember, - upsertMember, -} from '../../memory/ingress-member-store.js'; import { deliverChannelReply } from '../../runtime/gateway-client.js'; +import { + blockIngressMember, + createIngressInvite, + listIngressInvites, + listIngressMembers, + redeemIngressInvite, + revokeIngressInvite, + revokeIngressMember, + upsertIngressMember, +} from '../../runtime/ingress-service.js'; import type { AssistantInboxEscalationRequest, IngressInviteRequest, IngressMemberRequest } from '../ipc-protocol.js'; import { defineHandlers, type HandlerContext, log } from './shared.js'; import { renderHistoryContent } from './shared.js'; @@ -39,116 +35,55 @@ export function handleIngressInvite( try { switch (msg.action) { case 'create': { - if (!msg.sourceChannel) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'sourceChannel is required for create' }); - return; - } - const { invite, rawToken } = createInvite({ + const result = createIngressInvite({ sourceChannel: msg.sourceChannel, note: msg.note, maxUses: msg.maxUses, expiresInMs: msg.expiresInMs, }); - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: invite.id, - sourceChannel: invite.sourceChannel, - token: rawToken, - tokenHash: invite.tokenHash, - maxUses: invite.maxUses, - useCount: invite.useCount, - expiresAt: invite.expiresAt, - status: invite.status, - note: invite.note ?? undefined, - createdAt: invite.createdAt, - }, - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } case 'list': { - const invites = listInvites({ + const result = listIngressInvites({ sourceChannel: msg.sourceChannel, - status: msg.status as InviteStatus | undefined, - }); - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invites: invites.map((inv) => ({ - id: inv.id, - sourceChannel: inv.sourceChannel, - tokenHash: inv.tokenHash, - maxUses: inv.maxUses, - useCount: inv.useCount, - expiresAt: inv.expiresAt, - status: inv.status, - note: inv.note ?? undefined, - createdAt: inv.createdAt, - })), + status: msg.status, }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_invite_response', success: true, invites: result.data }); return; } case 'revoke': { - if (!msg.inviteId) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'inviteId is required for revoke' }); - return; - } - const revoked = revokeInvite(msg.inviteId); - if (!revoked) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'Invite not found or already revoked' }); + const result = revokeIngressInvite(msg.inviteId); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: revoked.id, - sourceChannel: revoked.sourceChannel, - tokenHash: revoked.tokenHash, - maxUses: revoked.maxUses, - useCount: revoked.useCount, - expiresAt: revoked.expiresAt, - status: revoked.status, - note: revoked.note ?? undefined, - createdAt: revoked.createdAt, - }, - }); + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } case 'redeem': { - if (!msg.token) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'token is required for redeem' }); - return; - } - const result = redeemInvite({ - rawToken: msg.token, + const result = redeemIngressInvite({ + token: msg.token, externalUserId: msg.externalUserId, externalChatId: msg.externalChatId, sourceChannel: msg.sourceChannel, }); - if ('error' in result) { + if (!result.ok) { ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: result.invite.id, - sourceChannel: result.invite.sourceChannel, - tokenHash: result.invite.tokenHash, - maxUses: result.invite.maxUses, - useCount: result.invite.useCount, - expiresAt: result.invite.expiresAt, - status: result.invite.status, - note: result.invite.note ?? undefined, - createdAt: result.invite.createdAt, - }, - }); + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } @@ -163,21 +98,6 @@ export function handleIngressInvite( } } -function memberToResponse(m: IngressMember) { - return { - id: m.id, - sourceChannel: m.sourceChannel, - externalUserId: m.externalUserId ?? undefined, - externalChatId: m.externalChatId ?? undefined, - displayName: m.displayName ?? undefined, - username: m.username ?? undefined, - status: m.status, - policy: m.policy, - lastSeenAt: m.lastSeenAt ?? undefined, - createdAt: m.createdAt, - }; -} - export function handleIngressMember( msg: IngressMemberRequest, socket: net.Socket, @@ -186,30 +106,22 @@ export function handleIngressMember( try { switch (msg.action) { case 'list': { - const members = listMembers({ + const result = listIngressMembers({ assistantId: msg.assistantId, sourceChannel: msg.sourceChannel, status: msg.status, policy: msg.policy, }); - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - members: members.map(memberToResponse), - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_member_response', success: true, members: result.data }); return; } case 'upsert': { - if (!msg.sourceChannel) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'sourceChannel is required for upsert' }); - return; - } - if (!msg.externalUserId && !msg.externalChatId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'At least one of externalUserId or externalChatId is required for upsert' }); - return; - } - const member = upsertMember({ + const result = upsertIngressMember({ assistantId: msg.assistantId, sourceChannel: msg.sourceChannel, externalUserId: msg.externalUserId, @@ -219,47 +131,31 @@ export function handleIngressMember( policy: msg.policy, status: msg.status, }); - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(member), - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } case 'revoke': { - if (!msg.memberId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'memberId is required for revoke' }); + const result = revokeIngressMember(msg.memberId, msg.reason); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); return; } - const revoked = revokeMember(msg.memberId, msg.reason); - if (!revoked) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'Member not found or cannot be revoked' }); - return; - } - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(revoked), - }); + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } case 'block': { - if (!msg.memberId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'memberId is required for block' }); - return; - } - const blocked = blockMember(msg.memberId, msg.reason); - if (!blocked) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'Member not found or already blocked' }); + const result = blockIngressMember(msg.memberId, msg.reason); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(blocked), - }); + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index ac1244349bd..c18b3f053e8 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -124,6 +124,16 @@ import { handlePairingRequest, handlePairingStatus, } from './routes/pairing-routes.js'; +import { + handleBlockMember, + handleCreateInvite, + handleListInvites, + handleListMembers, + handleRedeemInvite, + handleRevokeInvite, + handleRevokeMember, + handleUpsertMember, +} from './routes/ingress-routes.js'; import { handleAddSecret } from './routes/secret-routes.js'; // Re-export for consumers @@ -627,6 +637,21 @@ export class RuntimeHttpServer { const contactMatch = endpoint.match(/^contacts\/([^/]+)$/); if (contactMatch && req.method === 'GET') return handleGetContact(contactMatch[1]); + // Ingress members + if (endpoint === 'ingress/members' && req.method === 'GET') return handleListMembers(url); + if (endpoint === 'ingress/members' && req.method === 'POST') return await handleUpsertMember(req); + const memberBlockMatch = endpoint.match(/^ingress\/members\/([^/]+)\/block$/); + if (memberBlockMatch && req.method === 'POST') return await handleBlockMember(req, memberBlockMatch[1]); + const memberMatch = endpoint.match(/^ingress\/members\/([^/]+)$/); + if (memberMatch && req.method === 'DELETE') return await handleRevokeMember(req, memberMatch[1]); + + // Ingress invites + if (endpoint === 'ingress/invites' && req.method === 'GET') return handleListInvites(url); + if (endpoint === 'ingress/invites' && req.method === 'POST') return await handleCreateInvite(req); + if (endpoint === 'ingress/invites/redeem' && req.method === 'POST') return await handleRedeemInvite(req); + const inviteMatch = endpoint.match(/^ingress\/invites\/([^/]+)$/); + if (inviteMatch && req.method === 'DELETE') return handleRevokeInvite(inviteMatch[1]); + // Integrations — Telegram config if (endpoint === 'integrations/telegram/config' && req.method === 'GET') return handleGetTelegramConfig(); if (endpoint === 'integrations/telegram/config' && req.method === 'POST') return await handleSetTelegramConfig(req); diff --git a/assistant/src/runtime/ingress-service.ts b/assistant/src/runtime/ingress-service.ts new file mode 100644 index 00000000000..77b5cd870ff --- /dev/null +++ b/assistant/src/runtime/ingress-service.ts @@ -0,0 +1,237 @@ +/** + * Shared business logic for ingress member and invite management. + * + * Extracted from the IPC handlers in daemon/handlers/config-inbox.ts so that + * both the HTTP routes and the IPC handlers call the same logic. + */ + +import { + createInvite, + type IngressInvite, + type InviteStatus, + listInvites, + redeemInvite, + revokeInvite, +} from '../memory/ingress-invite-store.js'; +import { + blockMember, + type IngressMember, + listMembers, + type MemberPolicy, + type MemberStatus, + revokeMember, + upsertMember, +} from '../memory/ingress-member-store.js'; + +// --------------------------------------------------------------------------- +// Response shapes — used by both HTTP routes and IPC handlers +// --------------------------------------------------------------------------- + +export interface InviteResponseData { + id: string; + sourceChannel: string; + token?: string; + tokenHash: string; + maxUses: number; + useCount: number; + expiresAt: number | null; + status: string; + note?: string; + createdAt: number; +} + +export interface MemberResponseData { + id: string; + sourceChannel: string; + externalUserId?: string; + externalChatId?: string; + displayName?: string; + username?: string; + status: string; + policy: string; + lastSeenAt?: number; + createdAt: number; +} + +// --------------------------------------------------------------------------- +// Mappers +// --------------------------------------------------------------------------- + +function inviteToResponse(inv: IngressInvite, rawToken?: string): InviteResponseData { + return { + id: inv.id, + sourceChannel: inv.sourceChannel, + ...(rawToken ? { token: rawToken } : {}), + tokenHash: inv.tokenHash, + maxUses: inv.maxUses, + useCount: inv.useCount, + expiresAt: inv.expiresAt, + status: inv.status, + note: inv.note ?? undefined, + createdAt: inv.createdAt, + }; +} + +export function memberToResponse(m: IngressMember): MemberResponseData { + return { + id: m.id, + sourceChannel: m.sourceChannel, + externalUserId: m.externalUserId ?? undefined, + externalChatId: m.externalChatId ?? undefined, + displayName: m.displayName ?? undefined, + username: m.username ?? undefined, + status: m.status, + policy: m.policy, + lastSeenAt: m.lastSeenAt ?? undefined, + createdAt: m.createdAt, + }; +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +export type IngressResult = + | { ok: true; data: T } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Invite operations +// --------------------------------------------------------------------------- + +export function createIngressInvite(params: { + sourceChannel?: string; + note?: string; + maxUses?: number; + expiresInMs?: number; +}): IngressResult { + if (!params.sourceChannel) { + return { ok: false, error: 'sourceChannel is required for create' }; + } + const { invite, rawToken } = createInvite({ + sourceChannel: params.sourceChannel, + note: params.note, + maxUses: params.maxUses, + expiresInMs: params.expiresInMs, + }); + return { ok: true, data: inviteToResponse(invite, rawToken) }; +} + +export function listIngressInvites(params: { + sourceChannel?: string; + status?: string; +}): IngressResult { + const invites = listInvites({ + sourceChannel: params.sourceChannel, + status: params.status as InviteStatus | undefined, + }); + return { + ok: true, + data: invites.map((inv) => inviteToResponse(inv)), + }; +} + +export function revokeIngressInvite(inviteId?: string): IngressResult { + if (!inviteId) { + return { ok: false, error: 'inviteId is required for revoke' }; + } + const revoked = revokeInvite(inviteId); + if (!revoked) { + return { ok: false, error: 'Invite not found or already revoked' }; + } + return { ok: true, data: inviteToResponse(revoked) }; +} + +export function redeemIngressInvite(params: { + token?: string; + externalUserId?: string; + externalChatId?: string; + sourceChannel?: string; +}): IngressResult { + if (!params.token) { + return { ok: false, error: 'token is required for redeem' }; + } + const result = redeemInvite({ + rawToken: params.token, + externalUserId: params.externalUserId, + externalChatId: params.externalChatId, + sourceChannel: params.sourceChannel, + }); + if ('error' in result) { + return { ok: false, error: result.error }; + } + return { ok: true, data: inviteToResponse(result.invite) }; +} + +// --------------------------------------------------------------------------- +// Member operations +// --------------------------------------------------------------------------- + +export function listIngressMembers(params: { + assistantId?: string; + sourceChannel?: string; + status?: string; + policy?: string; +}): IngressResult { + const members = listMembers({ + assistantId: params.assistantId, + sourceChannel: params.sourceChannel, + status: params.status as MemberStatus | undefined, + policy: params.policy as MemberPolicy | undefined, + }); + return { + ok: true, + data: members.map(memberToResponse), + }; +} + +export function upsertIngressMember(params: { + sourceChannel?: string; + externalUserId?: string; + externalChatId?: string; + displayName?: string; + username?: string; + policy?: string; + status?: string; + assistantId?: string; +}): IngressResult { + if (!params.sourceChannel) { + return { ok: false, error: 'sourceChannel is required for upsert' }; + } + if (!params.externalUserId && !params.externalChatId) { + return { ok: false, error: 'At least one of externalUserId or externalChatId is required for upsert' }; + } + const member = upsertMember({ + assistantId: params.assistantId, + sourceChannel: params.sourceChannel, + externalUserId: params.externalUserId, + externalChatId: params.externalChatId, + displayName: params.displayName, + username: params.username, + policy: params.policy as MemberPolicy | undefined, + status: params.status as MemberStatus | undefined, + }); + return { ok: true, data: memberToResponse(member) }; +} + +export function revokeIngressMember(memberId?: string, reason?: string): IngressResult { + if (!memberId) { + return { ok: false, error: 'memberId is required for revoke' }; + } + const revoked = revokeMember(memberId, reason); + if (!revoked) { + return { ok: false, error: 'Member not found or cannot be revoked' }; + } + return { ok: true, data: memberToResponse(revoked) }; +} + +export function blockIngressMember(memberId?: string, reason?: string): IngressResult { + if (!memberId) { + return { ok: false, error: 'memberId is required for block' }; + } + const blocked = blockMember(memberId, reason); + if (!blocked) { + return { ok: false, error: 'Member not found or already blocked' }; + } + return { ok: true, data: memberToResponse(blocked) }; +} diff --git a/assistant/src/runtime/routes/ingress-routes.ts b/assistant/src/runtime/routes/ingress-routes.ts new file mode 100644 index 00000000000..214c086065b --- /dev/null +++ b/assistant/src/runtime/routes/ingress-routes.ts @@ -0,0 +1,174 @@ +/** + * Route handlers for ingress member and invite management. + * + * Members: + * GET /v1/ingress/members — list members + * POST /v1/ingress/members — upsert a member + * DELETE /v1/ingress/members/:id — revoke a member + * POST /v1/ingress/members/:id/block — block a member + * + * Invites: + * GET /v1/ingress/invites — list invites + * POST /v1/ingress/invites — create an invite + * DELETE /v1/ingress/invites/:id — revoke an invite + * POST /v1/ingress/invites/redeem — redeem an invite + */ + +import { + blockIngressMember, + createIngressInvite, + listIngressInvites, + listIngressMembers, + redeemIngressInvite, + revokeIngressInvite, + revokeIngressMember, + upsertIngressMember, +} from '../ingress-service.js'; + +// --------------------------------------------------------------------------- +// Members +// --------------------------------------------------------------------------- + +/** + * GET /v1/ingress/members?assistantId=&sourceChannel=&status=&policy= + */ +export function handleListMembers(url: URL): Response { + const result = listIngressMembers({ + assistantId: url.searchParams.get('assistantId') ?? undefined, + sourceChannel: url.searchParams.get('sourceChannel') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + policy: url.searchParams.get('policy') ?? undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, members: result.data }); +} + +/** + * POST /v1/ingress/members + */ +export async function handleUpsertMember(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = upsertIngressMember({ + sourceChannel: body.sourceChannel as string | undefined, + externalUserId: body.externalUserId as string | undefined, + externalChatId: body.externalChatId as string | undefined, + displayName: body.displayName as string | undefined, + username: body.username as string | undefined, + policy: body.policy as string | undefined, + status: body.status as string | undefined, + assistantId: body.assistantId as string | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, member: result.data }); +} + +/** + * DELETE /v1/ingress/members/:id + */ +export async function handleRevokeMember(req: Request, memberId: string): Promise { + let reason: string | undefined; + try { + const body = (await req.json()) as Record; + reason = body.reason as string | undefined; + } catch { + // DELETE may have no body + } + + const result = revokeIngressMember(memberId, reason); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, member: result.data }); +} + +/** + * POST /v1/ingress/members/:id/block + */ +export async function handleBlockMember(req: Request, memberId: string): Promise { + const body = (await req.json()) as Record; + const reason = body.reason as string | undefined; + + const result = blockIngressMember(memberId, reason); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, member: result.data }); +} + +// --------------------------------------------------------------------------- +// Invites +// --------------------------------------------------------------------------- + +/** + * GET /v1/ingress/invites?sourceChannel=&status= + */ +export function handleListInvites(url: URL): Response { + const result = listIngressInvites({ + sourceChannel: url.searchParams.get('sourceChannel') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invites: result.data }); +} + +/** + * POST /v1/ingress/invites + */ +export async function handleCreateInvite(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = createIngressInvite({ + sourceChannel: body.sourceChannel as string | undefined, + note: body.note as string | undefined, + maxUses: body.maxUses as number | undefined, + expiresInMs: body.expiresInMs as number | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invite: result.data }, { status: 201 }); +} + +/** + * DELETE /v1/ingress/invites/:id + */ +export function handleRevokeInvite(inviteId: string): Response { + const result = revokeIngressInvite(inviteId); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, invite: result.data }); +} + +/** + * POST /v1/ingress/invites/redeem + */ +export async function handleRedeemInvite(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = redeemIngressInvite({ + token: body.token as string | undefined, + externalUserId: body.externalUserId as string | undefined, + externalChatId: body.externalChatId as string | undefined, + sourceChannel: body.sourceChannel as string | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invite: result.data }); +}