This commit was merged in pull request #730.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import {
|
import {
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
type DiscordIngressEnvelope,
|
type DiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
@@ -28,6 +32,7 @@ import type {
|
|||||||
AbortPayload,
|
AbortPayload,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
|
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -37,6 +42,7 @@ import {
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
@@ -122,6 +128,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(RuntimeProviderService)
|
||||||
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -637,9 +649,130 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
|
@SubscribeMessage('discord:approve')
|
||||||
|
async handleDiscordApproval(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||||
|
const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i);
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'approve',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
||||||
|
);
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const providerId = actionParts[1]!;
|
||||||
|
const sessionId = actionParts[2]!;
|
||||||
|
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
actorId,
|
||||||
|
tenantId,
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
),
|
||||||
|
agentName,
|
||||||
|
});
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: approval !== null,
|
||||||
|
approvalId: approval?.approvalId,
|
||||||
|
expiresAt: approval?.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('discord:stop')
|
||||||
|
async handleDiscordStop(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||||
|
const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i);
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'stop',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
if (!actorId) return;
|
||||||
|
const providerId = actionParts[1]!;
|
||||||
|
const sessionId = actionParts[2]!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||||
|
// provisioned approving-admin identity, never the Discord service account.
|
||||||
|
await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, {
|
||||||
|
actorScope: {
|
||||||
|
userId: actorId,
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
||||||
|
} catch {
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlates the immutable termination target rather than either Discord message.
|
||||||
|
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
||||||
|
*/
|
||||||
|
private discordRuntimeActionCorrelation(
|
||||||
|
instanceId: string,
|
||||||
|
ingress: DiscordIngressPayload,
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): string {
|
||||||
|
const target = [
|
||||||
|
instanceId,
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.conversationId,
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
];
|
||||||
|
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
private resolveDiscordIngress(
|
private resolveDiscordIngress(
|
||||||
client: Socket,
|
client: Socket,
|
||||||
envelope: DiscordIngressEnvelope,
|
envelope: DiscordIngressEnvelope,
|
||||||
|
operation: 'send' | 'approve' | 'stop' = 'send',
|
||||||
): DiscordIngressPayload | null {
|
): DiscordIngressPayload | null {
|
||||||
const payload = verifyDiscordIngressEnvelope(
|
const payload = verifyDiscordIngressEnvelope(
|
||||||
envelope,
|
envelope,
|
||||||
@@ -654,6 +787,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
if (!binding) {
|
||||||
|
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
|||||||
@@ -1,13 +1,126 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createDiscordIngressEnvelope,
|
createDiscordIngressEnvelope,
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
DiscordPlugin,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'DISCORD_SERVICE_TOKEN',
|
||||||
|
'DISCORD_SERVICE_USER_ID',
|
||||||
|
'DISCORD_SERVICE_TENANT_ID',
|
||||||
|
'DISCORD_INTERACTION_BINDINGS',
|
||||||
|
'DISCORD_ALLOWED_GUILD_IDS',
|
||||||
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
] as const;
|
||||||
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||||
|
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||||
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': {
|
||||||
|
role: role === 'admin' ? 'admin' : 'operator',
|
||||||
|
mosaicUserId: 'mosaic-admin-001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
const value = savedEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
savedEnv.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => entries.set(key, value),
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discordGateway(role: 'admin' | 'member'): {
|
||||||
|
gateway: ChatGateway;
|
||||||
|
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
||||||
|
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
||||||
|
} {
|
||||||
|
const authorization = commandAuthorization(role);
|
||||||
|
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
||||||
|
const runtimeRegistry = new RuntimeProviderService(
|
||||||
|
{
|
||||||
|
require: () => ({
|
||||||
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||||
|
terminate: async () => undefined,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ record: async () => undefined } as never,
|
||||||
|
{
|
||||||
|
consume: async (approvalId, action) => {
|
||||||
|
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
||||||
|
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
gateway: new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
authorization,
|
||||||
|
runtimeRegistry,
|
||||||
|
),
|
||||||
|
client: { data: { discordService: true }, emit: vi.fn() },
|
||||||
|
consumedActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingressEnvelope(
|
||||||
|
content: string,
|
||||||
|
messageId: string,
|
||||||
|
overrides: Partial<DiscordIngressPayload> = {},
|
||||||
|
): ReturnType<typeof createDiscordIngressEnvelope> {
|
||||||
|
return createDiscordIngressEnvelope(
|
||||||
|
createPayload({ content, messageId, ...overrides }),
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||||
return {
|
return {
|
||||||
@@ -23,6 +136,42 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
describe('Discord ingress security', () => {
|
||||||
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||||
|
const [binding] = parseDiscordInteractionBindings(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': 'admin' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
||||||
|
).toEqual(binding);
|
||||||
|
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a differently named configured interaction instance without code changes', () => {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'guild-001',
|
||||||
|
'channel-001',
|
||||||
|
'user-001',
|
||||||
|
'send',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(binding?.instanceId).toBe('Nova');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts only the configured Discord service identity', () => {
|
it('accepts only the configured Discord service identity', () => {
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||||
@@ -86,4 +235,155 @@ describe('Discord ingress security', () => {
|
|||||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||||
expect(replayProtector.size).toBe(2);
|
expect(replayProtector.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, consumedActions } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve fleet runtime-1', 'approve-message', {
|
||||||
|
correlationId: 'approval-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(approval.success).toBe(true);
|
||||||
|
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(consumedActions).toEqual([
|
||||||
|
{
|
||||||
|
actorId: 'mosaic-admin-001',
|
||||||
|
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects approval when the binding targets a different runtime agent', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve fleet runtime-1', 'mismatched-agent-approve'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('member');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve fleet runtime-1', 'member-approve'),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/stop fleet runtime-1 forged', 'unpaired-stop'),
|
||||||
|
);
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects replaying a Discord-created termination approval', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve fleet runtime-1', 'replay-approve', {
|
||||||
|
correlationId: 'replay-approval-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
};
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one', {
|
||||||
|
correlationId: 'replay-stop-correlation-one',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two', {
|
||||||
|
correlationId: 'replay-stop-correlation-two',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
||||||
|
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
|
const emitted = vi.fn();
|
||||||
|
const plugin = new DiscordPlugin({
|
||||||
|
token: 'unused',
|
||||||
|
gatewayUrl: 'http://unused',
|
||||||
|
serviceToken: SERVICE_TOKEN,
|
||||||
|
allowedGuildIds: ['guild-001'],
|
||||||
|
allowedChannelIds: ['channel-001'],
|
||||||
|
allowedUserIds: ['user-001'],
|
||||||
|
interactionBindings: [
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const internals = plugin as unknown as {
|
||||||
|
client: { user: { id: string } };
|
||||||
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||||
|
handleDiscordMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
internals.client = { user: { id: 'bot-001' } };
|
||||||
|
internals.socket = { connected: true, emit: emitted };
|
||||||
|
internals.handleDiscordMessage({
|
||||||
|
id: 'thread-message',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'thread-001',
|
||||||
|
author: { id: 'user-001', bot: false },
|
||||||
|
mentions: { has: () => true },
|
||||||
|
content: '<@bot-001> hello from thread',
|
||||||
|
channel: { parentId: 'channel-001' },
|
||||||
|
attachments: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, envelope] = emitted.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
ReturnType<typeof createDiscordIngressEnvelope>,
|
||||||
|
];
|
||||||
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] {
|
|||||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
interactionBindings: parseDiscordInteractionBindings(
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'],
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,129 @@ export interface DiscordPluginConfig {
|
|||||||
allowedGuildIds: readonly string[];
|
allowedGuildIds: readonly string[];
|
||||||
allowedChannelIds: readonly string[];
|
allowedChannelIds: readonly string[];
|
||||||
allowedUserIds: readonly string[];
|
allowedUserIds: readonly string[];
|
||||||
|
/** Provisioned interaction bindings; instance identity is configuration, never code. */
|
||||||
|
interactionBindings?: readonly DiscordInteractionBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
|
||||||
|
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
|
||||||
|
|
||||||
|
/** A provisioned Discord-to-Mosaic identity pairing. */
|
||||||
|
export interface DiscordInteractionUserBinding {
|
||||||
|
role: DiscordInteractionRole;
|
||||||
|
/** Required for privileged approval and stop operations. */
|
||||||
|
mosaicUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
|
||||||
|
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
|
||||||
|
|
||||||
|
export interface DiscordInteractionBinding {
|
||||||
|
instanceId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
/** Pairing roster keyed by Discord user ID. */
|
||||||
|
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationRoles: Readonly<
|
||||||
|
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
|
||||||
|
> = {
|
||||||
|
bind: ['admin'],
|
||||||
|
attach: ['operator', 'admin'],
|
||||||
|
send: ['operator', 'admin'],
|
||||||
|
approve: ['admin'],
|
||||||
|
stop: ['admin'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
|
||||||
|
export function resolveDiscordInteractionBinding(
|
||||||
|
bindings: readonly DiscordInteractionBinding[],
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
operation: DiscordInteractionOperation,
|
||||||
|
): DiscordInteractionBinding | null {
|
||||||
|
const binding = bindings.find(
|
||||||
|
(candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
|
||||||
|
);
|
||||||
|
if (!binding) return null;
|
||||||
|
const pairing = binding.pairedUsers[userId];
|
||||||
|
const role = typeof pairing === 'string' ? pairing : pairing?.role;
|
||||||
|
return role && operationRoles[operation].includes(role) ? binding : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
|
||||||
|
export function resolveDiscordInteractionActorId(
|
||||||
|
binding: DiscordInteractionBinding,
|
||||||
|
discordUserId: string,
|
||||||
|
): string | null {
|
||||||
|
const pairing = binding.pairedUsers[discordUserId];
|
||||||
|
if (typeof pairing === 'string') return null;
|
||||||
|
const mosaicUserId = pairing?.mosaicUserId?.trim();
|
||||||
|
return mosaicUserId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
|
||||||
|
export function parseDiscordInteractionBindings(
|
||||||
|
value: string | undefined,
|
||||||
|
): DiscordInteractionBinding[] {
|
||||||
|
if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
|
||||||
|
}
|
||||||
|
return parsed.map((binding: unknown): DiscordInteractionBinding => {
|
||||||
|
if (typeof binding !== 'object' || binding === null)
|
||||||
|
throw new Error('Invalid Discord interaction binding');
|
||||||
|
const candidate = binding as Partial<DiscordInteractionBinding>;
|
||||||
|
if (
|
||||||
|
!candidate.instanceId ||
|
||||||
|
!candidate.guildId ||
|
||||||
|
!candidate.channelId ||
|
||||||
|
!candidate.pairedUsers ||
|
||||||
|
typeof candidate.pairedUsers !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid Discord interaction binding');
|
||||||
|
}
|
||||||
|
const pairedUsers = Object.fromEntries(
|
||||||
|
Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
|
||||||
|
if (!discordUserId.trim()) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
if (typeof pairing === 'string') {
|
||||||
|
if (!['viewer', 'operator', 'admin'].includes(pairing)) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
return [discordUserId, pairing];
|
||||||
|
}
|
||||||
|
if (typeof pairing !== 'object' || pairing === null) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
|
||||||
|
if (
|
||||||
|
!userBinding.role ||
|
||||||
|
!['viewer', 'operator', 'admin'].includes(userBinding.role) ||
|
||||||
|
(userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
discordUserId,
|
||||||
|
{
|
||||||
|
role: userBinding.role,
|
||||||
|
...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
) as Record<string, DiscordInteractionPairing>;
|
||||||
|
return {
|
||||||
|
instanceId: candidate.instanceId,
|
||||||
|
guildId: candidate.guildId,
|
||||||
|
channelId: candidate.channelId,
|
||||||
|
pairedUsers,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordIngressPayload {
|
export interface DiscordIngressPayload {
|
||||||
@@ -22,6 +145,15 @@ export interface DiscordIngressPayload {
|
|||||||
userId: string;
|
userId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
threadId?: string;
|
||||||
|
attachments?: readonly DiscordAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordIngressEnvelope {
|
export interface DiscordIngressEnvelope {
|
||||||
@@ -44,6 +176,8 @@ function signedPayload(payload: DiscordIngressPayload): string {
|
|||||||
payload.userId,
|
payload.userId,
|
||||||
payload.conversationId,
|
payload.conversationId,
|
||||||
payload.content,
|
payload.content,
|
||||||
|
payload.threadId ?? '',
|
||||||
|
JSON.stringify(payload.attachments ?? []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +348,18 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelId = message.channelId;
|
const channelId = message.channelId;
|
||||||
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
|
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||||
|
const bindingChannelId = parentChannelId ?? channelId;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
this.config.interactionBindings ?? [],
|
||||||
|
message.guildId,
|
||||||
|
bindingChannelId,
|
||||||
|
message.author.id,
|
||||||
|
'send',
|
||||||
|
);
|
||||||
|
if (!binding) return;
|
||||||
|
const conversationId =
|
||||||
|
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
|
||||||
this.channelConversations.set(channelId, conversationId);
|
this.channelConversations.set(channelId, conversationId);
|
||||||
|
|
||||||
const envelope = createDiscordIngressEnvelope(
|
const envelope = createDiscordIngressEnvelope(
|
||||||
@@ -222,22 +367,39 @@ export class DiscordPlugin {
|
|||||||
correlationId: randomUUID(),
|
correlationId: randomUUID(),
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
guildId: message.guildId,
|
guildId: message.guildId,
|
||||||
channelId,
|
channelId: bindingChannelId,
|
||||||
userId: message.author.id,
|
userId: message.author.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
content,
|
content,
|
||||||
|
threadId: parentChannelId ? channelId : undefined,
|
||||||
|
attachments: Array.from(message.attachments.values()).map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name,
|
||||||
|
url: attachment.url,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
this.config.serviceToken,
|
this.config.serviceToken,
|
||||||
);
|
);
|
||||||
this.socket.emit('message', envelope);
|
this.socket.emit(
|
||||||
|
content.startsWith('/approve ')
|
||||||
|
? 'discord:approve'
|
||||||
|
: content.startsWith('/stop ')
|
||||||
|
? 'discord:stop'
|
||||||
|
: 'message',
|
||||||
|
envelope,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAllowedMessage(message: DiscordMessage): boolean {
|
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||||
const guildId = message.guildId;
|
const guildId = message.guildId;
|
||||||
|
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||||
|
// Threads inherit their authorization boundary from their configured parent.
|
||||||
|
const authorizationChannelId = parentChannelId ?? message.channelId;
|
||||||
return (
|
return (
|
||||||
guildId !== null &&
|
guildId !== null &&
|
||||||
includesId(this.config.allowedGuildIds, guildId) &&
|
includesId(this.config.allowedGuildIds, guildId) &&
|
||||||
includesId(this.config.allowedChannelIds, message.channelId) &&
|
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
|
||||||
includesId(this.config.allowedUserIds, message.author.id)
|
includesId(this.config.allowedUserIds, message.author.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user