Skip to content

Commit 89ea073

Browse files
committed
fix(devices): persist paired device connect metadata
1 parent 935b2f1 commit 89ea073

4 files changed

Lines changed: 181 additions & 31 deletions

File tree

src/gateway/server.auth.control-ui.suite.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,9 +885,12 @@ export function registerControlUiAndPairingSuite(): void {
885885
(entry) => entry.deviceId === identity.deviceId,
886886
);
887887
expect(pendingAfterRead).toHaveLength(0);
888-
if (!(await getPairedDevice(identity.deviceId))) {
888+
const pairedAfterRead = await getPairedDevice(identity.deviceId);
889+
if (!pairedAfterRead) {
889890
throw new Error(`expected paired device ${identity.deviceId}`);
890891
}
892+
expect(pairedAfterRead.lastSeenReason).toBe("connect");
893+
expect(typeof pairedAfterRead.lastSeenAtMs).toBe("number");
891894
wsRemoteRead.close();
892895

893896
const ws2 = await openWs(port, { host: "gateway.example" });

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,9 +1251,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
12511251
? await approveBootstrapDevicePairing(
12521252
pairing.request.requestId,
12531253
boundBootstrapProfile,
1254+
{ accessMetadata: clientAccessMetadata },
12541255
)
12551256
: await approveDevicePairing(pairing.request.requestId, {
12561257
callerScopes: scopes,
1258+
accessMetadata: clientAccessMetadata,
12571259
});
12581260
if (approved?.status === "approved") {
12591261
if (allowSilentBootstrapPairing && boundBootstrapProfile) {

src/infra/device-pairing.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,75 @@ describe("device pairing tokens", () => {
789789
});
790790
});
791791

792+
test("approval access metadata initializes paired device last-seen fields", async () => {
793+
const baseDir = await makeDevicePairingDir();
794+
const request = await requestDevicePairing(
795+
{
796+
deviceId: "node-1",
797+
publicKey: "public-key-node-1",
798+
role: "node",
799+
scopes: [],
800+
displayName: "pending-name",
801+
remoteIp: "127.0.0.1",
802+
},
803+
baseDir,
804+
);
805+
const firstSeenAtMs = Date.now();
806+
807+
const approved = await approveDevicePairing(
808+
request.request.requestId,
809+
{
810+
callerScopes: [],
811+
accessMetadata: {
812+
displayName: "connected-name",
813+
remoteIp: "10.0.0.1",
814+
lastSeenAtMs: firstSeenAtMs,
815+
lastSeenReason: "connect",
816+
},
817+
},
818+
baseDir,
819+
);
820+
expectRecordFields(approved, "approved result", { status: "approved" });
821+
822+
const paired = await getPairedDevice("node-1", baseDir);
823+
expectRecordFields(paired, "paired device", {
824+
displayName: "connected-name",
825+
remoteIp: "10.0.0.1",
826+
lastSeenAtMs: firstSeenAtMs,
827+
lastSeenReason: "connect",
828+
});
829+
});
830+
831+
test("repair approvals preserve paired device last-seen fields without access metadata", async () => {
832+
const baseDir = await makeDevicePairingDir();
833+
await setupPairedNodeDevice(baseDir);
834+
await updatePairedDeviceMetadata(
835+
"node-1",
836+
{
837+
lastSeenAtMs: 1234,
838+
lastSeenReason: "bg_app_refresh",
839+
},
840+
baseDir,
841+
);
842+
843+
const repair = await requestDevicePairing(
844+
{
845+
deviceId: "node-1",
846+
publicKey: "public-key-node-1",
847+
role: "node",
848+
scopes: [],
849+
},
850+
baseDir,
851+
);
852+
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
853+
854+
const paired = await getPairedDevice("node-1", baseDir);
855+
expectRecordFields(paired, "paired device", {
856+
lastSeenAtMs: 1234,
857+
lastSeenReason: "bg_app_refresh",
858+
});
859+
});
860+
792861
test("device token verification refreshes paired device last-seen metadata", async () => {
793862
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
794863
const beforeVerifyAtMs = Date.now();
@@ -1269,6 +1338,44 @@ describe("device pairing tokens", () => {
12691338
expect(paired?.tokens?.operator).toBeUndefined();
12701339
});
12711340

1341+
test("bootstrap approval access metadata initializes paired device last-seen fields", async () => {
1342+
const baseDir = await makeDevicePairingDir();
1343+
const request = await requestDevicePairing(
1344+
{
1345+
deviceId: "bootstrap-device-seen",
1346+
publicKey: "bootstrap-public-key-seen",
1347+
role: "node",
1348+
roles: ["node"],
1349+
scopes: [],
1350+
silent: true,
1351+
remoteIp: "127.0.0.1",
1352+
},
1353+
baseDir,
1354+
);
1355+
const firstSeenAtMs = Date.now();
1356+
1357+
const approved = await approveBootstrapDevicePairing(
1358+
request.request.requestId,
1359+
PAIRING_SETUP_BOOTSTRAP_PROFILE,
1360+
{
1361+
accessMetadata: {
1362+
remoteIp: "10.0.0.2",
1363+
lastSeenAtMs: firstSeenAtMs,
1364+
lastSeenReason: "connect",
1365+
},
1366+
},
1367+
baseDir,
1368+
);
1369+
expectRecordFields(approved, "approved result", { status: "approved" });
1370+
1371+
const paired = await getPairedDevice("bootstrap-device-seen", baseDir);
1372+
expectRecordFields(paired, "paired device", {
1373+
remoteIp: "10.0.0.2",
1374+
lastSeenAtMs: firstSeenAtMs,
1375+
lastSeenReason: "connect",
1376+
});
1377+
});
1378+
12721379
test("baseline bootstrap pairing issues bounded operator token when requested by QR handoff", async () => {
12731380
const baseDir = await makeDevicePairingDir();
12741381
const request = await requestDevicePairing(

src/infra/device-pairing.ts

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export type PairedDeviceMetadataPatch = Pick<
111111
| "lastSeenReason"
112112
>;
113113

114+
export type DevicePairingAccessMetadata = Pick<
115+
PairedDevice,
116+
"displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason"
117+
>;
118+
114119
export type DevicePairingList = {
115120
pending: DevicePairingPendingRequest[];
116121
paired: PairedDevice[];
@@ -458,6 +463,36 @@ function buildDeviceAuthToken(params: {
458463
};
459464
}
460465

466+
function buildApprovedPairedDevice(params: {
467+
pending: DevicePairingPendingRequest;
468+
existing: PairedDevice | undefined;
469+
roles: string[] | undefined;
470+
approvedScopes: string[] | undefined;
471+
tokens: Record<string, DeviceAuthToken>;
472+
now: number;
473+
accessMetadata?: DevicePairingAccessMetadata;
474+
}): PairedDevice {
475+
return {
476+
deviceId: params.pending.deviceId,
477+
publicKey: params.pending.publicKey,
478+
displayName: params.accessMetadata?.displayName ?? params.pending.displayName,
479+
platform: params.pending.platform,
480+
deviceFamily: params.pending.deviceFamily,
481+
clientId: params.pending.clientId,
482+
clientMode: params.pending.clientMode,
483+
role: params.pending.role,
484+
roles: params.roles,
485+
scopes: params.approvedScopes,
486+
approvedScopes: params.approvedScopes,
487+
remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp,
488+
tokens: params.tokens,
489+
createdAtMs: params.existing?.createdAtMs ?? params.now,
490+
approvedAtMs: params.now,
491+
lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs,
492+
lastSeenReason: params.accessMetadata?.lastSeenReason ?? params.existing?.lastSeenReason,
493+
};
494+
}
495+
461496
function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | undefined): string[] {
462497
const normalized = normalizeDeviceAuthScopes(scopes);
463498
if (role === "operator") {
@@ -620,12 +655,14 @@ export async function approveDevicePairing(
620655
): Promise<ApproveDevicePairingResult>;
621656
export async function approveDevicePairing(
622657
requestId: string,
623-
options: { callerScopes?: readonly string[] },
658+
options: { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata },
624659
baseDir?: string,
625660
): Promise<ApproveDevicePairingResult>;
626661
export async function approveDevicePairing(
627662
requestId: string,
628-
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
663+
optionsOrBaseDir?:
664+
| { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata }
665+
| string,
629666
maybeBaseDir?: string,
630667
): Promise<ApproveDevicePairingResult> {
631668
const options =
@@ -707,23 +744,15 @@ export async function approveDevicePairing(
707744
lastUsedAtMs: existingToken?.lastUsedAtMs,
708745
};
709746
}
710-
const device: PairedDevice = {
711-
deviceId: pending.deviceId,
712-
publicKey: pending.publicKey,
713-
displayName: pending.displayName,
714-
platform: pending.platform,
715-
deviceFamily: pending.deviceFamily,
716-
clientId: pending.clientId,
717-
clientMode: pending.clientMode,
718-
role: pending.role,
747+
const device = buildApprovedPairedDevice({
748+
pending,
749+
existing,
719750
roles,
720-
scopes: approvedScopes,
721751
approvedScopes,
722-
remoteIp: pending.remoteIp,
723752
tokens,
724-
createdAtMs: existing?.createdAtMs ?? now,
725-
approvedAtMs: now,
726-
};
753+
now,
754+
accessMetadata: options?.accessMetadata,
755+
});
727756
delete state.pendingById[requestId];
728757
state.pairedByDeviceId[device.deviceId] = device;
729758
await persistState(state, baseDir, "both");
@@ -735,7 +764,24 @@ export async function approveBootstrapDevicePairing(
735764
requestId: string,
736765
bootstrapProfile: DeviceBootstrapProfile,
737766
baseDir?: string,
767+
): Promise<ApproveDevicePairingResult>;
768+
export async function approveBootstrapDevicePairing(
769+
requestId: string,
770+
bootstrapProfile: DeviceBootstrapProfile,
771+
options: { accessMetadata?: DevicePairingAccessMetadata },
772+
baseDir?: string,
773+
): Promise<ApproveDevicePairingResult>;
774+
export async function approveBootstrapDevicePairing(
775+
requestId: string,
776+
bootstrapProfile: DeviceBootstrapProfile,
777+
optionsOrBaseDir?: { accessMetadata?: DevicePairingAccessMetadata } | string,
778+
maybeBaseDir?: string,
738779
): Promise<ApproveDevicePairingResult> {
780+
const options =
781+
typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined
782+
? undefined
783+
: optionsOrBaseDir;
784+
const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
739785
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
740786
const approvedScopes = resolveBootstrapProfileScopesForRoles(
741787
approvedRoles,
@@ -796,23 +842,15 @@ export async function approveBootstrapDevicePairing(
796842
});
797843
}
798844

799-
const device: PairedDevice = {
800-
deviceId: pending.deviceId,
801-
publicKey: pending.publicKey,
802-
displayName: pending.displayName,
803-
platform: pending.platform,
804-
deviceFamily: pending.deviceFamily,
805-
clientId: pending.clientId,
806-
clientMode: pending.clientMode,
807-
role: pending.role,
845+
const device = buildApprovedPairedDevice({
846+
pending,
847+
existing,
808848
roles,
809-
scopes: nextApprovedScopes,
810849
approvedScopes: nextApprovedScopes,
811-
remoteIp: pending.remoteIp,
812850
tokens,
813-
createdAtMs: existing?.createdAtMs ?? now,
814-
approvedAtMs: now,
815-
};
851+
now,
852+
accessMetadata: options?.accessMetadata,
853+
});
816854
delete state.pendingById[requestId];
817855
state.pairedByDeviceId[device.deviceId] = device;
818856
await persistState(state, baseDir, "both");

0 commit comments

Comments
 (0)