diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 04313fdc9..6ddf93a5f 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -208,20 +208,25 @@ both ending in the same write (`applyOwnerRecovery` in - **Master PIN:** `POST /api/auth/recover-password` with the owner email, the Master PIN, and the new password. Works offline. -- **FloAdmin email approval:** available when cloud account services are on and the store has a - registered owner email (`cloudSync.isEmailRecoveryAvailable()`, advertised as - `cloudRecoveryAvailable` by `GET /api/auth/setup/status`). If FloAdmin has not verified that +- **FloAdmin email approval:** available when cloud account services are on, the cloud server URL + is the official FloAdmin endpoint in both SQLite and active runtime state, and an active owner + account matches the registered owner email (`cloudSync.isEmailRecoveryAvailable()`, advertised as + `cloudRecoveryAvailable` by `GET /api/auth/setup/status`). Updating an active owner's email in + staff management keeps `settings.email` in sync, and database backup restore and import preserve + and resync `settings.email` from the preserved active owner account. If FloAdmin has not verified that email, the request first sends the verification email (`step: 'verify_email'`) and the recover page polls `GET /api/auth/recover-password/cloud/verification`; once verified it requests the reset automatically. FloAdmin then emails the registered owner a link, the POS polls the approval status, and after approval the owner chooses the new password on the POS. - `POST /api/auth/recover-password/cloud/complete` resolves the owner whose email matches the + `POST /api/auth/recover-password/cloud/complete` resolves the active owner whose email matches the registered cloud email, redeems the single-use grant with FloAdmin, and only then writes the - password. The password is never sent to FloAdmin. Cloud requests are signed with the - install's `api_key` like every other `/api/pos/*` call. + password. The password is never sent to FloAdmin. Recovery requests are pinned to the trusted + FloAdmin server URL and signed with the install's `api_key` like every other `/api/pos/*` call. If the owner email matches no active owner, nothing is redeemed. When no active administrator -remains, either path restores owner access to the matched account. +remains, the Master PIN path restores owner access to the matched active account; the FloAdmin +email path always requires an active owner matching the registered email and never promotes a +non-owner account. ## Master PIN as a second factor diff --git a/docs/reference/api.md b/docs/reference/api.md index 9757f4ba1..dff720c7f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -620,8 +620,8 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | `GET` | `/order-numbering` | `ROLE_ACCESS.allStaff` | none | - | | `PUT` | `/order-numbering` | `ROLE_ACCESS.ownerManager` | body: `order_number_prefix`, `order_number_include_date`, `order_number_reset_daily`, `invoice_number_prefix`, `invoice_number_include_period`, `invoice_number_reset_period`, `invoice_financial_year_start_month`, `invoice_financial_year_start_day` | - | | `GET` | `/cloud` | `ROLE_ACCESS.ownerManager` | none | - | -| `PUT` | `/cloud` | `ROLE_ACCESS.ownerManager` | body: `cloud_server_url`, `cloud_api_key`, `cloud_store_id`, `cloud_sync_enabled`, `cloud_orders_enabled`, `cloud_reports_enabled`, `cloud_command_polling_enabled` | - | -| `POST` | `/cloud/register` | `ROLE_ACCESS.ownerManager` | body: `cloud_server_url` | - | +| `PUT` | `/cloud` | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`, `cloud_api_key`, `cloud_store_id`) | body: `cloud_server_url`, `cloud_api_key`, `cloud_store_id`, `cloud_sync_enabled`, `cloud_orders_enabled`, `cloud_reports_enabled`, `cloud_command_polling_enabled` | `403` when a non-owner supplies `cloud_server_url`, an unmasked `cloud_api_key`, or `cloud_store_id`. | +| `POST` | `/cloud/register` | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`) | body: `cloud_server_url` | `403` when a non-owner supplies `cloud_server_url`. | | `POST` | `/cloud/test` | `ROLE_ACCESS.ownerManager` | none | - | | `GET` | `/cloud/account` | `ROLE_ACCESS.owner` | none | - | | `PUT` | `/cloud/account/preferences` | `ROLE_ACCESS.owner` | body: `product_updates`, `email_marketing` | Body `product_updates` and `email_marketing`. `409` when no cloud account is linked. | diff --git a/main/db.ts b/main/db.ts index 7f59627e6..11931b639 100644 --- a/main/db.ts +++ b/main/db.ts @@ -3092,6 +3092,7 @@ export type RestoreOutboxState = { }; const RESTORE_PROTECTED_SETTING_KEYS = [ 'jwt_secret', + 'email', 'cloud_api_key', 'cloud_device_secret', 'cloud_pos_hash', @@ -3151,6 +3152,29 @@ export function mergeRestoreProtectedSettings( else if (!state.key.startsWith('cloud_')) dbInstance.prepare('DELETE FROM settings WHERE key = ?').run(state.key); } + try { + const activeOwners = dbInstance + .prepare( + "SELECT lower(trim(email)) AS email FROM users WHERE role = 'owner' AND is_active = 1 AND email IS NOT NULL AND trim(email) != '' ORDER BY created_at ASC, id ASC", + ) + .all() as { email: string }[]; + if (activeOwners.length > 0) { + const currentEmail = String( + ( + dbInstance.prepare("SELECT value FROM settings WHERE key = 'email'").get() as + | { value: string | null } + | undefined + )?.value || '', + ) + .trim() + .toLowerCase(); + if (!activeOwners.some((row) => row.email === currentEmail)) { + upsert.run('email', activeOwners[0].email, now()); + } + } + } catch { + // Skip owner email resync if users table is unavailable in a partial fixture. + } const hasDeviceSecret = states.some( (state) => state.key === 'cloud_device_secret' && state.present, ); diff --git a/main/routes/auth.ts b/main/routes/auth.ts index 9f254e1a8..0b8a3317f 100644 --- a/main/routes/auth.ts +++ b/main/routes/auth.ts @@ -544,7 +544,7 @@ function applyOwnerRecovery( const updated = db.transaction(() => { // Re-checked in the transaction, so a concurrent write cannot turn a // plain reset into a promotion. - if (hasActiveAdministrator()) { + if (via === 'FloAdmin email approval' || hasActiveAdministrator()) { return db .prepare( 'UPDATE users SET password = ?, tokens_valid_after = ?, updated_at = ? WHERE id = ? AND role = ? AND is_active = 1', @@ -706,13 +706,20 @@ router.post( authRateLimit(), asyncHandler(async (req: Request, res: Response) => { if (!cloudRecoveryPrecheck(req, res)) return; + const email = cloudSync.getRegisteredOwnerEmail(); + const owner = getDatabase() + .prepare('SELECT id FROM users WHERE lower(email) = ? AND role = ? AND is_active = 1') + .get(email, INITIAL_ADMIN_ROLE); + if (!owner) { + return res.status(409).json({ error: 'Email recovery is not available on this install.' }); + } try { const signal = getHttpRequestSignal(req); // An unverified owner email gets the verification email first; the reset follows once verified. if (!(await cloudSync.ensureOwnerEmailVerification(signal))) { return res.status(202).json({ step: 'verify_email', - email_hint: maskEmail(cloudSync.getRegisteredOwnerEmail()), + email_hint: maskEmail(email), }); } const data = await cloudSync.requestPasswordReset(signal); @@ -778,14 +785,9 @@ router.post( // Resolve the account before consuming the single-use grant. const email = cloudSync.getRegisteredOwnerEmail(); - const stranded = !hasActiveAdministrator(); - const user = ( - stranded - ? db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email) - : db - .prepare('SELECT * FROM users WHERE email = ? AND role = ? AND is_active = 1') - .get(email, INITIAL_ADMIN_ROLE) - ) as any; + const user = db + .prepare('SELECT * FROM users WHERE lower(email) = ? AND role = ? AND is_active = 1') + .get(email, INITIAL_ADMIN_ROLE) as any; if (!user) { return res .status(404) diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 09130d88d..1d5e64d14 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1051,6 +1051,16 @@ router.put('/cloud', requirePermission('cloud.manage'), (req: Request, res: Resp cloud_reports_enabled, cloud_command_polling_enabled, } = req.body; + const isOwner = (req as Request & { user?: { role?: string } }).user?.role === 'owner'; + const hasUnmaskedApiKey = cloud_api_key !== undefined && !isMaskedSecret(cloud_api_key); + if ( + !isOwner && + (cloud_server_url !== undefined || hasUnmaskedApiKey || cloud_store_id !== undefined) + ) { + return res + .status(403) + .json({ error: 'Only the store owner can modify cloud credentials or server URL' }); + } const db = getDatabase(); const updates: Record = { cloud_store_id: cloud_store_id === undefined ? undefined : String(cloud_store_id || ''), @@ -1061,18 +1071,11 @@ router.put('/cloud', requirePermission('cloud.manage'), (req: Request, res: Resp }; if (cloud_server_url !== undefined) { - const requestedUrl = normalizeCloudServerUrl(cloud_server_url || DEFAULT_CLOUD_SERVER_URL); - if ( - requestedUrl !== normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL) && - (req as Request & { user?: { role?: string } }).user?.role !== 'owner' - ) { - return res - .status(403) - .json({ error: 'Only the store owner can configure a custom cloud server URL' }); - } - updates.cloud_server_url = requestedUrl; + updates.cloud_server_url = normalizeCloudServerUrl( + cloud_server_url || DEFAULT_CLOUD_SERVER_URL, + ); } - if (cloud_api_key !== undefined && !isMaskedSecret(cloud_api_key)) { + if (hasUnmaskedApiKey) { updates.cloud_api_key = String(cloud_api_key || ''); } const enablingCloud = [ @@ -1114,6 +1117,17 @@ router.post( requirePermission('cloud.manage'), asyncHandler(async (req: Request, res: Response) => { try { + let requestedUrl: string | undefined; + if (req.body?.cloud_server_url !== undefined) { + if ((req as Request & { user?: { role?: string } }).user?.role !== 'owner') { + return res + .status(403) + .json({ error: 'Only the store owner can modify cloud credentials or server URL' }); + } + requestedUrl = normalizeCloudServerUrl( + req.body.cloud_server_url || DEFAULT_CLOUD_SERVER_URL, + ); + } const deletionRequest = await cloudSync.getDeletionRequestStatus({ allowRemote: cloudSync.isCloudAccountAvailable(), signal: getHttpRequestSignal(req), @@ -1124,31 +1138,22 @@ router.post( 'A cloud deletion request is pending review. Cancel it before re-enabling cloud services.', }); } - if (cloudSync.getStatus().cloud_services_disabled_by_user) { + const currentStatus = cloudSync.getStatus(); + if (currentStatus.cloud_services_disabled_by_user) { return res.status(409).json({ error: CLOUD_ACCOUNT_UNAVAILABLE_ERROR }); } - if (req.body?.cloud_server_url !== undefined) { - const requestedUrl = normalizeCloudServerUrl( - req.body.cloud_server_url || DEFAULT_CLOUD_SERVER_URL, - ); - if ( - requestedUrl !== normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL) && - (req as Request & { user?: { role?: string } }).user?.role !== 'owner' - ) { - return res - .status(403) - .json({ error: 'Only the store owner can configure a custom cloud server URL' }); - } - upsertSettings(getDatabase(), { - cloud_server_url: requestedUrl, - }); - } - if (cloudSync.getStatus().cloud_deletion_blocked) { + if (currentStatus.cloud_deletion_blocked) { return res.status(409).json({ error: 'Cloud deletion is unresolved; retry or cancel it before re-enabling cloud services.', }); } + if (requestedUrl !== undefined) { + upsertSettings(getDatabase(), { + cloud_server_url: requestedUrl, + }); + cloudSync.reload(); + } // Registration sends contact metadata for FloAdmin support; it does not // create a cloud owner account or grant authentication access. await cloudSync.register(getHttpRequestSignal(req)); diff --git a/main/routes/staff.ts b/main/routes/staff.ts index a70500fe7..d9efd3327 100644 --- a/main/routes/staff.ts +++ b/main/routes/staff.ts @@ -98,6 +98,52 @@ function normalizeStaffEmail(email: unknown): string { .toLowerCase(); } +function syncRegisteredOwnerEmail( + db: ReturnType, + previousOwnerEmail?: string, + nextOwnerEmail?: string, +): boolean { + const currentSettingRow = db.prepare("SELECT value FROM settings WHERE key = 'email'").get() as + | { value: string | null } + | undefined; + const currentSettingEmail = normalizeStaffEmail(currentSettingRow?.value); + const normalizedPrev = normalizeStaffEmail(previousOwnerEmail); + const normalizedNext = normalizeStaffEmail(nextOwnerEmail); + + let targetEmail = ''; + if (normalizedNext && (!currentSettingEmail || currentSettingEmail === normalizedPrev)) { + targetEmail = normalizedNext; + } else { + const matchesActiveOwner = currentSettingEmail + ? Boolean( + db + .prepare( + "SELECT 1 FROM users WHERE lower(email) = ? AND role = 'owner' AND is_active = 1 LIMIT 1", + ) + .get(currentSettingEmail), + ) + : false; + if (matchesActiveOwner) return false; + if (normalizedNext) { + targetEmail = normalizedNext; + } else { + const fallbackOwner = db + .prepare( + "SELECT email FROM users WHERE role = 'owner' AND is_active = 1 AND email IS NOT NULL AND trim(email) != '' ORDER BY created_at ASC, id ASC LIMIT 1", + ) + .get() as { email: string } | undefined; + targetEmail = normalizeStaffEmail(fallbackOwner?.email); + } + } + + if (!targetEmail || targetEmail === currentSettingEmail) return false; + db.prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('email', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(targetEmail, now()); + return true; +} + function normalizeStationIds(value: unknown): string[] | null { if (value === undefined) return []; if ( @@ -464,33 +510,48 @@ router.put('/:id', requireStaffWrite, authRateLimit(), (req: Request, res: Respo role: targetRole, isActive: member.is_active === 1, }); - const result = db - .prepare(` - UPDATE users SET - name = COALESCE(?, name), - email = COALESCE(?, email), - password = ?, - role = COALESCE(?, role), - pin_hash = ?, - tokens_valid_after = ?, - updated_at = ? - WHERE id = ? - AND ( - ? = 0 - OR (SELECT COUNT(*) FROM users WHERE role = 'owner' AND is_active = 1) > 1 - ) - `) - .run( - name || null, - normalizedEmail || null, - hashedPassword, - role || null, - hashedPin, - tokensValidAfter, - now(), - req.params.id, - demotesActiveOwner ? 1 : 0, - ); + let ownerEmailSynced = false; + const result = db.transaction(() => { + const updateResult = db + .prepare(` + UPDATE users SET + name = COALESCE(?, name), + email = COALESCE(?, email), + password = ?, + role = COALESCE(?, role), + pin_hash = ?, + tokens_valid_after = ?, + updated_at = ? + WHERE id = ? + AND ( + ? = 0 + OR (SELECT COUNT(*) FROM users WHERE role = 'owner' AND is_active = 1) > 1 + ) + `) + .run( + name || null, + normalizedEmail || null, + hashedPassword, + role || null, + hashedPin, + tokensValidAfter, + now(), + req.params.id, + demotesActiveOwner ? 1 : 0, + ); + if ( + updateResult.changes > 0 && + member.is_active === 1 && + (member.role === 'owner' || targetRole === 'owner') + ) { + ownerEmailSynced = syncRegisteredOwnerEmail( + db, + member.role === 'owner' ? member.email : undefined, + targetRole === 'owner' ? normalizedEmail || member.email : undefined, + ); + } + return updateResult; + })(); if (result.changes === 0) { captureAdministrationAccessGuard(); return res.status(400).json({ @@ -500,6 +561,13 @@ router.put('/:id', requireStaffWrite, authRateLimit(), (req: Request, res: Respo } invalidateUserAuthCache(req.params.id as string); syncPostHogIdentity(); + if (ownerEmailSynced) { + try { + cloudSync.refreshRegistrationProfile(); + } catch { + // Cloud profile refresh is best-effort and must not fail staff updates. + } + } const updated = db .prepare(`SELECT ${STAFF_SELECT_FIELDS} FROM users WHERE id = ?`) @@ -538,18 +606,32 @@ router.post('/:id/deactivate', requireStaffWrite, (req: Request, res: Response) role: member.role, isActive: false, }); - const result = db - .prepare(` - UPDATE users SET is_active = 0, tokens_valid_after = ?, updated_at = ? - WHERE id = ? AND is_active = 1 - AND (role != 'owner' OR (SELECT COUNT(*) FROM users WHERE role = 'owner' AND is_active = 1) > 1) - `) - .run(changedAt, changedAt, req.params.id); + let ownerEmailSynced = false; + const result = db.transaction(() => { + const updateResult = db + .prepare(` + UPDATE users SET is_active = 0, tokens_valid_after = ?, updated_at = ? + WHERE id = ? AND is_active = 1 + AND (role != 'owner' OR (SELECT COUNT(*) FROM users WHERE role = 'owner' AND is_active = 1) > 1) + `) + .run(changedAt, changedAt, req.params.id); + if (updateResult.changes > 0 && member.role === 'owner') { + ownerEmailSynced = syncRegisteredOwnerEmail(db); + } + return updateResult; + })(); if (result.changes === 0) { captureAdministrationAccessGuard(); return res.status(400).json({ error: 'Cannot deactivate the last owner account' }); } invalidateUserAuthCache(req.params.id as string); + if (ownerEmailSynced) { + try { + cloudSync.refreshRegistrationProfile(); + } catch { + // Cloud profile refresh is best-effort and must not fail staff deactivation. + } + } const updated = db .prepare(`SELECT ${STAFF_SELECT_FIELDS} FROM users WHERE id = ?`) .get(req.params.id); diff --git a/main/services/cloud-sync.ts b/main/services/cloud-sync.ts index 11a71bf40..a9c781ca9 100644 --- a/main/services/cloud-sync.ts +++ b/main/services/cloud-sync.ts @@ -1377,8 +1377,16 @@ export class CloudSyncService { return { ok: true, status: this.getStatus() }; } - async getEmailPreferences(signal?: AbortSignal): Promise> { - const res = await this.signedFetch('/api/pos/email-preferences', { method: 'GET', signal }); + async getEmailPreferences( + signal?: AbortSignal, + serverUrlOverride?: string, + ): Promise> { + const res = await this.signedFetch( + '/api/pos/email-preferences', + { method: 'GET', signal }, + false, + serverUrlOverride, + ); const data = (await res.json().catch(() => ({}))) as Record; throwIfRequestAborted(signal); if (this.cloudDeletionInProgress) throw new Error('Cloud deletion in progress'); @@ -1418,13 +1426,19 @@ export class CloudSyncService { async requestEmailVerification( preferences: { product_updates?: boolean; marketing?: boolean; source?: string } = {}, signal?: AbortSignal, + serverUrlOverride?: string, ): Promise> { throwIfRequestAborted(signal); - const res = await this.signedFetch('/api/pos/email/verification', { - method: 'POST', - body: JSON.stringify(preferences), - signal, - }); + const res = await this.signedFetch( + '/api/pos/email/verification', + { + method: 'POST', + body: JSON.stringify(preferences), + signal, + }, + false, + serverUrlOverride, + ); const data = (await res.json().catch(() => ({}))) as Record; throwIfRequestAborted(signal); if (this.cloudDeletionInProgress) throw new Error('Cloud deletion in progress'); @@ -1442,39 +1456,89 @@ export class CloudSyncService { return data; } + private isDevCloudRecoveryAllowed(): boolean { + return Boolean(app && !app.isPackaged && process.env.FLOCAFE_ALLOW_DEV_CLOUD_RECOVERY === '1'); + } + isTrustedRecoveryServerUrl(rawUrl?: string): boolean { - if (Boolean(app && !app.isPackaged && process.env.FLOCAFE_ALLOW_DEV_CLOUD_RECOVERY === '1')) { + const trustedDefault = normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL); + const isTrustedCandidate = (candidate: string): boolean => { + const normalized = normalizeCloudServerUrl(candidate); + return this.isDevCloudRecoveryAllowed() || normalized === trustedDefault; + }; + try { + if (rawUrl !== undefined) { + return isTrustedCandidate(rawUrl || DEFAULT_CLOUD_SERVER_URL); + } + const persisted = + this.readSettings(getDatabase()).cloud_server_url || DEFAULT_CLOUD_SERVER_URL; + if (!isTrustedCandidate(persisted)) return false; + if (this.settings?.server_url) { + if (!isTrustedCandidate(this.settings.server_url)) return false; + if ( + normalizeCloudServerUrl(this.settings.server_url) !== normalizeCloudServerUrl(persisted) + ) { + return false; + } + } return true; + } catch { + return false; + } + } + + private resolveTrustedRecoveryServerUrl(): string { + if (!this.isTrustedRecoveryServerUrl()) { + throw new CloudRequestError('Password recovery requires the official FloAdmin server', 403); } + if (this.isDevCloudRecoveryAllowed()) { + const persisted = + this.readSettings(getDatabase()).cloud_server_url || DEFAULT_CLOUD_SERVER_URL; + return normalizeCloudServerUrl(persisted); + } + return normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL); + } + + private hasMatchingActiveOwnerEmail(email = this.getRegisteredOwnerEmail()): boolean { + if (!email) return false; try { - const candidate = - rawUrl || this.readSettings(getDatabase()).cloud_server_url || DEFAULT_CLOUD_SERVER_URL; - return ( - normalizeCloudServerUrl(candidate) === normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL) - ); + const row = getDatabase() + .prepare( + "SELECT 1 FROM users WHERE lower(email) = ? AND role = 'owner' AND is_active = 1 LIMIT 1", + ) + .get(email); + return Boolean(row); } catch { return false; } } - /** True when cloud services are on and the store has an owner email FloAdmin can verify and mail. */ + /** True when cloud services are on and the store has an active owner matching the registered email. */ isEmailRecoveryAvailable(): boolean { + const email = this.getRegisteredOwnerEmail(); return ( !isOfflineMode() && this.isCloudAccountAvailable() && this.isTrustedRecoveryServerUrl() && - Boolean(this.getRegisteredOwnerEmail()) + Boolean(email) && + this.hasMatchingActiveOwnerEmail(email) ); } async isOwnerEmailVerified(signal?: AbortSignal): Promise { - return (await this.getEmailPreferences(signal)).verified === true; + const trustedServerUrl = this.resolveTrustedRecoveryServerUrl(); + return (await this.getEmailPreferences(signal, trustedServerUrl)).verified === true; } /** Sends the verification email when the owner email is unverified; returns whether it already is. */ async ensureOwnerEmailVerification(signal?: AbortSignal): Promise { + const trustedServerUrl = this.resolveTrustedRecoveryServerUrl(); if (await this.isOwnerEmailVerified(signal)) return true; - const sent = await this.requestEmailVerification({ source: 'password-recovery' }, signal); + const sent = await this.requestEmailVerification( + { source: 'password-recovery' }, + signal, + trustedServerUrl, + ); return sent.verified === true; } @@ -1490,15 +1554,18 @@ export class CloudSyncService { method: 'GET' | 'POST', signal?: AbortSignal, ): Promise> { - if (!this.isTrustedRecoveryServerUrl()) { - throw new CloudRequestError('Password recovery requires the official FloAdmin server', 403); - } + const trustedServerUrl = this.resolveTrustedRecoveryServerUrl(); throwIfRequestAborted(signal); - const res = await this.signedFetch(pathname, { - method, - body: method === 'POST' ? '{}' : undefined, - signal, - }); + const res = await this.signedFetch( + pathname, + { + method, + body: method === 'POST' ? '{}' : undefined, + signal, + }, + false, + trustedServerUrl, + ); const data = (await res.json().catch(() => ({}))) as Record; throwIfRequestAborted(signal); if (!res.ok) { @@ -3878,6 +3945,7 @@ export class CloudSyncService { pathname: string, init: RequestInit, allowDuringDeletion = false, + serverUrlOverride?: string, ): Promise { if (this.cloudDeletionInProgress && !allowDuringDeletion) throw new Error('Cloud deletion in progress'); @@ -3887,14 +3955,23 @@ export class CloudSyncService { 'Cloud deletion is unresolved; retry or cancel it before using cloud services', ); } - const cfg = this.settings ?? this.loadSettings(); + const cfg = serverUrlOverride + ? (this.loadSettings(false) ?? this.settings) + : (this.settings ?? this.loadSettings()); if (!cfg?.api_key) throw new Error('Cloud POS is not registered'); if (!allowDuringDeletion && !this.isCloudAccountAvailable()) { throw new Error('Cloud account services are unavailable while Cloud services are stopped'); } + if ( + serverUrlOverride !== undefined && + (!this.isTrustedRecoveryServerUrl(serverUrlOverride) || + !this.isTrustedRecoveryServerUrl(cfg.server_url)) + ) { + throw new CloudRequestError('Password recovery requires the official FloAdmin server', 403); + } const method = (init.method || 'GET').toUpperCase(); - const url = endpoint(cfg.server_url, pathname); + const url = endpoint(serverUrlOverride || cfg.server_url, pathname); const body = typeof init.body === 'string' ? init.body : ''; const signedPath = `${url.pathname}${url.search}`; const signedHeaders = this.buildSignedHeaders( diff --git a/tests/cloud-password-recovery.test.ts b/tests/cloud-password-recovery.test.ts index 2c68e585a..819440b5f 100644 --- a/tests/cloud-password-recovery.test.ts +++ b/tests/cloud-password-recovery.test.ts @@ -34,18 +34,28 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { process.env.JWT_SECRET = 'test-secret-cloud-recovery'; process.env.FLO_AUTH_RATE_LIMIT_MAX = '100'; -const express = require('express'); const request = require('supertest'); const bcrypt = require('bcryptjs'); -const { initDatabase, getDatabase, closeDatabase } = require('../main/db'); -const { authRoutes } = require('../main/routes/auth'); +const jwt = require('jsonwebtoken'); +const { + initDatabase, + getDatabase, + closeDatabase, + createBackup, + restoreBackup, + captureRestoreProtectedSettings, + mergeRestoreProtectedSettings, +} = require('../main/db'); +const { authRoutes, getJWTSecret } = require('../main/routes/auth'); +const { settingsRoutes } = require('../main/routes/settings'); +const { staffRoutes } = require('../main/routes/staff'); const { cloudSync, CloudRequestError, CloudSyncService, DEFAULT_CLOUD_SERVER_URL, } = require('../main/services/cloud-sync'); -const { assertOrThrow, assertEqualOrThrow } = require('./helpers/test-setup'); +const { createApp, assertOrThrow, assertEqualOrThrow } = require('./helpers/test-setup'); function isNativeAbiMismatch(error: any): boolean { return ( @@ -64,9 +74,11 @@ try { throw error; } -const app = express(); -app.use(express.json()); -app.use('/api/auth', authRoutes); +const app = createApp({ + '/api/auth': authRoutes, + '/api/settings': settingsRoutes, + '/api/staff': staffRoutes, +}); const RESET_ID = 'reset-1234-abcd'; const ORIGINAL = 'OriginalPass123'; @@ -290,6 +302,301 @@ async function main() { rejectedRedeem instanceof CloudRequestError && rejectedRedeem.status === 403, 'redeemPasswordReset rejects with 403 CloudRequestError on untrusted server URL', ); + + // ── F1(a): Trust check vs in-memory server_url divergence ───────────────── + realCloudSync.reload(); + realCloudSync.stop(); + upsertSetting.run('cloud_server_url', DEFAULT_CLOUD_SERVER_URL); + assertEqualOrThrow( + realCloudSync.isTrustedRecoveryServerUrl(), + false, + 'isTrustedRecoveryServerUrl rejects when in-memory server_url is untrusted despite SQLite default URL', + ); + assertEqualOrThrow( + realCloudSync.isEmailRecoveryAvailable(), + false, + 'isEmailRecoveryAvailable is false when in-memory server_url is untrusted', + ); + + let dispatchedUrls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + dispatchedUrls.push(String(input)); + return new Response(JSON.stringify({ verified: true, reset_id: RESET_ID, status: 'pending' }), { + status: 200, + }); + }) as typeof fetch; + + try { + let staleMemRequestErr: unknown = null; + try { + await realCloudSync.requestPasswordReset(); + } catch (err) { + staleMemRequestErr = err; + } + assertOrThrow( + staleMemRequestErr instanceof CloudRequestError && staleMemRequestErr.status === 403, + 'requestPasswordReset rejects when in-memory server_url is untrusted', + ); + assertEqualOrThrow( + dispatchedUrls.length, + 0, + 'no network call is dispatched when in-memory server_url is untrusted', + ); + + let staleMemVerifyErr: unknown = null; + try { + await realCloudSync.isOwnerEmailVerified(); + } catch (err) { + staleMemVerifyErr = err; + } + assertOrThrow( + staleMemVerifyErr instanceof CloudRequestError && staleMemVerifyErr.status === 403, + 'isOwnerEmailVerified rejects when in-memory server_url is untrusted', + ); + + realCloudSync.reload(); + realCloudSync.stop(); + await realCloudSync.requestPasswordReset(); + assertEqualOrThrow(dispatchedUrls.length, 1, 'trusted recovery request dispatches one fetch'); + assertOrThrow( + dispatchedUrls[0].startsWith(DEFAULT_CLOUD_SERVER_URL), + `trusted recovery request dispatches to DEFAULT_CLOUD_SERVER_URL (got ${dispatchedUrls[0]})`, + ); + } finally { + globalThis.fetch = originalFetch; + } + + // ── F1(b): POST /api/settings/cloud/register early-return before SQLite write ─ + const ownerToken = jwt.sign( + { userId: 'owner-1', email: 'owner@example.com', role: 'owner' }, + getJWTSecret(), + { expiresIn: '1h' }, + ); + upsertSetting.run('cloud_server_url', DEFAULT_CLOUD_SERVER_URL); + upsertSetting.run('cloud_deletion_status', 'failed'); + upsertSetting.run('cloud_services_disabled_by_user', 'false'); + + res = await request(app) + .post('/api/settings/cloud/register') + .set('Authorization', `Bearer ${ownerToken}`) + .send({ cloud_server_url: 'https://custom-blocked.example.com' }); + assertEqualOrThrow( + res.status, + 409, + 'POST /api/settings/cloud/register returns 409 when cloud_deletion_blocked is true', + ); + const storedUrlAfterBlockedRegister = ( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_server_url'").get() as + | { value: string } + | undefined + )?.value; + assertEqualOrThrow( + storedUrlAfterBlockedRegister, + DEFAULT_CLOUD_SERVER_URL, + 'blocked cloud register does not write cloud_server_url to SQLite before early return', + ); + upsertSetting.run('cloud_deletion_status', ''); + + // ── F1(c): Manager cannot overwrite cloud credentials or server URL ─────── + db.prepare( + `INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES ('manager-1', 'Manager', 'manager@example.com', ?, 'manager', 1, datetime('now'), datetime('now'))`, + ).run(bcrypt.hashSync('ManagerPass123', 10)); + const managerToken = jwt.sign( + { userId: 'manager-1', email: 'manager@example.com', role: 'manager' }, + getJWTSecret(), + { expiresIn: '1h' }, + ); + + upsertSetting.run('cloud_api_key', 'owner-api-key'); + upsertSetting.run('cloud_store_id', 'owner-store-id'); + upsertSetting.run('cloud_server_url', 'https://custom-owner.example.com'); + + res = await request(app) + .put('/api/settings/cloud') + .set('Authorization', `Bearer ${managerToken}`) + .send({ cloud_api_key: 'attacker-api-key' }); + assertEqualOrThrow(res.status, 403, 'manager cannot overwrite cloud_api_key via PUT /cloud'); + + res = await request(app) + .put('/api/settings/cloud') + .set('Authorization', `Bearer ${managerToken}`) + .send({ cloud_store_id: 'attacker-store-id' }); + assertEqualOrThrow(res.status, 403, 'manager cannot overwrite cloud_store_id via PUT /cloud'); + + res = await request(app) + .put('/api/settings/cloud') + .set('Authorization', `Bearer ${managerToken}`) + .send({ cloud_server_url: DEFAULT_CLOUD_SERVER_URL }); + assertEqualOrThrow( + res.status, + 403, + 'manager cannot reset cloud_server_url to DEFAULT_CLOUD_SERVER_URL via PUT /cloud', + ); + + res = await request(app) + .post('/api/settings/cloud/register') + .set('Authorization', `Bearer ${managerToken}`) + .send({ cloud_server_url: DEFAULT_CLOUD_SERVER_URL }); + assertEqualOrThrow( + res.status, + 403, + 'manager cannot set cloud_server_url via POST /cloud/register', + ); + + assertEqualOrThrow( + ( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_api_key'").get() as { + value: string; + } + ).value, + 'owner-api-key', + 'cloud_api_key remains unchanged after rejected manager requests', + ); + assertEqualOrThrow( + ( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_store_id'").get() as { + value: string; + } + ).value, + 'owner-store-id', + 'cloud_store_id remains unchanged after rejected manager requests', + ); + assertEqualOrThrow( + ( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_server_url'").get() as { + value: string; + } + ).value, + 'https://custom-owner.example.com', + 'cloud_server_url remains unchanged after rejected manager requests', + ); + + res = await request(app) + .put('/api/settings/cloud') + .set('Authorization', `Bearer ${managerToken}`) + .send({ cloud_sync_enabled: true, cloud_orders_enabled: true }); + assertEqualOrThrow( + res.status, + 200, + 'manager with cloud.manage can still toggle cloud sync/orders flags', + ); + + res = await request(app) + .put('/api/settings/cloud') + .set('Authorization', `Bearer ${ownerToken}`) + .send({ + cloud_server_url: DEFAULT_CLOUD_SERVER_URL, + cloud_api_key: 'owner-api-key', + cloud_store_id: 'owner-store-id', + }); + assertEqualOrThrow( + res.status, + 200, + 'owner can update cloud_server_url, cloud_api_key, and cloud_store_id', + ); + cloudSync.stop(); + realCloudSync.reload(); + realCloudSync.stop(); + + // ── F2(a): Staff email update keeps settings.email in sync ──────────────── + res = await request(app) + .put('/api/staff/owner-1') + .set('Authorization', `Bearer ${ownerToken}`) + .send({ email: 'new-owner@example.com' }); + assertEqualOrThrow(res.status, 200, 'owner email update via PUT /api/staff/:id succeeds'); + assertEqualOrThrow( + realCloudSync.getRegisteredOwnerEmail(), + 'new-owner@example.com', + 'PUT /api/staff/:id syncs settings.email when active owner email changes', + ); + assertEqualOrThrow( + realCloudSync.isEmailRecoveryAvailable(), + true, + 'email recovery remains available after owner email update', + ); + + // ── F2(b): Backup restore & protected settings preserve/resync owner email ─ + db.prepare("UPDATE settings SET value = 'stale@backup.example.com' WHERE key = 'email'").run(); + const staleBackup = await createBackup(path.join(testDir, 'stale-email-backup.db')); + // Restore live state to new-owner@example.com, then restore staleBackup.path + upsertSetting.run('email', 'new-owner@example.com'); + const preservedSettings = captureRestoreProtectedSettings(db); + upsertSetting.run('email', 'stale@backup.example.com'); + mergeRestoreProtectedSettings(db, preservedSettings); + assertEqualOrThrow( + realCloudSync.getRegisteredOwnerEmail(), + 'new-owner@example.com', + 'captureRestoreProtectedSettings and mergeRestoreProtectedSettings preserve settings.email', + ); + + const restoreRes = restoreBackup(staleBackup.path); + assertEqualOrThrow(restoreRes.success, true, 'restoreBackup succeeds'); + assertEqualOrThrow( + realCloudSync.getRegisteredOwnerEmail(), + 'new-owner@example.com', + 'restoreBackup preserves active owner email in settings.email over stale backup email', + ); + fs.rmSync(staleBackup.path, { force: true }); + + // ── F2(c): Recovery precheck & stranded store non-owner escalation guard ── + const postRestoreDb = getDatabase(); + postRestoreDb + .prepare( + `INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES ('cashier-1', 'Cashier', 'cashier@example.com', ?, 'cashier', 1, datetime('now'), datetime('now'))`, + ) + .run(bcrypt.hashSync('CashierPass123', 10)); + + postRestoreDb + .prepare("UPDATE settings SET value = 'cashier@example.com' WHERE key = 'email'") + .run(); + assertEqualOrThrow( + realCloudSync.isEmailRecoveryAvailable(), + false, + 'isEmailRecoveryAvailable is false when settings.email matches only a non-owner account', + ); + + registeredEmail = 'cashier@example.com'; + res = await request(app).post(`${cloud}/request`).send({}); + assertEqualOrThrow( + res.status, + 409, + 'POST /recover-password/cloud/request rejects when registered email does not match an active owner', + ); + + // Strand the store by denying administrative permissions on owner-1. + for (const permissionId of ['settings.manage', 'staff.operational.manage']) { + postRestoreDb + .prepare( + `INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES ('owner-1', ?, 'deny', 'owner-1', datetime('now'), datetime('now'))`, + ) + .run(permissionId); + } + const callsBeforeStranded = redeemCalls; + res = await request(app) + .post(`${cloud}/complete`) + .send({ reset_id: RESET_ID, new_password: 'EscalatedPassword123' }); + assertEqualOrThrow( + res.status, + 404, + 'POST /recover-password/cloud/complete refuses to match a non-owner even when the store is stranded', + ); + assertEqualOrThrow( + redeemCalls, + callsBeforeStranded, + 'stranded non-owner complete attempt does not consume the reset grant', + ); + const cashierRole = ( + postRestoreDb.prepare("SELECT role FROM users WHERE id = 'cashier-1'").get() as { role: string } + ).role; + assertEqualOrThrow( + cashierRole, + 'cashier', + 'cashier-1 is not promoted to owner on a stranded store via stale cloud email', + ); } main()