dev/2026-04-08 #1

Merged
hzhang merged 29 commits from dev/2026-04-08 into main 2026-04-13 09:34:22 +00:00
2 changed files with 79 additions and 31 deletions
Showing only changes of commit 988170dcf6 - Show all commits

View File

@@ -254,7 +254,7 @@ export class YonexusServerRuntime {
const record = this.ensureClientRecord(helloIdentifier); const record = this.ensureClientRecord(helloIdentifier);
record.updatedAt = this.now(); record.updatedAt = this.now();
this.options.transport.registerConnection(helloIdentifier, connection.ws); this.options.transport.assignIdentifierToTemp(connection.ws, helloIdentifier);
this.registry.sessions.set(helloIdentifier, { this.registry.sessions.set(helloIdentifier, {
identifier: helloIdentifier, identifier: helloIdentifier,
socket: connection.ws, socket: connection.ws,
@@ -361,7 +361,7 @@ export class YonexusServerRuntime {
} }
if (connection.identifier !== identifier) { if (connection.identifier !== identifier) {
this.options.transport.registerConnection(identifier, connection.ws); this.options.transport.assignIdentifierToTemp(connection.ws, identifier);
} }
const session = this.registry.sessions.get(identifier); const session = this.registry.sessions.get(identifier);
@@ -540,7 +540,7 @@ export class YonexusServerRuntime {
session.lastActivityAt = now; session.lastActivityAt = now;
session.publicKey = publicKey; session.publicKey = publicKey;
} }
this.options.transport.markAuthenticated(identifier); this.options.transport.promoteToAuthenticated(identifier, connection.ws);
this.options.transport.sendToConnection( this.options.transport.sendToConnection(
{ ...connection, identifier }, { ...connection, identifier },
encodeBuiltin( encodeBuiltin(

View File

@@ -9,6 +9,17 @@ export interface ClientConnection {
isAuthenticated: boolean; isAuthenticated: boolean;
} }
/**
* Temporary connection tracking before authentication.
* Connections remain in this state until successfully authenticated.
*/
interface TempConnection {
readonly ws: WebSocket;
readonly connectedAt: number;
/** The identifier claimed during hello, if any */
assignedIdentifier: string | null;
}
export interface ServerTransport { export interface ServerTransport {
readonly isRunning: boolean; readonly isRunning: boolean;
readonly connections: ReadonlyMap<string, ClientConnection>; readonly connections: ReadonlyMap<string, ClientConnection>;
@@ -18,8 +29,23 @@ export interface ServerTransport {
sendToConnection(connection: ClientConnection, message: string): boolean; sendToConnection(connection: ClientConnection, message: string): boolean;
broadcast(message: string): void; broadcast(message: string): void;
closeConnection(identifier: string, code?: number, reason?: string): boolean; closeConnection(identifier: string, code?: number, reason?: string): boolean;
registerConnection(identifier: string, ws: WebSocket): boolean; /**
markAuthenticated(identifier: string): boolean; * Promote a temp connection to authenticated status.
* This implements the single-identifier-single-active-connection policy:
* - If another authenticated connection exists for this identifier, it is closed
* - The connection is moved from temp to authenticated registry
*/
promoteToAuthenticated(identifier: string, ws: WebSocket): boolean;
/**
* Remove a temp connection without promoting it.
* Called when authentication fails or connection closes before auth.
*/
removeTempConnection(ws: WebSocket): void;
/**
* Assign an identifier to a temp connection during hello processing.
* This does NOT register the connection as authenticated yet.
*/
assignIdentifierToTemp(ws: WebSocket, identifier: string): void;
} }
export type MessageHandler = (connection: ClientConnection, message: string) => void; export type MessageHandler = (connection: ClientConnection, message: string) => void;
@@ -36,7 +62,7 @@ export interface ServerTransportOptions {
export class YonexusServerTransport implements ServerTransport { export class YonexusServerTransport implements ServerTransport {
private wss: WebSocketServer | null = null; private wss: WebSocketServer | null = null;
private _connections = new Map<string, ClientConnection>(); private _connections = new Map<string, ClientConnection>();
private tempConnections = new Set<WebSocket>(); private tempConnections = new Map<WebSocket, TempConnection>();
private options: ServerTransportOptions; private options: ServerTransportOptions;
private _isRunning = false; private _isRunning = false;
@@ -87,14 +113,15 @@ export class YonexusServerTransport implements ServerTransport {
return; return;
} }
// Close all connections // Close all authenticated connections
for (const conn of this._connections.values()) { for (const conn of this._connections.values()) {
conn.ws.close(1000, "Server shutting down"); conn.ws.close(1000, "Server shutting down");
} }
this._connections.clear(); this._connections.clear();
for (const ws of this.tempConnections) { // Close all temp connections
ws.close(1000, "Server shutting down"); for (const temp of this.tempConnections.values()) {
temp.ws.close(1000, "Server shutting down");
} }
this.tempConnections.clear(); this.tempConnections.clear();
@@ -144,39 +171,54 @@ export class YonexusServerTransport implements ServerTransport {
return true; return true;
} }
registerConnection(identifier: string, ws: WebSocket): boolean { promoteToAuthenticated(identifier: string, ws: WebSocket): boolean {
// Check if already connected // Verify the connection exists in temp connections
if (this._connections.has(identifier)) { const tempConn = this.tempConnections.get(ws);
// Replace old connection if (!tempConn) {
const oldConn = this._connections.get(identifier)!; return false;
oldConn.ws.close(1008, "New connection established");
} }
// Remove from temp connections if present // Check if already have an authenticated connection for this identifier
// If so, close it (single-identifier-single-active-connection policy)
const existingConn = this._connections.get(identifier);
if (existingConn) {
existingConn.ws.close(1008, "Connection replaced by new authenticated session");
this._connections.delete(identifier);
}
// Remove from temp connections
this.tempConnections.delete(ws); this.tempConnections.delete(ws);
// Register the new authenticated connection
const conn: ClientConnection = { const conn: ClientConnection = {
identifier, identifier,
ws, ws,
connectedAt: Math.floor(Date.now() / 1000), connectedAt: tempConn.connectedAt,
isAuthenticated: false isAuthenticated: true
}; };
this._connections.set(identifier, conn); this._connections.set(identifier, conn);
return true; return true;
} }
markAuthenticated(identifier: string): boolean { removeTempConnection(ws: WebSocket): void {
const conn = this._connections.get(identifier); this.tempConnections.delete(ws);
if (!conn) { }
return false;
assignIdentifierToTemp(ws: WebSocket, identifier: string): void {
const tempConn = this.tempConnections.get(ws);
if (tempConn) {
tempConn.assignedIdentifier = identifier;
} }
conn.isAuthenticated = true;
return true;
} }
private handleConnection(ws: WebSocket, _req: import("http").IncomingMessage): void { private handleConnection(ws: WebSocket, _req: import("http").IncomingMessage): void {
this.tempConnections.add(ws); // Store as temp connection until authenticated
this.tempConnections.set(ws, {
ws,
connectedAt: Math.floor(Date.now() / 1000),
assignedIdentifier: null
});
const tempConn: ClientConnection = { const tempConn: ClientConnection = {
identifier: null, identifier: null,
@@ -187,14 +229,20 @@ export class YonexusServerTransport implements ServerTransport {
ws.on("message", (data: RawData) => { ws.on("message", (data: RawData) => {
const message = data.toString("utf8"); const message = data.toString("utf8");
// Try to get identifier from registered connections // Try to get identifier from temp connections first, then authenticated connections
let identifier: string | null = null; 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) { for (const [id, conn] of this._connections) {
if (conn.ws === ws) { if (conn.ws === ws) {
identifier = id; identifier = id;
break; break;
} }
} }
}
const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn; const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn;
this.options.onMessage(connection, message); this.options.onMessage(connection, message);
@@ -203,7 +251,7 @@ export class YonexusServerTransport implements ServerTransport {
ws.on("close", (code: number, reason: Buffer) => { ws.on("close", (code: number, reason: Buffer) => {
this.tempConnections.delete(ws); this.tempConnections.delete(ws);
// Find and remove from registered connections // Find and remove from authenticated connections
for (const [id, conn] of this._connections) { for (const [id, conn] of this._connections) {
if (conn.ws === ws) { if (conn.ws === ws) {
this._connections.delete(id); this._connections.delete(id);