Add capabilities negotiation flow

Changes:
- Server sends 'request_capabilities' after handshake
- Client responds with 'capabilities_response' containing supported features
- Server computes intersection of account + client capabilities
- Effective capabilities used for message validation
- Client metadata stored per-connection
- Updated tests for capabilities negotiation
- Updated CLIENT_SPEC.md documentation

Protocol messages added:
- request_capabilities (server → client)
- capabilities_response (client → server)

ConnectedClient fields added:
- clientCapabilities?: ChannelCapabilities
- clientMetadata?: Record<string, unknown>
- capabilitiesReady: boolean

New methods:
- getEffectiveCapabilities(client) - returns intersection
- clientSupportsCapability(client, capability) - check support
This commit is contained in:
root
2026-03-31 21:41:26 +02:00
parent 8dd07e791c
commit 1421f9d645
4 changed files with 355 additions and 21 deletions

View File

@@ -188,18 +188,22 @@ describe("WebSocketServerManager", () => {
});
describe("addClient", () => {
it("adds a client and sends handshake", () => {
it("adds a client and sends handshake + capabilities request", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
expect(sentMessages.length).toBe(1);
expect(sentMessages.length).toBe(2); // handshake + request_capabilities
const handshake = JSON.parse(sentMessages[0]);
expect(handshake.type).toBe("handshake");
expect(handshake.accountId).toBe("test-account");
expect(handshake.capabilities.text).toBe(true);
expect(handshake.metadata.serverName).toBe("Test Server");
const capsRequest = JSON.parse(sentMessages[1]);
expect(capsRequest.type).toBe("request_capabilities");
});
it("tracks connection count", () => {
@@ -215,6 +219,121 @@ describe("WebSocketServerManager", () => {
});
});
describe("capabilities negotiation", () => {
it("receives and processes capabilities response", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Simulate client capabilities response
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: true,
typing: true
},
metadata: { clientName: "Test Client" }
})));
const connections = manager.getConnections();
expect(connections[0].capabilitiesReady).toBe(true);
expect(connections[0].clientCapabilities?.images).toBe(true);
expect(connections[0].clientCapabilities?.files).toBe(false);
expect(connections[0].clientMetadata?.clientName).toBe("Test Client");
});
it("uses intersection of account and client capabilities", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
// Account allows everything
const fullCapAccount = {
...mockAccount,
capabilities: {
text: true,
images: true,
files: true,
voice: true,
reactions: true,
threads: true,
typing: true
}
};
manager.addClient(mockWs as any, fullCapAccount);
// Client only supports text and images
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: false,
typing: false
}
})));
const effective = manager.getEffectiveCapabilities(manager.getConnections()[0]);
expect(effective.text).toBe(true);
expect(effective.images).toBe(true);
expect(effective.files).toBe(false); // Client doesn't support
expect(effective.voice).toBe(false); // Client doesn't support
});
it("falls back to account capabilities if client doesn't report", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Don't send capabilities_response
const effective = manager.getEffectiveCapabilities(manager.getConnections()[0]);
// Should use account capabilities
expect(effective.text).toBe(true);
expect(effective.images).toBe(false); // Account default
});
it("clientSupportsCapability checks effective capabilities", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Before capabilities response - uses account defaults
expect(manager.clientSupportsCapability(manager.getConnections()[0], "text")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "images")).toBe(false);
// Send capabilities response
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: false,
typing: false
}
})));
// After response - uses intersection
expect(manager.clientSupportsCapability(manager.getConnections()[0], "text")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "images")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "files")).toBe(false);
});
});
describe("sendText", () => {
it("sends text message to all connections for account", async () => {
const sentMessages: string[] = [];

View File

@@ -61,18 +61,20 @@ async function handleInboundMessage(
return;
}
// Validate capabilities
// Validate capabilities - check both account and client capabilities
const content = message.content;
if (content.type === "image" && !account.capabilities.images) {
api.logger?.warn(`Account ${account.accountId} tried to send image but capability is disabled`);
// Check if CLIENT supports this capability (client-reported)
if (content.type === "image" && !wsManager.clientSupportsCapability(client, "images")) {
api.logger?.warn(`Client ${client.account.accountId} tried to send image but capability is not supported by client`);
return;
}
if (content.type === "file" && !account.capabilities.files) {
api.logger?.warn(`Account ${account.accountId} tried to send file but capability is disabled`);
if (content.type === "file" && !wsManager.clientSupportsCapability(client, "files")) {
api.logger?.warn(`Client ${client.account.accountId} tried to send file but capability is not supported by client`);
return;
}
if (content.type === "voice" && !account.capabilities.voice) {
api.logger?.warn(`Account ${account.accountId} tried to send voice but capability is disabled`);
if (content.type === "voice" && !wsManager.clientSupportsCapability(client, "voice")) {
api.logger?.warn(`Client ${client.account.accountId} tried to send voice but capability is not supported by client`);
return;
}

View File

@@ -45,12 +45,24 @@ export type PairingCodeMessage = {
message: string;
};
export type CapabilitiesRequest = {
type: "request_capabilities";
};
export type CapabilitiesResponse = {
type: "capabilities_response";
capabilities: ChannelCapabilities;
metadata?: Record<string, unknown>;
};
export type WebSocketMessage =
| InboundMessage
| OutboundMessage
| HandshakeMessage
| TypingIndicator
| PairingCodeMessage;
| PairingCodeMessage
| CapabilitiesRequest
| CapabilitiesResponse;
export type MessageContent =
| { type: "text"; text: string }
@@ -65,6 +77,9 @@ export type ConnectedClient = {
userId?: string;
connectedAt: Date;
lastPingAt: Date;
clientCapabilities?: ChannelCapabilities; // Capabilities reported by client
clientMetadata?: Record<string, unknown>; // Metadata reported by client
capabilitiesReady: boolean; // Whether client has reported capabilities
};
export type MessageHandler = (message: InboundMessage, client: ConnectedClient) => Promise<void> | void;
@@ -89,15 +104,28 @@ export class WebSocketServerManager {
account,
connectedAt: new Date(),
lastPingAt: new Date(),
clientCapabilities: undefined,
clientMetadata: undefined,
capabilitiesReady: false,
};
this.clients.set(ws, client);
// Set up message handler
ws.on("message", async (data) => {
try {
const message = JSON.parse(data.toString()) as InboundMessage;
if (this.messageHandler) {
await this.messageHandler(message, client);
const message = JSON.parse(data.toString()) as WebSocketMessage;
// Handle capabilities response from client
if (message.type === "capabilities_response") {
this.handleCapabilitiesResponse(client, message);
return;
}
// Handle regular inbound messages
if (message.type === "message") {
if (this.messageHandler) {
await this.messageHandler(message as InboundMessage, client);
}
}
} catch (err) {
console.error("Failed to parse WebSocket message:", err);
@@ -121,9 +149,52 @@ export class WebSocketServerManager {
// Send handshake
this.sendHandshake(client);
// Request client capabilities after handshake
this.requestCapabilities(client);
return client;
}
/**
* Request capabilities from client
*/
private requestCapabilities(client: ConnectedClient): void {
const request: CapabilitiesRequest = {
type: "request_capabilities",
};
this.sendToClient(client.ws, request);
}
/**
* Handle capabilities response from client
*/
private handleCapabilitiesResponse(
client: ConnectedClient,
response: CapabilitiesResponse
): void {
// Merge client capabilities with account capabilities (intersection)
const accountCaps = client.account.capabilities;
const clientCaps = response.capabilities;
client.clientCapabilities = {
text: accountCaps.text && clientCaps.text,
images: accountCaps.images && clientCaps.images,
files: accountCaps.files && clientCaps.files,
voice: accountCaps.voice && clientCaps.voice,
reactions: accountCaps.reactions && clientCaps.reactions,
threads: accountCaps.threads && clientCaps.threads,
typing: accountCaps.typing && clientCaps.typing,
};
client.clientMetadata = response.metadata;
client.capabilitiesReady = true;
console.log(
`Client capabilities received for account ${client.account.accountId}:`,
client.clientCapabilities
);
}
/**
* Send handshake message to client
*/
@@ -131,13 +202,34 @@ export class WebSocketServerManager {
const handshake: HandshakeMessage = {
type: "handshake",
accountId: client.account.accountId,
capabilities: client.account.capabilities,
capabilities: client.account.capabilities, // Server capabilities (max allowed)
metadata: client.account.metadata || {},
timestamp: Date.now(),
};
this.sendToClient(client.ws, handshake);
}
/**
* Get effective capabilities for a client (intersection of account and client capabilities)
*/
getEffectiveCapabilities(client: ConnectedClient): ChannelCapabilities {
// If client hasn't reported capabilities yet, use account capabilities
if (!client.capabilitiesReady || !client.clientCapabilities) {
return client.account.capabilities;
}
// Return intersection (client can only use what both support)
return client.clientCapabilities;
}
/**
* Check if a client supports a specific capability
*/
clientSupportsCapability(client: ConnectedClient, capability: keyof ChannelCapabilities): boolean {
const caps = this.getEffectiveCapabilities(client);
return caps[capability] === true;
}
/**
* Send a message to a specific client
*/