diff --git a/plugin/core/persistence.ts b/plugin/core/persistence.ts index 5cbbeeb..da93fcc 100644 --- a/plugin/core/persistence.ts +++ b/plugin/core/persistence.ts @@ -110,6 +110,9 @@ export interface ClientSession { /** WebSocket connection instance */ readonly socket: unknown; // Will be typed as WebSocket when implementing transport + /** Public key presented during hello, before pairing completes */ + publicKey?: string; + /** Whether the client is currently authenticated */ isAuthenticated: boolean; diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 2e2ac5a..aa6873c 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,9 +1,13 @@ import { type BuiltinEnvelope, type HelloPayload, + type PairConfirmPayload, YONEXUS_PROTOCOL_VERSION, buildError, buildHelloAck, + buildPairFailed, + buildPairRequest, + buildPairSuccess, decodeBuiltin, encodeBuiltin, isBuiltinMessage @@ -125,6 +129,14 @@ export class YonexusServerRuntime { if (envelope.type === "hello") { await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); + return; + } + + if (envelope.type === "pair_confirm") { + await this.handlePairConfirm( + connection, + envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload> + ); } } @@ -176,7 +188,8 @@ export class YonexusServerRuntime { socket: connection.ws, isAuthenticated: false, connectedAt: connection.connectedAt, - lastActivityAt: this.now() + lastActivityAt: this.now(), + publicKey: payload.publicKey }); const nextAction = this.determineNextAction(record); @@ -193,13 +206,113 @@ export class YonexusServerRuntime { ) ); - if (nextAction === "pair_required") { - await this.beginPairing(record); + if (nextAction === "pair_required" || nextAction === "waiting_pair_confirm") { + await this.beginPairing({ + record, + connection: { ...connection, identifier: helloIdentifier }, + requestId: envelope.requestId, + reusePending: nextAction === "waiting_pair_confirm" + }); } await this.persist(); } + private async handlePairConfirm( + connection: ClientConnection, + envelope: BuiltinEnvelope<"pair_confirm", PairConfirmPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "pair_confirm payload is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const identifier = payload.identifier?.trim(); + if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier: identifier || "unknown", + reason: "identifier_not_allowed" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const record = this.ensureClientRecord(identifier); + const submittedCode = payload.pairingCode?.trim(); + if (!submittedCode) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "pairingCode is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const result = this.pairingService.confirmPairing(record, submittedCode); + if (!result.success || !result.secret || !result.pairedAt) { + const reason = result.reason === "not_pending" ? "internal_error" : result.reason ?? "internal_error"; + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier, + reason + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + return; + } + + if (connection.identifier !== identifier) { + this.options.transport.registerConnection(identifier, connection.ws); + } + + const session = this.registry.sessions.get(identifier); + record.publicKey = session?.publicKey ?? record.publicKey; + record.updatedAt = this.now(); + + this.options.transport.sendToConnection( + { ...connection, identifier }, + encodeBuiltin( + buildPairSuccess( + { + identifier, + secret: result.secret, + pairedAt: result.pairedAt + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + } + private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" { if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { return "waiting_pair_confirm"; @@ -223,18 +336,66 @@ export class YonexusServerRuntime { return created; } - private async beginPairing(record: ClientRecord): Promise { - if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { - return; - } + private async beginPairing(options: { + record: ClientRecord; + connection: ClientConnection; + requestId?: string; + reusePending?: boolean; + }): Promise { + const { record, connection, requestId, reusePending = false } = options; + + const request = + reusePending && hasPendingPairing(record) && !isPairingExpired(record, this.now()) + ? { + identifier: record.identifier, + pairingCode: record.pairingCode ?? "", + expiresAt: record.pairingExpiresAt ?? this.now(), + ttlSeconds: this.pairingService.getRemainingTtl(record), + createdAt: record.updatedAt + } + : this.pairingService.createPairingRequest(record); + + const notified = reusePending + ? record.pairingNotifyStatus === "sent" + : await this.notificationService.sendPairingNotification(request); - const request = this.pairingService.createPairingRequest(record); - const notified = await this.notificationService.sendPairingNotification(request); if (notified) { this.pairingService.markNotificationSent(record); } else { this.pairingService.markNotificationFailed(record); } + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairRequest( + { + identifier: record.identifier, + expiresAt: request.expiresAt, + ttlSeconds: this.pairingService.getRemainingTtl(record), + adminNotification: notified ? "sent" : "failed", + codeDelivery: "out_of_band" + }, + { requestId, timestamp: this.now() } + ) + ) + ); + + if (!notified) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier: record.identifier, + reason: "admin_notification_failed" + }, + { requestId, timestamp: this.now() } + ) + ) + ); + this.pairingService.clearPairingState(record); + } } private async persist(): Promise {