feat: wire rule registry and client-authenticated callback into server runtime
- Add ruleRegistry and onClientAuthenticated options to YonexusServerRuntime - Dispatch rewritten rule messages (rule::sender::content) to rule registry - Guard onClientAuthenticated behind promoteToAuthenticated return value - Fix transport message handler: use tempConn directly when ws is in temp state, preventing stale _connections entry from causing promoteToAuthenticated to fail - Close competing temp connections with same identifier on promotion - Expose __yonexusServer on globalThis for cross-plugin communication - Remove auto-failure on admin notification miss; pairing stays pending Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,12 +47,15 @@ import {
|
||||
type DiscordNotificationService
|
||||
} from "../notifications/discord.js";
|
||||
import { safeErrorMessage } from "./logging.js";
|
||||
import type { ServerRuleRegistry } from "./rules.js";
|
||||
|
||||
export interface YonexusServerRuntimeOptions {
|
||||
config: YonexusServerConfig;
|
||||
store: YonexusServerStore;
|
||||
transport: ServerTransport;
|
||||
notificationService?: DiscordNotificationService;
|
||||
ruleRegistry?: ServerRuleRegistry;
|
||||
onClientAuthenticated?: (identifier: string) => void;
|
||||
now?: () => number;
|
||||
sweepIntervalMs?: number;
|
||||
}
|
||||
@@ -447,7 +450,7 @@ export class YonexusServerRuntime {
|
||||
);
|
||||
record.recentHandshakeAttempts.push(now);
|
||||
|
||||
if (record.recentHandshakeAttempts.length > AUTH_MAX_ATTEMPTS_PER_WINDOW) {
|
||||
if (record.recentHandshakeAttempts.length >= AUTH_MAX_ATTEMPTS_PER_WINDOW) {
|
||||
await this.triggerRePairRequired(connection, record, envelope.requestId, "rate_limited");
|
||||
return;
|
||||
}
|
||||
@@ -543,7 +546,10 @@ export class YonexusServerRuntime {
|
||||
session.lastActivityAt = now;
|
||||
session.publicKey = publicKey;
|
||||
}
|
||||
this.options.transport.promoteToAuthenticated(identifier, connection.ws);
|
||||
const promoted = this.options.transport.promoteToAuthenticated(identifier, connection.ws);
|
||||
if (promoted) {
|
||||
this.options.onClientAuthenticated?.(identifier);
|
||||
}
|
||||
this.options.transport.sendToConnection(
|
||||
{ ...connection, identifier },
|
||||
encodeBuiltin(
|
||||
@@ -613,6 +619,11 @@ export class YonexusServerRuntime {
|
||||
this.pairingService.markNotificationFailed(record);
|
||||
}
|
||||
|
||||
// Persist immediately so the pairing code is readable from disk (e.g. via CLI)
|
||||
if (!reusePending) {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
this.options.transport.sendToConnection(
|
||||
connection,
|
||||
encodeBuiltin(
|
||||
@@ -620,7 +631,7 @@ export class YonexusServerRuntime {
|
||||
{
|
||||
identifier: record.identifier,
|
||||
expiresAt: request.expiresAt,
|
||||
ttlSeconds: this.pairingService.getRemainingTtl(record),
|
||||
ttlSeconds: request.ttlSeconds,
|
||||
adminNotification: notified ? "sent" : "failed",
|
||||
codeDelivery: "out_of_band"
|
||||
},
|
||||
@@ -628,22 +639,8 @@ export class YonexusServerRuntime {
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (!notified) {
|
||||
this.options.transport.sendToConnection(
|
||||
connection,
|
||||
encodeBuiltin(
|
||||
buildPairFailed(
|
||||
{
|
||||
identifier: record.identifier,
|
||||
reason: "admin_notification_failed"
|
||||
},
|
||||
{ requestId, timestamp: this.now() }
|
||||
)
|
||||
)
|
||||
);
|
||||
this.pairingService.clearPairingState(record);
|
||||
}
|
||||
// Pairing remains pending regardless of notification status.
|
||||
// The admin can retrieve the pairing code via the server CLI command.
|
||||
}
|
||||
|
||||
private async handleHeartbeat(
|
||||
@@ -932,16 +929,8 @@ export class YonexusServerRuntime {
|
||||
const parsed = parseRuleMessage(raw);
|
||||
const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`;
|
||||
|
||||
// TODO: Dispatch to registered rules via rule registry
|
||||
// For now, just log the rewritten message
|
||||
// this.ruleRegistry.dispatch(rewritten);
|
||||
|
||||
// Update last activity
|
||||
session.lastActivityAt = this.now();
|
||||
|
||||
// Future: dispatch to rule registry
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
void rewritten;
|
||||
this.options.ruleRegistry?.dispatch(rewritten);
|
||||
} catch (error) {
|
||||
// Malformed rule message
|
||||
this.options.transport.sendToConnection(
|
||||
|
||||
@@ -186,6 +186,16 @@ export class YonexusServerTransport implements ServerTransport {
|
||||
this._connections.delete(identifier);
|
||||
}
|
||||
|
||||
// Also close any OTHER temp connections that claimed the same identifier.
|
||||
// This handles the case where a second hello came in with the same identifier
|
||||
// while the first was still in the temp/pairing phase.
|
||||
for (const [otherWs, otherTemp] of this.tempConnections.entries()) {
|
||||
if (otherWs !== ws && otherTemp.assignedIdentifier === identifier) {
|
||||
otherWs.close(1008, "Connection replaced by new authenticated session");
|
||||
this.tempConnections.delete(otherWs);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from temp connections
|
||||
this.tempConnections.delete(ws);
|
||||
|
||||
@@ -229,22 +239,24 @@ export class YonexusServerTransport implements ServerTransport {
|
||||
|
||||
ws.on("message", (data: RawData) => {
|
||||
const message = data.toString("utf8");
|
||||
// Try to get identifier from temp connections first, then authenticated connections
|
||||
let identifier: string | null = null;
|
||||
const tempData = this.tempConnections.get(ws);
|
||||
if (tempData) {
|
||||
identifier = tempData.assignedIdentifier;
|
||||
}
|
||||
if (!identifier) {
|
||||
for (const [id, conn] of this._connections) {
|
||||
if (conn.ws === ws) {
|
||||
identifier = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If this ws is still in temp state, use tempConn directly.
|
||||
// Never fall through to _connections — it may hold a stale entry for the
|
||||
// same identifier from a previously-authenticated session that hasn't
|
||||
// finished closing yet, which would cause promoteToAuthenticated to receive
|
||||
// the wrong WebSocket and silently fail.
|
||||
if (this.tempConnections.has(ws)) {
|
||||
this.options.onMessage(tempConn, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn;
|
||||
// ws has been promoted — find it in authenticated connections
|
||||
let connection: ClientConnection = tempConn;
|
||||
for (const [, conn] of this._connections) {
|
||||
if (conn.ws === ws) {
|
||||
connection = conn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.options.onMessage(connection, message);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user