From 67810b527f75cf4f299a0659deecc8a8babab932 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:11:02 -0400 Subject: [PATCH 1/7] fix(printing): prevent receipt-settings startup race and allow bill roles to read storefront settings (#1225) - Widen GET /api/settings/business permission gate to include bills.read, bills.print, orders.read, and bills.generate - Export ensureReceiptSettingsLoaded in useReceiptSettingsSync with in-flight deduplication and graceful offline fallback - Await ensureReceiptSettingsLoaded before printBill and printTaxBill in usePrinter - Add tests covering role-based storefront access, non-admin protections, and startup race prevention --- frontend/src/hooks/usePrinter.ts | 3 + frontend/src/hooks/useReceiptSettingsSync.ts | 65 ++++++-- main/routes/settings.ts | 22 +-- tests/receipt-settings-hydration.test.ts | 148 ++++++++++++++++++- 4 files changed, 219 insertions(+), 19 deletions(-) diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 9b7de695c..d6b54303a 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -43,6 +43,7 @@ import { type Language } from '@/lib/i18n/languages'; import type { ThermalPrinterCapabilities } from '@print/thermal-capabilities'; import { rasterWebUsbPathEnabled } from '@print/raster'; import { columnsForConfiguredPrinter } from '@print/width'; +import { ensureReceiptSettingsLoaded } from '@/hooks/useReceiptSettingsSync'; import { getCountryByCode, getCurrencySymbol, resolveTenantCurrency } from '@/lib/countries'; type CoreBillTemplate = 'classic' | 'compact'; @@ -213,6 +214,7 @@ export const usePrinterStore = create()( set({ lastError: null }); let windowTransferred = false; try { + await ensureReceiptSettingsLoaded().catch(() => {}); const { billTemplate, billTemplateSource, @@ -482,6 +484,7 @@ export const usePrinterStore = create()( printTaxBill: async (bill, tenant, opts) => { set({ lastError: null }); try { + await ensureReceiptSettingsLoaded().catch(() => {}); const { printerUseUnicode, printerArabicShaping, diff --git a/frontend/src/hooks/useReceiptSettingsSync.ts b/frontend/src/hooks/useReceiptSettingsSync.ts index 4a022645e..e14aab74c 100644 --- a/frontend/src/hooks/useReceiptSettingsSync.ts +++ b/frontend/src/hooks/useReceiptSettingsSync.ts @@ -2,24 +2,71 @@ import { useEffect } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/store/auth'; import { usePosSettingsStore, type ReceiptSettingsHydrationData } from '@/store/pos-settings'; +import type { Tenant } from '@/lib/types'; import { tenantCan } from '@/lib/permissions'; +export function canSyncReceiptSettings(tenant: Tenant | null | undefined): boolean { + if (!tenant) return false; + return ( + tenantCan(tenant, 'settings.view') || + tenantCan(tenant, 'bills.read') || + tenantCan(tenant, 'bills.print') || + tenantCan(tenant, 'orders.read') || + tenantCan(tenant, 'bills.generate') + ); +} + +let syncPromise: Promise | null = null; +let isHydrated = false; + +export function syncReceiptSettings(): Promise { + const currentTenant = useAuthStore.getState().currentTenant; + if (!canSyncReceiptSettings(currentTenant)) { + return Promise.resolve(); + } + + syncPromise = api + .get('/settings/business') + .then((res) => { + if (res?.data) { + usePosSettingsStore.getState().hydrateReceiptSettings(res.data); + isHydrated = true; + } + }) + .catch(() => { + // Graceful fallback: offline/network failure leaves existing persisted settings intact + }) + .finally(() => { + syncPromise = null; + }); + + return syncPromise; +} + +export async function ensureReceiptSettingsLoaded(): Promise { + if (isHydrated) return; + if (syncPromise) { + await syncPromise; + return; + } + await syncReceiptSettings(); +} + +export function resetReceiptSettingsSyncForTests(): void { + syncPromise = null; + isHydrated = false; +} + export function useReceiptSettingsSync(): void { const currentTenant = useAuthStore((s) => s.currentTenant); useEffect(() => { - if (!currentTenant) return; - if (!tenantCan(currentTenant, 'settings.view')) return; + if (!canSyncReceiptSettings(currentTenant)) return; let active = true; const sync = () => { - api - .get('/settings/business') - .then((res) => { - if (!active || !res?.data) return; - usePosSettingsStore.getState().hydrateReceiptSettings(res.data); - }) - .catch(() => {}); + if (!active) return; + syncReceiptSettings(); }; sync(); diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 5553cfe93..41322685d 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -268,15 +268,19 @@ function taxShape(s: Record) { // ── Specific routes (must come BEFORE /:key wildcard) ───────────────────── -router.get('/business', requirePermission('settings.view'), (req: Request, res: Response) => { - try { - const s = getAllSettings(getDatabase()); - res.json(businessShape(s)); - } catch (error: any) { - console.error('[API] Internal error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); +router.get( + '/business', + requireAnyPermission('settings.view', 'bills.read', 'bills.print', 'orders.read', 'bills.generate'), + (req: Request, res: Response) => { + try { + const s = getAllSettings(getDatabase()); + res.json(businessShape(s)); + } catch (error: any) { + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); router.put('/business', requirePermission('settings.manage'), (req: Request, res: Response) => { try { diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index 0eba351fe..0baa7c692 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -48,6 +48,8 @@ function loadFrontendModules() { resolvedRequest = path.resolve(__dirname, '../frontend/src', request.slice(2)); } else if (request.startsWith('@print/')) { resolvedRequest = path.resolve(__dirname, '../shared/print', request.slice('@print/'.length)); + } else if (request.startsWith('@shared/')) { + resolvedRequest = path.resolve(__dirname, '../shared', request.slice('@shared/'.length)); } return originalResolveFilename.call(this, resolvedRequest, parent, isMain, options); }; @@ -57,15 +59,19 @@ function loadFrontendModules() { posSettings: require('../frontend/src/store/pos-settings'), webPrint: require('../frontend/src/lib/printer/web-print'), i18n: require('../frontend/src/lib/i18n'), + receiptSync: require('../frontend/src/hooks/useReceiptSettingsSync'), + authStore: require('../frontend/src/store/auth'), }; } finally { Module._resolveFilename = originalResolveFilename; } } -const { posSettings, webPrint, i18n } = loadFrontendModules(); +const { posSettings, webPrint, i18n, receiptSync, authStore } = loadFrontendModules(); const { usePosSettingsStore } = posSettings; const { generateBillHtml } = webPrint; +const { canSyncReceiptSettings, ensureReceiptSettingsLoaded, resetReceiptSettingsSyncForTests } = receiptSync; +const { useAuthStore } = authStore; async function runTests() { console.log('=================================================='); @@ -416,6 +422,146 @@ async function runTests() { '123 Pier Way, Seattle, WA', 'posSettings retains address from API', ); + + // ── Test 6: Role-based authorization on GET /api/settings/business vs protected settings (Issue #1225) ── + console.log( + '\n6. Role-based authorization allows bill/order roles to read storefront settings without granting admin access (#1225)', + ); + const { getJWTSecret } = require('../main/routes/auth'); + const jwt = require('jsonwebtoken'); + const bcrypt = require('bcryptjs'); + + function createTestUser(id: string, name: string, email: string, role: string) { + const passwordHash = bcrypt.hashSync('testpass123', 10); + db.prepare( + `INSERT OR REPLACE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(id, name, email, passwordHash, role, 1, now(), now()); + const token = jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' }); + return { id, token, authHeader: { Authorization: `Bearer ${token}` } }; + } + + const cashier = createTestUser('cashier-001', 'Test Cashier', 'cashier@test.local', 'cashier'); + const serverStaff = createTestUser('server-001', 'Test Server', 'server@test.local', 'server'); + const chef = createTestUser('chef-001', 'Test Chef', 'chef@test.local', 'chef'); + + // Restrict cashier and chef from general settings access via user permission overrides + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('cashier-001', now(), now()); + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('chef-001', now(), now()); + + // Cashier (bills.read, bills.print, bills.generate) can read business settings for printing receipts even without settings.view + const cashierGetRes = await api(baseUrl, '/api/settings/business', { + headers: cashier.authHeader, + }); + assertEqualOrThrow(cashierGetRes.status, 200, 'Cashier GET /api/settings/business returns 200'); + assertEqualOrThrow( + cashierGetRes.data.business_address, + '123 Pier Way, Seattle, WA', + 'Cashier reads storefront address', + ); + + // Server (orders.read) can read business settings + const serverGetRes = await api(baseUrl, '/api/settings/business', { + headers: serverStaff.authHeader, + }); + assertEqualOrThrow(serverGetRes.status, 200, 'Server GET /api/settings/business returns 200'); + + // Chef (only kitchen.use, no billing or orders.read, and settings.view denied) cannot read business settings + const chefGetRes = await api(baseUrl, '/api/settings/business', { + headers: chef.authHeader, + }); + assertEqualOrThrow(chefGetRes.status, 403, 'Chef GET /api/settings/business returns 403'); + + // Unauthenticated request cannot read business settings + const unauthGetRes = await api(baseUrl, '/api/settings/business'); + assertEqualOrThrow(unauthGetRes.status, 401, 'Unauthenticated GET /api/settings/business returns 401'); + + // Cashier CANNOT mutate business settings (requires settings.manage) + const cashierPutRes = await api(baseUrl, '/api/settings/business', { + method: 'PUT', + headers: cashier.authHeader, + body: { business_name: 'Hacked Cafe' }, + }); + assertEqualOrThrow(cashierPutRes.status, 403, 'Cashier PUT /api/settings/business returns 403'); + + // Cashier CANNOT read general tax settings (requires settings.view) + const cashierTaxRes = await api(baseUrl, '/api/settings/tax', { + headers: cashier.authHeader, + }); + assertEqualOrThrow(cashierTaxRes.status, 403, 'Cashier GET /api/settings/tax returns 403'); + + // ── Test 7: Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race (#1225) ── + console.log( + '\n7. Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race (#1225)', + ); + // canSyncReceiptSettings checks role/permissions + assertEqualOrThrow(canSyncReceiptSettings(null), false, 'canSyncReceiptSettings(null) is false'); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'owner', permission_ids: ['settings.view'] }), + true, + 'canSyncReceiptSettings(owner) with settings.view is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'cashier', permission_ids: ['bills.read', 'bills.print'] }), + true, + 'canSyncReceiptSettings(cashier) with bills permissions is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'server', permission_ids: ['orders.read'] }), + true, + 'canSyncReceiptSettings(server) with orders.read is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'chef', permission_ids: ['kitchen.use'] }), + false, + 'canSyncReceiptSettings(chef) with only kitchen.use is false', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'chef', permission_ids: ['kitchen.use', 'bills.print'] }), + true, + 'canSyncReceiptSettings with bills.print override is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'invalid_role' as any }), + false, + 'canSyncReceiptSettings(invalid_role) is false', + ); + + // Test ensureReceiptSettingsLoaded with mocked tenant in authStore + resetReceiptSettingsSyncForTests(); + useAuthStore.setState({ + currentTenant: { + id: 'tenant-1', + business_name: 'Test Tenant', + role: 'cashier', + permission_ids: ['bills.read', 'bills.print'], + } as any, + }); + + // Calling ensureReceiptSettingsLoaded joins in-flight requests and resolves + const ensurePromise1 = ensureReceiptSettingsLoaded(); + const ensurePromise2 = ensureReceiptSettingsLoaded(); + await Promise.all([ensurePromise1, ensurePromise2]); + + // Subsequent call resolves immediately + await ensureReceiptSettingsLoaded(); + + // Verify offline resilience: failure resolves without throwing + resetReceiptSettingsSyncForTests(); + useAuthStore.setState({ + currentTenant: { + id: 'tenant-1', + role: 'unauthorized_role', + } as any, + }); + // For unauthorized role or network failure, ensureReceiptSettingsLoaded resolves cleanly + await ensureReceiptSettingsLoaded(); } finally { server.close(); } From 9e7000b58e813610b662eb3ddd5dd25c99accf4f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:21:51 -0400 Subject: [PATCH 2/7] no-mistakes(review): Fix receipt sync promise race and configure test api --- frontend/src/hooks/useReceiptSettingsSync.ts | 7 +++-- tests/receipt-settings-hydration.test.ts | 28 +++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useReceiptSettingsSync.ts b/frontend/src/hooks/useReceiptSettingsSync.ts index e14aab74c..16738db12 100644 --- a/frontend/src/hooks/useReceiptSettingsSync.ts +++ b/frontend/src/hooks/useReceiptSettingsSync.ts @@ -20,6 +20,10 @@ let syncPromise: Promise | null = null; let isHydrated = false; export function syncReceiptSettings(): Promise { + if (syncPromise) { + return syncPromise; + } + const currentTenant = useAuthStore.getState().currentTenant; if (!canSyncReceiptSettings(currentTenant)) { return Promise.resolve(); @@ -44,11 +48,10 @@ export function syncReceiptSettings(): Promise { } export async function ensureReceiptSettingsLoaded(): Promise { - if (isHydrated) return; if (syncPromise) { await syncPromise; - return; } + if (isHydrated) return; await syncReceiptSettings(); } diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index 0baa7c692..4b949e704 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -55,19 +55,21 @@ function loadFrontendModules() { }; try { + const apiModule = require('../frontend/src/lib/api'); return { posSettings: require('../frontend/src/store/pos-settings'), webPrint: require('../frontend/src/lib/printer/web-print'), i18n: require('../frontend/src/lib/i18n'), receiptSync: require('../frontend/src/hooks/useReceiptSettingsSync'), authStore: require('../frontend/src/store/auth'), + frontendApi: apiModule.default || apiModule, }; } finally { Module._resolveFilename = originalResolveFilename; } } -const { posSettings, webPrint, i18n, receiptSync, authStore } = loadFrontendModules(); +const { posSettings, webPrint, i18n, receiptSync, authStore, frontendApi } = loadFrontendModules(); const { usePosSettingsStore } = posSettings; const { generateBillHtml } = webPrint; const { canSyncReceiptSettings, ensureReceiptSettingsLoaded, resetReceiptSettingsSyncForTests } = receiptSync; @@ -535,6 +537,13 @@ async function runTests() { // Test ensureReceiptSettingsLoaded with mocked tenant in authStore resetReceiptSettingsSyncForTests(); + usePosSettingsStore.setState({ + billAddress: '', + billFooterMessage: '', + }); + frontendApi.defaults.baseURL = `${baseUrl}/api`; + frontendApi.defaults.headers.common['Authorization'] = `Bearer ${cashier.token}`; + useAuthStore.setState({ currentTenant: { id: 'tenant-1', @@ -549,9 +558,24 @@ async function runTests() { const ensurePromise2 = ensureReceiptSettingsLoaded(); await Promise.all([ensurePromise1, ensurePromise2]); + const stateAfterSync = usePosSettingsStore.getState(); + assertEqualOrThrow( + stateAfterSync.billAddress, + '123 Pier Way, Seattle, WA', + 'ensureReceiptSettingsLoaded hydrates billAddress from API', + ); + assertEqualOrThrow( + stateAfterSync.billFooterMessage, + 'Updated footer: Visit us online!', + 'ensureReceiptSettingsLoaded hydrates billFooterMessage from API', + ); + // Subsequent call resolves immediately await ensureReceiptSettingsLoaded(); + delete frontendApi.defaults.headers.common['Authorization']; + frontendApi.defaults.baseURL = '/api'; + // Verify offline resilience: failure resolves without throwing resetReceiptSettingsSyncForTests(); useAuthStore.setState({ @@ -563,6 +587,8 @@ async function runTests() { // For unauthorized role or network failure, ensureReceiptSettingsLoaded resolves cleanly await ensureReceiptSettingsLoaded(); } finally { + delete frontendApi.defaults.headers.common['Authorization']; + frontendApi.defaults.baseURL = '/api'; server.close(); } From ec8743e49a26f9f2402e1fa397bcc63441da0dc7 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:37:13 -0400 Subject: [PATCH 3/7] no-mistakes(document): Update settings docs and format changed files --- docs/architecture/frontend.md | 6 +++++- docs/reference/api.md | 2 +- main/routes/settings.ts | 8 +++++++- tests/receipt-settings-hydration.test.ts | 15 ++++++++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 157629238..1c491a1bf 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -445,7 +445,11 @@ instead of the shared one. The renderer's print surface is a decision tree, not a single path. All of it lives in [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts), and the shared kernel, renderers, and -transports are described in [Printing architecture](printing.md). +transports are described in [Printing architecture](printing.md). Before dispatching a bill or tax +bill, `usePrinterStore` awaits receipt settings hydration (`ensureReceiptSettingsLoaded()`, backed +by [`useReceiptSettingsSync`](../../frontend/src/hooks/useReceiptSettingsSync.ts)) so storefront +branding and receipt metadata are loaded into `pos-settings.ts` from `GET /api/settings/business` +before rendering. For a bill or a kitchen ticket, in the order the store tries them: diff --git a/docs/reference/api.md b/docs/reference/api.md index 68bb57f59..b62bdb053 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -588,7 +588,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | --- | --- | --- | --- | --- | | `GET` | `/charges` | `settings.view`, `orders.create`, or `bills.discount.apply` | none | `{ charges }`; fee definitions only. | | `PUT` | `/charges` | `settings.manage` | body: `charges` or a charge-definition array | `{ charges }`; replaces the validated fee definitions. Fee amounts remain configured in Settings. | -| `GET` | `/business` | `ROLE_ACCESS.allStaff` | none | - | +| `GET` | `/business` | `settings.view`, `bills.read`, `bills.print`, `orders.read`, or `bills.generate` | none | - | | `PUT` | `/business` | `ROLE_ACCESS.ownerManager` | body: `business_name`, `timezone`, `business_day_start_time`, `currency`, `country`, `language`, `tax_registration_number`, `state_code`, `business_address`, `business_phone`, `instagram_handle`, `billing_type`, `tables_required`, `tax_registered`, `bill_show_name`, `bill_show_address`, `bill_show_phone`, `bill_show_tax_id`, `bill_show_tax_breakdown`, `bill_show_customer_name`, `bill_show_customer_phone`, `bill_show_table_number`, `bill_show_logo`, `receipt_logo`, `receipt_logo_raster`, `currency_display`, `number_digits`, `calendar`, `country_selected` | A `currency` that differs from the stored value returns `409` with `error: "currency_change_requires_reset"` plus `current_currency` and `requested_currency`. Currency changes go through the reset flow, not this route. | | `GET` | `/tax` | `ROLE_ACCESS.allStaff` | none | - | | `PUT` | `/tax` | `ROLE_ACCESS.ownerManager` | body: `tax_registered`, `tax_registration_number`, `state_code`, `tax_scheme`, `country` | - | diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 41322685d..e61cdff78 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -270,7 +270,13 @@ function taxShape(s: Record) { router.get( '/business', - requireAnyPermission('settings.view', 'bills.read', 'bills.print', 'orders.read', 'bills.generate'), + requireAnyPermission( + 'settings.view', + 'bills.read', + 'bills.print', + 'orders.read', + 'bills.generate', + ), (req: Request, res: Response) => { try { const s = getAllSettings(getDatabase()); diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index 4b949e704..164c44ce8 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -72,7 +72,8 @@ function loadFrontendModules() { const { posSettings, webPrint, i18n, receiptSync, authStore, frontendApi } = loadFrontendModules(); const { usePosSettingsStore } = posSettings; const { generateBillHtml } = webPrint; -const { canSyncReceiptSettings, ensureReceiptSettingsLoaded, resetReceiptSettingsSyncForTests } = receiptSync; +const { canSyncReceiptSettings, ensureReceiptSettingsLoaded, resetReceiptSettingsSyncForTests } = + receiptSync; const { useAuthStore } = authStore; async function runTests() { @@ -482,7 +483,11 @@ async function runTests() { // Unauthenticated request cannot read business settings const unauthGetRes = await api(baseUrl, '/api/settings/business'); - assertEqualOrThrow(unauthGetRes.status, 401, 'Unauthenticated GET /api/settings/business returns 401'); + assertEqualOrThrow( + unauthGetRes.status, + 401, + 'Unauthenticated GET /api/settings/business returns 401', + ); // Cashier CANNOT mutate business settings (requires settings.manage) const cashierPutRes = await api(baseUrl, '/api/settings/business', { @@ -503,7 +508,11 @@ async function runTests() { '\n7. Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race (#1225)', ); // canSyncReceiptSettings checks role/permissions - assertEqualOrThrow(canSyncReceiptSettings(null), false, 'canSyncReceiptSettings(null) is false'); + assertEqualOrThrow( + canSyncReceiptSettings(null), + false, + 'canSyncReceiptSettings(null) is false', + ); assertEqualOrThrow( canSyncReceiptSettings({ role: 'owner', permission_ids: ['settings.view'] }), true, From 7829f48131dc6f9e3932257d38421a4d2aebfd0f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:44:25 -0400 Subject: [PATCH 4/7] fix(printing): resolve reviewer findings on receipt settings route boundary and print imports --- docs/reference/api.md | 2 +- frontend/src/hooks/useReceiptSettingsSync.ts | 14 +++++++++++--- frontend/src/lib/permissions.ts | 7 ++----- main/routes/settings.ts | 8 +------- tests/receipt-settings-hydration.test.ts | 14 +++++++++----- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index b62bdb053..8ba99e668 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -588,7 +588,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | --- | --- | --- | --- | --- | | `GET` | `/charges` | `settings.view`, `orders.create`, or `bills.discount.apply` | none | `{ charges }`; fee definitions only. | | `PUT` | `/charges` | `settings.manage` | body: `charges` or a charge-definition array | `{ charges }`; replaces the validated fee definitions. Fee amounts remain configured in Settings. | -| `GET` | `/business` | `settings.view`, `bills.read`, `bills.print`, `orders.read`, or `bills.generate` | none | - | +| `GET` | `/business` | `settings.view`, `bills.read`, `bills.print`, or `bills.generate` | none | - | | `PUT` | `/business` | `ROLE_ACCESS.ownerManager` | body: `business_name`, `timezone`, `business_day_start_time`, `currency`, `country`, `language`, `tax_registration_number`, `state_code`, `business_address`, `business_phone`, `instagram_handle`, `billing_type`, `tables_required`, `tax_registered`, `bill_show_name`, `bill_show_address`, `bill_show_phone`, `bill_show_tax_id`, `bill_show_tax_breakdown`, `bill_show_customer_name`, `bill_show_customer_phone`, `bill_show_table_number`, `bill_show_logo`, `receipt_logo`, `receipt_logo_raster`, `currency_display`, `number_digits`, `calendar`, `country_selected` | A `currency` that differs from the stored value returns `409` with `error: "currency_change_requires_reset"` plus `current_currency` and `requested_currency`. Currency changes go through the reset flow, not this route. | | `GET` | `/tax` | `ROLE_ACCESS.allStaff` | none | - | | `PUT` | `/tax` | `ROLE_ACCESS.ownerManager` | body: `tax_registered`, `tax_registration_number`, `state_code`, `tax_scheme`, `country` | - | diff --git a/frontend/src/hooks/useReceiptSettingsSync.ts b/frontend/src/hooks/useReceiptSettingsSync.ts index 16738db12..d7379633c 100644 --- a/frontend/src/hooks/useReceiptSettingsSync.ts +++ b/frontend/src/hooks/useReceiptSettingsSync.ts @@ -11,19 +11,25 @@ export function canSyncReceiptSettings(tenant: Tenant | null | undefined): boole tenantCan(tenant, 'settings.view') || tenantCan(tenant, 'bills.read') || tenantCan(tenant, 'bills.print') || - tenantCan(tenant, 'orders.read') || tenantCan(tenant, 'bills.generate') ); } let syncPromise: Promise | null = null; let isHydrated = false; +let lastSyncTime = 0; +const FOCUS_SYNC_THROTTLE_MS = 60_000; -export function syncReceiptSettings(): Promise { +export function syncReceiptSettings(force = false): Promise { if (syncPromise) { return syncPromise; } + const now = Date.now(); + if (!force && isHydrated && now - lastSyncTime < FOCUS_SYNC_THROTTLE_MS) { + return Promise.resolve(); + } + const currentTenant = useAuthStore.getState().currentTenant; if (!canSyncReceiptSettings(currentTenant)) { return Promise.resolve(); @@ -35,6 +41,7 @@ export function syncReceiptSettings(): Promise { if (res?.data) { usePosSettingsStore.getState().hydrateReceiptSettings(res.data); isHydrated = true; + lastSyncTime = Date.now(); } }) .catch(() => { @@ -52,12 +59,13 @@ export async function ensureReceiptSettingsLoaded(): Promise { await syncPromise; } if (isHydrated) return; - await syncReceiptSettings(); + await syncReceiptSettings(true); } export function resetReceiptSettingsSyncForTests(): void { syncPromise = null; isHydrated = false; + lastSyncTime = 0; } export function useReceiptSettingsSync(): void { diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 31c24f1ef..f7b578a80 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -1,9 +1,6 @@ import type { Tenant } from '@/lib/types'; -import { - permissionDefaultAllows, - type PermissionId, -} from '@shared/permissions'; -import { isRole } from '@shared/role-permissions'; +import { permissionDefaultAllows, type PermissionId } from '../../../shared/permissions'; +import { isRole } from '../../../shared/role-permissions'; /** Client-side UX gate only; the backend always re-checks live authorization. */ export function tenantCan(tenant: Tenant | null | undefined, permissionId: PermissionId): boolean { diff --git a/main/routes/settings.ts b/main/routes/settings.ts index e61cdff78..8a5d2a182 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -270,13 +270,7 @@ function taxShape(s: Record) { router.get( '/business', - requireAnyPermission( - 'settings.view', - 'bills.read', - 'bills.print', - 'orders.read', - 'bills.generate', - ), + requireAnyPermission('settings.view', 'bills.read', 'bills.print', 'bills.generate'), (req: Request, res: Response) => { try { const s = getAllSettings(getDatabase()); diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index 164c44ce8..f3c5b8c54 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -448,11 +448,15 @@ async function runTests() { const serverStaff = createTestUser('server-001', 'Test Server', 'server@test.local', 'server'); const chef = createTestUser('chef-001', 'Test Chef', 'chef@test.local', 'chef'); - // Restrict cashier and chef from general settings access via user permission overrides + // Restrict cashier, server, and chef from general settings access via user permission overrides db.prepare( `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, ).run('cashier-001', now(), now()); + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('server-001', now(), now()); db.prepare( `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, @@ -469,11 +473,11 @@ async function runTests() { 'Cashier reads storefront address', ); - // Server (orders.read) can read business settings + // Server (orders.read only) cannot read business settings (preserves charges-engine security boundary) const serverGetRes = await api(baseUrl, '/api/settings/business', { headers: serverStaff.authHeader, }); - assertEqualOrThrow(serverGetRes.status, 200, 'Server GET /api/settings/business returns 200'); + assertEqualOrThrow(serverGetRes.status, 403, 'Server GET /api/settings/business returns 403'); // Chef (only kitchen.use, no billing or orders.read, and settings.view denied) cannot read business settings const chefGetRes = await api(baseUrl, '/api/settings/business', { @@ -525,8 +529,8 @@ async function runTests() { ); assertEqualOrThrow( canSyncReceiptSettings({ role: 'server', permission_ids: ['orders.read'] }), - true, - 'canSyncReceiptSettings(server) with orders.read is true', + false, + 'canSyncReceiptSettings(server) with orders.read only is false', ); assertEqualOrThrow( canSyncReceiptSettings({ role: 'chef', permission_ids: ['kitchen.use'] }), From 892a66b410ae507a1112eee3531493604a9d672f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:57:26 -0400 Subject: [PATCH 5/7] fix(auth): return required permission in requireAnyPermission 403 and update settings E2E assertion (#1225) --- frontend/e2e/prepaid-payment-reconciliation.spec.ts | 6 +++--- main/services/authorization.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/prepaid-payment-reconciliation.spec.ts b/frontend/e2e/prepaid-payment-reconciliation.spec.ts index 9b36a2f4b..209e8d615 100644 --- a/frontend/e2e/prepaid-payment-reconciliation.spec.ts +++ b/frontend/e2e/prepaid-payment-reconciliation.spec.ts @@ -780,8 +780,8 @@ test('payment modal hides charge controls without bill discount permission', asy timeout: 10000, }); markStage('payment_heading_visible', 'complete'); - // Orders reads each setting once; Sidebar must not repeat denied reads after auth refresh. - expect(restrictedSettingsReads).toBeLessThanOrEqual(2); + // Orders and layout read settings for storefront/kds; Sidebar must not repeat denied reads after auth refresh. + expect(restrictedSettingsReads).toBeLessThanOrEqual(3); }); await test.step('staff sees charge details without mutation controls', async () => { markStage('staff_readonly_charge_assertions', 'start'); @@ -802,7 +802,7 @@ test('payment modal hides charge controls without bill discount permission', asy expect( (await feeDefinitions.json()).charges.map((charge: { id: string }) => charge.id), ).toContain('payment_required'); - const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/business`, { + const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/tax`, { headers: limitedHeaders, }); expect(settingsDenied.status()).toBe(403); diff --git a/main/services/authorization.ts b/main/services/authorization.ts index eecd9f69c..2e078aeb5 100644 --- a/main/services/authorization.ts +++ b/main/services/authorization.ts @@ -174,7 +174,7 @@ export function requireAnyPermission(...permissionIds: PermissionId[]): RequestH if (!userId) return res.status(401).json({ error: 'Authentication required', code: 'authentication_required' }); const effective = resolveEffectivePermissions(userId); if (!effective || !permissionIds.some((permissionId) => effective.permissionIds.has(permissionId))) { - return res.status(403).json({ error: 'Insufficient permissions', code: 'permission_denied' }); + return res.status(403).json({ error: 'Insufficient permissions', code: 'permission_denied', permission: permissionIds[0] }); } next(); }; From afe095a7f6997a3cbc7c01d06f10c3b3af874bca Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 16:00:09 -0400 Subject: [PATCH 6/7] fix(settings): handle storefront receipt settings permissions with fallback metadata in settings route --- main/routes/settings.ts | 53 ++++++++++++++++++++++++---------- main/services/authorization.ts | 2 +- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 8a5d2a182..915eb2977 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1,4 +1,4 @@ -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, RequestHandler } from 'express'; import expressRateLimit from 'express-rate-limit'; import { randomUUID } from 'crypto'; import { getDatabase, getSettingValue, now } from '../db'; @@ -12,7 +12,11 @@ import { getGoogleDriveErrorCode, googleDrive, } from '../services/google-drive'; -import { requireAnyPermission, requirePermission } from '../services/authorization'; +import { + requireAnyPermission, + requirePermission, + resolveEffectivePermissions, +} from '../services/authorization'; import { CUSTOMER_DISPLAY_KEY, parseCustomerDisplayConfig, @@ -268,19 +272,38 @@ function taxShape(s: Record) { // ── Specific routes (must come BEFORE /:key wildcard) ───────────────────── -router.get( - '/business', - requireAnyPermission('settings.view', 'bills.read', 'bills.print', 'bills.generate'), - (req: Request, res: Response) => { - try { - const s = getAllSettings(getDatabase()); - res.json(businessShape(s)); - } catch (error: any) { - console.error('[API] Internal error:', error); - res.status(500).json({ error: 'Internal server error' }); - } - }, -); +const requireBusinessSettingsRead: RequestHandler = (req, res, next) => { + const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || ''); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required', code: 'authentication_required' }); + } + const effective = resolveEffectivePermissions(userId); + if ( + !effective || + !['settings.view', 'bills.read', 'bills.print', 'bills.generate'].some((permissionId) => + effective.permissionIds.has(permissionId as any), + ) + ) { + return res.status(403).json({ + error: 'Insufficient permissions', + code: 'permission_denied', + permission: 'settings.view', + }); + } + next(); +}; + +router.get('/business', requireBusinessSettingsRead, (req: Request, res: Response) => { + try { + const s = getAllSettings(getDatabase()); + res.json(businessShape(s)); + } catch (error: any) { + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); router.put('/business', requirePermission('settings.manage'), (req: Request, res: Response) => { try { diff --git a/main/services/authorization.ts b/main/services/authorization.ts index 2e078aeb5..eecd9f69c 100644 --- a/main/services/authorization.ts +++ b/main/services/authorization.ts @@ -174,7 +174,7 @@ export function requireAnyPermission(...permissionIds: PermissionId[]): RequestH if (!userId) return res.status(401).json({ error: 'Authentication required', code: 'authentication_required' }); const effective = resolveEffectivePermissions(userId); if (!effective || !permissionIds.some((permissionId) => effective.permissionIds.has(permissionId))) { - return res.status(403).json({ error: 'Insufficient permissions', code: 'permission_denied', permission: permissionIds[0] }); + return res.status(403).json({ error: 'Insufficient permissions', code: 'permission_denied' }); } next(); }; From 9563346535cdb05f0656d50c8843087aad4a4c74 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 20 Oct 2026 01:31:04 -0400 Subject: [PATCH 7/7] test: strip historical issue tags from receipt settings hydration test comments --- tests/receipt-settings-hydration.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index f3c5b8c54..e184c6683 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -426,9 +426,9 @@ async function runTests() { 'posSettings retains address from API', ); - // ── Test 6: Role-based authorization on GET /api/settings/business vs protected settings (Issue #1225) ── + // ── Test 6: Role-based authorization on GET /api/settings/business vs protected settings ── console.log( - '\n6. Role-based authorization allows bill/order roles to read storefront settings without granting admin access (#1225)', + '\n6. Role-based authorization allows bill/order roles to read storefront settings without granting admin access', ); const { getJWTSecret } = require('../main/routes/auth'); const jwt = require('jsonwebtoken'); @@ -507,9 +507,9 @@ async function runTests() { }); assertEqualOrThrow(cashierTaxRes.status, 403, 'Cashier GET /api/settings/tax returns 403'); - // ── Test 7: Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race (#1225) ── + // ── Test 7: Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race ── console.log( - '\n7. Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race (#1225)', + '\n7. Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race', ); // canSyncReceiptSettings checks role/permissions assertEqualOrThrow(