feat: implement server pairing confirmation flow
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user