feat: implement server pairing confirmation flow
This commit is contained in:
@@ -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<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" {
|
||||
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<void> {
|
||||
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
|
||||
return;
|
||||
}
|
||||
private async beginPairing(options: {
|
||||
record: ClientRecord;
|
||||
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) {
|
||||
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<void> {
|
||||
|
||||
Reference in New Issue
Block a user