feat: implement server pairing confirmation flow

This commit is contained in:
nav
2026-04-08 21:38:43 +00:00
parent cd09fe6043
commit a05b226056
2 changed files with 173 additions and 9 deletions

View File

@@ -110,6 +110,9 @@ export interface ClientSession {
/** WebSocket connection instance */ /** WebSocket connection instance */
readonly socket: unknown; // Will be typed as WebSocket when implementing transport 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 */ /** Whether the client is currently authenticated */
isAuthenticated: boolean; isAuthenticated: boolean;

View File

@@ -1,9 +1,13 @@
import { import {
type BuiltinEnvelope, type BuiltinEnvelope,
type HelloPayload, type HelloPayload,
type PairConfirmPayload,
YONEXUS_PROTOCOL_VERSION, YONEXUS_PROTOCOL_VERSION,
buildError, buildError,
buildHelloAck, buildHelloAck,
buildPairFailed,
buildPairRequest,
buildPairSuccess,
decodeBuiltin, decodeBuiltin,
encodeBuiltin, encodeBuiltin,
isBuiltinMessage isBuiltinMessage
@@ -125,6 +129,14 @@ export class YonexusServerRuntime {
if (envelope.type === "hello") { if (envelope.type === "hello") {
await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); 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, socket: connection.ws,
isAuthenticated: false, isAuthenticated: false,
connectedAt: connection.connectedAt, connectedAt: connection.connectedAt,
lastActivityAt: this.now() lastActivityAt: this.now(),
publicKey: payload.publicKey
}); });
const nextAction = this.determineNextAction(record); const nextAction = this.determineNextAction(record);
@@ -193,13 +206,113 @@ export class YonexusServerRuntime {
) )
); );
if (nextAction === "pair_required") { if (nextAction === "pair_required" || nextAction === "waiting_pair_confirm") {
await this.beginPairing(record); await this.beginPairing({
record,
connection: { ...connection, identifier: helloIdentifier },
requestId: envelope.requestId,
reusePending: nextAction === "waiting_pair_confirm"
});
} }
await this.persist(); await this.persist();
} }
private async handlePairConfirm(
connection: ClientConnection,
envelope: BuiltinEnvelope<"pair_confirm", PairConfirmPayload>
): Promise<void> {
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" { private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" {
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
return "waiting_pair_confirm"; return "waiting_pair_confirm";
@@ -223,18 +336,66 @@ export class YonexusServerRuntime {
return created; return created;
} }
private async beginPairing(record: ClientRecord): Promise<void> { private async beginPairing(options: {
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { record: ClientRecord;
return; connection: ClientConnection;
} requestId?: string;
reusePending?: boolean;
}): Promise<void> {
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) { if (notified) {
this.pairingService.markNotificationSent(record); this.pairingService.markNotificationSent(record);
} else { } else {
this.pairingService.markNotificationFailed(record); 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<void> { private async persist(): Promise<void> {