Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions assets/common/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -463,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 (
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();
Expand All @@ -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--;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions assets/common/connection-signin.js
Original file line number Diff line number Diff line change
@@ -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.user?.id || !POSNIC.session.shopKey || window.CaptainAccess?.locked) 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;
Expand Down Expand Up @@ -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;}
Expand Down
37 changes: 37 additions & 0 deletions docs/till-reinstall-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 39 additions & 1 deletion tests/connection-signin.spec.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -76,3 +76,41 @@ 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');
});

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');
});
67 changes: 67 additions & 0 deletions tests/secure-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1102,3 +1102,70 @@ 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"}]');
});

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);
});