diff --git a/packages/web/server/lib/client-auth/pairing.js b/packages/web/server/lib/client-auth/pairing.js index ca8b4ddc..1efe84e5 100644 --- a/packages/web/server/lib/client-auth/pairing.js +++ b/packages/web/server/lib/client-auth/pairing.js @@ -262,14 +262,15 @@ export const createClientPairingRuntime = ({ if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError(); // The operator's typed pairing label is THIS server's name for the device - // (shown in the device list). It wins over the device's self-reported - // label; fall back to that only when no pairing label was set. - const label = normalizeOptionalString(session.label) - || normalizeOptionalString(clientLabel) - || normalizeOptionalString(deviceName) - || 'Remote client'; + // (shown in the device list) and wins outright. The device's self-reported + // label is only a fallback: on a re-pair with the same dedupeKey, + // createClient keeps the replaced record's label over it, so a rescan + // without a typed name does not reset the device to the app default. const result = await remoteClientAuthRuntime.createClient({ - label, + label: normalizeOptionalString(session.label), + fallbackLabel: normalizeOptionalString(clientLabel) + || normalizeOptionalString(deviceName) + || 'Remote client', clientKind: normalizedKind, dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`, authMethod: 'pairing', diff --git a/packages/web/server/lib/client-auth/pairing.test.js b/packages/web/server/lib/client-auth/pairing.test.js index 0b20aa38..da1a7df2 100644 --- a/packages/web/server/lib/client-auth/pairing.test.js +++ b/packages/web/server/lib/client-auth/pairing.test.js @@ -13,7 +13,7 @@ const makeRuntime = async (options = {}) => { createClient: vi.fn(async (input) => { const client = { id: `client-${createdClients.length + 1}`, - label: input.label, + label: input.label ?? input.fallbackLabel, clientKind: input.clientKind, authMethod: input.authMethod, pairingId: input.pairingId, @@ -61,6 +61,10 @@ describe('client auth pairing runtime', () => { pairingId: created.pairing.id, clientKind: 'mobile', dedupeKey: 'device-key', + // No operator-typed pairing label: the app-reported name is only a + // fallback so a re-pair keeps the existing device record's label. + label: null, + fallbackLabel: 'Iryna iPhone', })); await expect(runtime.redeemPairingSession({ diff --git a/packages/web/server/lib/client-auth/remote-clients.js b/packages/web/server/lib/client-auth/remote-clients.js index 171dca56..a172ef8f 100644 --- a/packages/web/server/lib/client-auth/remote-clients.js +++ b/packages/web/server/lib/client-auth/remote-clients.js @@ -157,6 +157,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP const createClient = async ({ label, + fallbackLabel, expiresAt, clientKind, dedupeKey, @@ -172,9 +173,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP const store = await readStore(); const normalizedDedupeKey = normalizeOptionalString(dedupeKey); const token = generateToken(); + // A dedupe-keyed mint REPLACES the previous record for the same device, + // so an operator-visible name must survive the replacement: an explicit + // label wins, otherwise the replaced record's label is kept, and only a + // first-ever mint falls back to the client-reported default. + const existing = normalizedDedupeKey + ? store.clients.find((entry) => entry.dedupeKey === normalizedDedupeKey) + : null; const client = { id: generateId(), - label: normalizeLabel(label), + label: normalizeLabel(normalizeOptionalString(label) || existing?.label || fallbackLabel), tokenHash: hashToken(token), createdAt: nowIso(), lastUsedAt: null, diff --git a/packages/web/server/lib/client-auth/remote-clients.test.js b/packages/web/server/lib/client-auth/remote-clients.test.js index 68b220d1..20063d47 100644 --- a/packages/web/server/lib/client-auth/remote-clients.test.js +++ b/packages/web/server/lib/client-auth/remote-clients.test.js @@ -83,6 +83,23 @@ describe('remote client auth runtime', () => { } }); + it('keeps the replaced record label on a dedupe re-mint without an explicit label', async () => { + const { dir, runtime } = await createRuntime(); + try { + await runtime.createClient({ label: 'Iryna iPhone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' }); + const remint = await runtime.createClient({ dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' }); + expect(remint.client.label).toBe('Iryna iPhone'); + + const renamed = await runtime.createClient({ label: 'Work phone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' }); + expect(renamed.client.label).toBe('Work phone'); + + const fresh = await runtime.createClient({ dedupeKey: 'mobile:device-2', fallbackLabel: 'OpenChamber Mobile' }); + expect(fresh.client.label).toBe('OpenChamber Mobile'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it('keeps the token store private on disk', async () => { const { dir, runtime } = await createRuntime(); try { diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index af342c43..ea3e0b99 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -839,7 +839,7 @@ export const createUiAuth = ({ let clientTokenResult = null; if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') { clientTokenResult = await clientAuthController.createClient({ - label: req.body?.clientLabel, + fallbackLabel: req.body?.clientLabel, expiresAt: new Date(Date.now() + ttlMs).toISOString(), clientKind: req.body?.clientKind, dedupeKey: req.body?.dedupeKey, @@ -907,7 +907,7 @@ export const createUiAuth = ({ let clientTokenResult = null; if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') { clientTokenResult = await clientAuthController.createClient({ - label: req.body?.clientLabel, + fallbackLabel: req.body?.clientLabel, expiresAt: new Date(Date.now() + ttlMs).toISOString(), clientKind: req.body?.clientKind, dedupeKey: req.body?.dedupeKey, diff --git a/packages/web/server/lib/ui-auth/ui-auth.test.js b/packages/web/server/lib/ui-auth/ui-auth.test.js index 54200f7f..185b0af1 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.test.js +++ b/packages/web/server/lib/ui-auth/ui-auth.test.js @@ -269,7 +269,7 @@ describe('ui auth client credential seam', () => { token: 'client-token', client: { id: 'device-1', - label: input.label, + label: input.label ?? input.fallbackLabel, createdAt: new Date().toISOString(), lastUsedAt: null, revokedAt: null, @@ -295,7 +295,7 @@ describe('ui auth client credential seam', () => { await auth.handleSessionCreate(req, res); expect(res.body.clientToken).toBe('client-token'); - expect(createClientInput.label).toBe('OpenChamber Desktop'); + expect(createClientInput.fallbackLabel).toBe('OpenChamber Desktop'); const expiresAt = Date.parse(createClientInput.expiresAt); expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000); expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);