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:
h z
2026-04-10 20:15:03 +01:00
parent 31f41cb49b
commit 59d5b26aff
6 changed files with 218 additions and 61 deletions

View File

@@ -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(

View File

@@ -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);
});