From 6cf45f0f341cf29c013cda082bbaa4d133d667bd Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 30 Oct 2026 16:28:41 +0530 Subject: [PATCH 1/2] WIP: recover Captain sign-in after a till reinstall --- assets/common/access.js | 39 +++++++++++++++++-- assets/common/connection-signin.js | 8 ++++ docs/till-reinstall-recovery.md | 37 ++++++++++++++++++ tests/connection-signin.spec.js | 28 +++++++++++++- tests/secure-access.test.js | 61 ++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 docs/till-reinstall-recovery.md diff --git a/assets/common/access.js b/assets/common/access.js index 76c04a2..43c6606 100644 --- a/assets/common/access.js +++ b/assets/common/access.js @@ -237,13 +237,24 @@ }; async function prove(base, credential = state, remember = true, signal) { const nonce = random(); - const answer = await post( + let answer; + try { answer = await post( base, "/captain/v1/route-proof", { sessionId: credential.sessionId, nonce }, signal, 2000, - ); + ); } catch (error) { + // Reinstalling a till can replace its local session database. Only the + // saved issuing address may offer reauthentication; discovery alone + // never authorizes sending a bearer or replaying an order. + if (base === credential.base && error.code === "UNKNOWN_AUTHORITY") { + throw Object.assign(new Error("Your shop addresses are saved. Sign in again to continue."), { + status: 401, code: "CONNECTION_SIGNIN", base, + }); + } + throw error; + } const key = await cryptoApi.subtle.importKey( "raw", new TextEncoder().encode(credential.routeKey), @@ -613,7 +624,7 @@ throw Object.assign(new Error("Unlock this phone first."), {code:"PIN_LOCKED"}); showDisabled(error); last = error; - if (error.code === "PIN_LOCKED" || error.accessRefused) throw error; + if (error.code === "PIN_LOCKED" || error.code === "CONNECTION_SIGNIN" || error.accessRefused) throw error; if ( attempted && error.status && @@ -717,6 +728,10 @@ try { await session.prepare(options.onlyBase); } catch (error) { + if (error.code === "CONNECTION_SIGNIN") { + if (host.CustomEvent) host.dispatchEvent?.(new host.CustomEvent("posnic:connection-signin", {detail: {base: error.base}})); + throw error; + } if ([401, 403].includes(error.status) || !state.connections?.length) throw error; } @@ -731,6 +746,10 @@ } return result; } catch (error) { + if (error.code === "CONNECTION_SIGNIN") { + if (host.CustomEvent) host.dispatchEvent?.(new host.CustomEvent("posnic:connection-signin", {detail: {base: error.base}})); + throw error; + } if (error.status === 403) { state.blockedAccess = error.message; await persist(); @@ -746,6 +765,11 @@ await session.prepare(options.onlyBase); return await transport(path, options); } + if (error.status === 401) { + error.code = 'CONNECTION_SIGNIN'; + error.base = selected; + if (host.CustomEvent) host.dispatchEvent?.(new host.CustomEvent('posnic:connection-signin', {detail: {base:selected}})); + } throw error; } finally { if(!options.background)activeRequests--; @@ -838,6 +862,13 @@ state = clean === primaryBase ? {...state, ...credential, connections: state.connections || []} : {...state, base: primaryBase, connections: [...(state.connections || []).filter(c => c.base !== clean), credential]}; + if (clean === primaryBase) { + delete state.blockedAccess; + // Password login may issue a plain bearer instead of a paired grant. + // Never inherit secrets or leases from the replaced installation. + for (const key of ['sessionId','refreshToken','routeKey','rotation','offlineUntil','targetDeviceId','idempotentOrders']) + if (!(key in credential)) delete state[key]; + } try { await persist(); } catch (error) { if(generation === started) state = previous; throw error; } if(generation !== started || locked) throw new Error('Connection cancelled.'); verified.add(clean); @@ -915,7 +946,7 @@ await session.start({ ...state, ...grant, base: state.base }); })() .catch(async (error) => { - if ([401, 403].includes(error.status)) { + if ([401, 403].includes(error.status) && error.code !== "CONNECTION_SIGNIN") { state.blockedAccess = error.message; await persist(); await session.suspend(); diff --git a/assets/common/connection-signin.js b/assets/common/connection-signin.js index ec67d4d..404c0f0 100644 --- a/assets/common/connection-signin.js +++ b/assets/common/connection-signin.js @@ -1,6 +1,13 @@ /* Add a separately authenticated route without signing out the working till. */ (function () { const t = value => window.I18N?.t(value) || value; + const recoveryShown = new Set(); + window.addEventListener('posnic:connection-signin', event => { + const base = event.detail?.base; + if (!base || recoveryShown.has(base) || !POSNIC.session.active) return; + recoveryShown.add(base); + window.CaptainConnectionSignIn.open(POSNIC.server.isLanUrl(base) ? 'lan' : 'cloud', {base}); + }); window.CaptainConnectionSignIn = { open(kind, options={}) { if (document.getElementById('connection-signin')) return; @@ -100,6 +107,7 @@ } if(request.signal.aborted)return; await POSNIC.internetChoice.select(kind); + recoveryShown.delete(base); close(); } catch(error) {if(!request.signal.aborted)status.textContent=t(error.message||'Could not connect. Try again.');} finally{controller=null;if(dialog.isConnected)form.querySelector('button').disabled=false;} diff --git a/docs/till-reinstall-recovery.md b/docs/till-reinstall-recovery.md new file mode 100644 index 0000000..199c015 --- /dev/null +++ b/docs/till-reinstall-recovery.md @@ -0,0 +1,37 @@ +# Reconnecting Captain after a till reinstall + +A reinstall which replaces the local database removes `captain_sessions`. The +phone still has the issuing URL, session ID, HMAC route key and renewal token. +The route-proof endpoint correctly returns UNKNOWN_AUTHORITY (404). Previously +the transport swallowed that response as OFFLINE, leaving the app retrying a +session the till no longer knew. Reinstalling Captain appeared to fix this +because it discarded the stale credentials and ran setup again. + +Captain now asks for explicit sign-in at the saved issuing URL when that exact +response occurs. No bearer, refresh token or order is sent before proof succeeds. +A discovered alternative URL returning the same response remains untrusted. +Network timeouts do not trigger this recovery. Device revocation remains blocked. +Password-only sessions also offer sign-in when the saved token is rejected with +401 and no refresh credential exists, including a changed desktop signing key. + +After validated same-shop, same-staff, same-branch login, replacement credentials +remove the previous installation's pairing secrets and offline lease. A blocked +primary session is cleared only by validated replacement. Separate connection +credentials and phone order storage are retained. Different identities remain +rejected by the existing identity guard. + +If the desktop address changed, select the correct till using Search this Wi-Fi +or scan its QR and authenticate. If a fresh database has different shop/staff/ +branch IDs, old work must not be silently assigned to it. This change does not +promise unattended reauthentication or automatic replay of ambiguous sends. + +## Synchronization and rollout + +Only Captain's existing secure local credential state changes; no business data, +sync collection, cloud schema, Gateway or sync-agent changes are needed. Session +secrets must remain local and must not be synchronized. The fix requires an +updated Captain APK; existing POS route-proof and password-login APIs suffice. +The browser regression simulates a replaced till session database, reconnects +through the displayed login, and checks retained local data. Secure-session tests +also check old secret removal, reload, separate connections and pending work. +Actual uninstall/reinstall on a client till/phone has not been verified. diff --git a/tests/connection-signin.spec.js b/tests/connection-signin.spec.js index 61c65bd..c4220ab 100644 --- a/tests/connection-signin.spec.js +++ b/tests/connection-signin.spec.js @@ -1,5 +1,5 @@ import {test,expect} from '@playwright/test'; -import {onTheMenu} from './support/shop.js'; +import {onTheMenu,RUNTIME_INFO} from './support/shop.js'; test('globe offers actionable sign-in, cancellation preserves Wi-Fi, and login links separate credentials',async({page})=>{ await nativePhone(page); await onTheMenu(page,'nothing');await page.goto('/kot-management.html'); @@ -76,3 +76,29 @@ test('Captain disabled during service offers recovery without clearing the saved expect(await page.evaluate(()=>POSNIC.session.active)).toBe(true); await dialog.getByRole('button',{name:'Change server',exact:true}).click();await expect(page).toHaveURL(/index.html/);await expect(page.locator('#captain-onboarding')).toBeVisible(); }); + +for (const paired of [true,false]) test(`a reinstalled till reconnects without reinstalling Captain (${paired?'paired':'password-only'})`,async({page})=>{ + await nativePhone(page);await onTheMenu(page,'nothing');await page.goto('/kot-management.html'); + const base='http://192.168.1.20:5555/api',calls=[]; + await page.route(base+'/**',async route=>{ + const request=route.request();calls.push({url:request.url(),headers:request.headers()}); + if(request.url().endsWith('/runtime-info'))return route.fulfill({json:RUNTIME_INFO}); + if(request.headers().authorization==='Bearer old-token')return route.fulfill({status:401,json:{message:'Invalid token'}}); + if(request.url().endsWith('/route-proof'))return route.fulfill({status:404,json:{error:{code:'UNKNOWN_AUTHORITY',message:'This address cannot deliver orders for this session.'}}}); + if(request.url().endsWith('/kioskMobileLogin'))return route.fulfill({json:{token:'replacement-token',shopKey:'smoke-shop-key',user:{id:'user-1'},branches:[{branch_id:'branch-1'}]}}); + return route.fulfill({json:{connected:true}}); + }); + await page.evaluate(async ({base,paired})=>{ + await POSNIC.session.start({base,token:'old-token',...(paired?{refreshToken:'old-refresh',routeKey:'a'.repeat(64),sessionId:'old-session',expiresAt:1}:{}),shopKey:'smoke-shop-key',user:{id:'user-1'},branches:[{branch_id:'branch-1'}]}); + POSNIC.server.remember({lan:base});POSNIC.server.pin(base); + localStorage.setItem('reinstall-test-draft','retained'); + try{await POSNIC.session.request('/captain/v1/session',{method:'GET',onlyBase:base});}catch{} + },{base,paired}); + const dialog=page.locator('#connection-signin');await expect(dialog).toBeVisible(); + await expect(dialog.locator('[name=address]')).toHaveValue(base); + if(paired)expect(calls.every(call=>!call.headers.authorization)).toBe(true); + await dialog.locator('[name=username]').fill('staff');await dialog.locator('[name=password]').fill('password');await dialog.locator('[type=submit]').click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(()=>POSNIC.session.token)).toBe('replacement-token'); + expect(await page.evaluate(()=>localStorage.getItem('reinstall-test-draft'))).toBe('retained'); +}); diff --git a/tests/secure-access.test.js b/tests/secure-access.test.js index a9a9db0..b6edd26 100644 --- a/tests/secure-access.test.js +++ b/tests/secure-access.test.js @@ -1102,3 +1102,64 @@ test('selected Wi-Fi never refreshes an expired cloud session',async()=>{ await access.session.request('/captain/v1/session',{onlyBase:lan}); assert.deepEqual(calls,[[lan+'/captain/v1/session','Bearer local']]); }); + +test('reinstalled issuing till asks for sign-in without sending secrets or losing saved work', async () => { + const f=fixture({'posnic.pending-orders':'[{"id":"unsent"}]'}), calls=[]; + const access=createAccess(f.plugin,f.storage,async(url,options)=>{ + calls.push({url,options}); + return {ok:false,status:404,json:async()=>({error:{code:'UNKNOWN_AUTHORITY',message:'Unknown session'}})}; + },webcrypto); + await access.session.start({...f.grant,routeKey:'a'.repeat(64),expiresAt:1,branches:[{branch_id:'branch'}]}); + await assert.rejects(access.session.request('/sales/qrOrder',{body:{idempotencyKey:'saved'}}),e=>e.code==='CONNECTION_SIGNIN'&&e.base===f.grant.base); + assert.equal(access.session.active,true); + assert.equal(access.session.token,'access'); + assert.equal(f.storage.getItem('posnic.pending-orders'),'[{"id":"unsent"}]'); + assert.equal(calls.length,1); + assert.ok(calls[0].url.endsWith('/route-proof')); + assert.equal(calls[0].options.headers.Authorization,undefined); + assert.equal(JSON.parse(calls[0].options.body).refreshToken,undefined); +}); + +test('reauthentication replaces a blocked primary session and preserves other credentials',async()=>{ + const f=fixture(), access=createAccess(f.plugin,f.storage,async()=>({ok:true,json:async()=>({connected:true})}),webcrypto); + const grant={...f.grant,branches:[{branch_id:'branch'}],refreshToken:undefined}; + await access.session.start({...grant,blockedAccess:'Old installation rejected session'}); + await access.session.link({...grant,token:'cloud'},'https://backup.example/api'); + assert.equal(access.session.needsReconnect,true); + await access.session.link({...grant,token:'replacement'},grant.base); + assert.equal(access.session.needsReconnect,false); + assert.equal(f.vault().connections[0].token,'cloud'); + assert.deepEqual(await access.session.request('/captain/v1/session'),{connected:true}); +}); + +test('password reconnect removes old pairing secrets instead of retaining the previous installation',async()=>{ + const f=fixture(), calls=[], access=createAccess(f.plugin,f.storage,async(url,options)=>{calls.push({url,options});return {ok:true,json:async()=>({connected:true})}},webcrypto); + await access.session.start({...f.grant,routeKey:'a'.repeat(64),rotation:'old',targetDeviceId:'old-till',idempotentOrders:true,branches:[{branch_id:'branch'}]}); + await access.session.link({token:'replacement',expiresIn:900,shopKey:f.grant.shopKey,user:f.grant.user,branches:[{branch_id:'branch'}]},f.grant.base); + for(const key of ['sessionId','refreshToken','routeKey','rotation','offlineUntil','targetDeviceId','idempotentOrders'])assert.equal(f.vault()[key],undefined,key); + await access.session.request('/captain/v1/session'); + assert.equal(calls.length,1);assert.equal(calls[0].options.headers.Authorization,'Bearer replacement'); + const reopened=createAccess(f.plugin,f.storage,async()=>({ok:true,json:async()=>({connected:true})}),webcrypto); + await reopened.ready;assert.equal(reopened.session.token,'replacement'); +}); + +test('unknown session at an alternative address stays untrusted rather than requesting sign-in there',async()=>{ + const f=fixture(), calls=[], peer='http://192.168.1.99:5555/api'; + const access=createAccess(f.plugin,f.storage,async(url,options)=>{ + calls.push({url,options}); + if(url.startsWith(f.grant.base))throw Error('offline'); + return {ok:false,status:404,json:async()=>({error:{code:'UNKNOWN_AUTHORITY',message:'Unknown session'}})}; + },webcrypto); + await access.session.start({...f.grant,routeKey:'a'.repeat(64),routes:[peer]}); + await assert.rejects(access.session.request('/sales/qrOrder',{body:{idempotencyKey:'pending'}}),e=>e.code==='OFFLINE'); + assert.equal(calls.filter(call=>call.url.startsWith(peer)).length,1); + assert.ok(calls.every(call=>call.url.endsWith('/route-proof'))); +}); + +test('a replaced password-only token requests sign-in without discarding the saved account',async()=>{ + const f=fixture({'posnic.pending-orders':'[{"id":"retained"}]'}), access=createAccess(f.plugin,f.storage,async()=>({ok:false,status:401,json:async()=>({message:'Invalid token'})}),webcrypto); + await access.session.start({...f.grant,sessionId:undefined,refreshToken:undefined}); + await assert.rejects(access.session.request('/captain/v1/session'),e=>e.code==='CONNECTION_SIGNIN'&&e.base===f.grant.base); + assert.equal(access.session.user.id,'staff');assert.equal(access.session.active,true); + assert.equal(f.storage.getItem('posnic.pending-orders'),'[{"id":"retained"}]'); +}); From 57b2103233f3149ae8a3971f373b7787b070077b Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 31 Oct 2026 01:42:04 +0530 Subject: [PATCH 2/2] Handle expired saved logins during connection recovery --- assets/common/access.js | 2 +- assets/common/connection-signin.js | 2 +- tests/connection-signin.spec.js | 12 ++++++++++++ tests/secure-access.test.js | 6 ++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/assets/common/access.js b/assets/common/access.js index 43c6606..7be2606 100644 --- a/assets/common/access.js +++ b/assets/common/access.js @@ -474,7 +474,7 @@ let attempted = false; const credential = credentials.get(base); if (credential.expiresAt && credential.expiresAt <= Date.now() && !credential.refreshToken) { - last = Object.assign(new Error('Sign-in required'), {status:401, code:'CONNECTION_SIGNIN'}); + last = Object.assign(new Error('Sign-in required'), {status:401, code:'CONNECTION_SIGNIN', base}); continue; } if ( diff --git a/assets/common/connection-signin.js b/assets/common/connection-signin.js index 404c0f0..7fc538f 100644 --- a/assets/common/connection-signin.js +++ b/assets/common/connection-signin.js @@ -4,7 +4,7 @@ const recoveryShown = new Set(); window.addEventListener('posnic:connection-signin', event => { const base = event.detail?.base; - if (!base || recoveryShown.has(base) || !POSNIC.session.active) return; + if (!base || recoveryShown.has(base) || !POSNIC.session.user?.id || !POSNIC.session.shopKey || window.CaptainAccess?.locked) return; recoveryShown.add(base); window.CaptainConnectionSignIn.open(POSNIC.server.isLanUrl(base) ? 'lan' : 'cloud', {base}); }); diff --git a/tests/connection-signin.spec.js b/tests/connection-signin.spec.js index c4220ab..2e5cc98 100644 --- a/tests/connection-signin.spec.js +++ b/tests/connection-signin.spec.js @@ -102,3 +102,15 @@ for (const paired of [true,false]) test(`a reinstalled till reconnects without r expect(await page.evaluate(()=>POSNIC.session.token)).toBe('replacement-token'); expect(await page.evaluate(()=>localStorage.getItem('reinstall-test-draft'))).toBe('retained'); }); + +test('an expired password-only login still opens the saved-address recovery form',async({page})=>{ + await nativePhone(page);await onTheMenu(page,'nothing');await page.goto('/kot-management.html'); + await page.evaluate(async()=>{ + const base='http://192.168.1.20:5555/api'; + await POSNIC.session.start({base,token:'expired-token',expiresAt:1,shopKey:'smoke-shop-key',user:{id:'user-1'},branches:[{branch_id:'branch-1'}]}); + POSNIC.server.remember({lan:base});POSNIC.server.pin(base); + try{await POSNIC.session.request('/captain/v1/session',{method:'GET',onlyBase:base});}catch{} + }); + await expect(page.locator('#connection-signin')).toBeVisible(); + await expect(page.locator('#connection-signin [name=address]')).toHaveValue('http://192.168.1.20:5555/api'); +}); diff --git a/tests/secure-access.test.js b/tests/secure-access.test.js index b6edd26..815e19c 100644 --- a/tests/secure-access.test.js +++ b/tests/secure-access.test.js @@ -1163,3 +1163,9 @@ test('a replaced password-only token requests sign-in without discarding the sav assert.equal(access.session.user.id,'staff');assert.equal(access.session.active,true); assert.equal(f.storage.getItem('posnic.pending-orders'),'[{"id":"retained"}]'); }); + +test('expired password-only credentials retain the issuing address for in-place recovery',async()=>{ + const f=fixture(), access=createAccess(f.plugin,f.storage,async()=>{throw Error('expired tokens must not be sent')},webcrypto); + await access.session.start({...f.grant,sessionId:undefined,refreshToken:undefined,expiresAt:1}); + await assert.rejects(access.session.request('/captain/v1/session'),e=>e.code==='CONNECTION_SIGNIN'&&e.base===f.grant.base); +});