diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa14bdc..05d58a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### New Features + +* Every command that talks to an instance now obtains a two-factor session when that instance's Partner Portal requires one — `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull` and the GUI. Reads are covered as well as writes: a token reaches every record through GraphQL and runs arbitrary Liquid through `exec liquid`, so protecting only deploys would have protected only the source code. `deploy` and `sync` ask up front, before doing any work; the rest ask when the instance refuses and then retry. The code is exchanged with the **Portal** for a short-lived session token (8 hours) which is cached as `two_factor_session` inside that environment's entry in `.pos` — tightening the file to 0600, since it now holds a credential shorter-lived than the year-long token beside it — so the prompt appears once per session and not once per command. Settings taken from `MPKIT_*` have no `.pos` entry behind them and keep the session for the life of the process instead. `--otp-code` and `POS_PORTAL_OTP_CODE` skip the prompt for scripts, and `POS_PORTAL_SESSION_TOKEN` supplies a session minted elsewhere. The prompt is raised before any spinner starts — a spinner repaints its line on a timer and used to paint straight over it, which looked like a hang. Note that the code never travels through the instance: instances run tenant-authored Liquid, so one that passed through could be harvested and replayed inside its 30-second window. Requires the matching Partner Portal and platformOS releases; against a portal or instance without them, nothing changes. + +* Partner Portal accounts with two-factor authentication enabled can now authenticate from the CLI. `pos-cli env add --email`, `pos-cli env refresh-token`, `pos-cli modules push` and the `pos-cli dns` email fallback prompt for a code (a recovery code works too) when the portal asks for a second factor, and retry the request with it. `--otp-code ` and the `POS_PORTAL_OTP_CODE` environment variable skip the prompt for scripted use; a non-interactive run explains what to set instead of hanging on a prompt that nobody can answer. A rejected code says so instead of blaming the password, and an account the portal has locked for too many attempts stops immediately rather than spending prompts on codes that would be refused unread. pos-cli gives up after three rejected codes, short of the portal's 5-attempt budget, so a typo here cannot trigger the 15-minute lock that is shared with the web UI. Previously these commands reported every one of these as "check if your email/password are correct", which left no way to tell a 2FA challenge from a wrong password — the browser-based `pos-cli env add --url` device flow was unaffected and remains the simplest option. Portals older than the `two_factor_invalid`/`two_factor_locked` responses are still handled: a code pos-cli sent itself can only have been refused for being wrong, since the portal would not have asked for one unless the password had already passed. + ## 6.4.0 (2026-08-20) ### New Features diff --git a/CLAUDE.md b/CLAUDE.md index 39f94220..1670ae48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,8 @@ pos-cli/ │ ├── ServerError.js # Centralized error handling │ ├── settings.js # Environment configuration (.pos file) │ ├── environments.js # Authentication flows +│ ├── utils/twoFactor.js # Partner Portal 2FA: prompt/retry around password auth +│ ├── twoFactorSession.js # Instance 2FA sessions, cached in .pos per environment │ ├── portal.js # Partner Portal API client │ ├── watch.js # File watching for sync mode │ ├── archive.js # Deployment archive creation @@ -248,7 +250,7 @@ Centralized error handling with specific handlers for different HTTP status code ### Important Technical Details #### Configuration Files -- `.pos` - Environment credentials (URL, token, email) as JSON +- `.pos` - Environment credentials (URL, token, email) as JSON. Also caches a `two_factor_session` (`{token, expires_at}`) per environment when an instance requires one; writing that tightens the file to 0600 - `.posignore` - Files to exclude from sync/deploy (gitignore syntax) - `pos-module.json` - Universal platformOS project manifest (analogous to `package.json`). Its presence in a consuming app is normal — it lists `dependencies`. Publishable modules additionally have `machine_name`, `version`, and `name`. It is the **sole source** for all `modules` CLI commands (`install`, `update`, `push`, `version`, `migrate`). - `pos-module.lock.json` - Resolved dependency versions (separate prod/dev sections) plus a `registries` map recording which registry each module was resolved from; makes the lock self-contained for `--frozen` mode diff --git a/README.md b/README.md index 453fcf80..119af0f0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,42 @@ Note that [`modules install`/`update`](#installation) take their registry URL fr The Instance details page in the Partner Portal shows the `env add` command pre-filled with both URLs, ready to copy. +#### Two-Factor Authentication + +If your Partner Portal account has two-factor authentication enabled, the token `env add` mints is good for a year against every Instance you can deploy to, so the portal asks for a second factor before issuing one. + +Nothing extra is needed for the default flow: `pos-cli env add [environment] --url [url]` (no `--email`) authorizes in the browser, where you answer the 2FA challenge like any other portal login. + +When you authenticate with `--email`, pos-cli prompts for the code after your password: + + pos-cli env add staging --url https://example.com --email you@example.com + Password: ****** + This account has two-factor authentication enabled. Your password was accepted. + Two-factor code (or a recovery code): 123456 + +A recovery code from the list you saved when you enabled 2FA is accepted anywhere the six-digit code is. To skip the prompt, pass `--otp-code` or set `POS_PORTAL_OTP_CODE`: + + pos-cli env add staging --url https://example.com --email you@example.com --otp-code 123456 + POS_PORTAL_OTP_CODE=123456 pos-cli env refresh-token staging + +The same applies to `pos-cli env refresh-token` and `pos-cli modules push`. In a non-interactive environment (CI, a `--json` run) pos-cli will not prompt — supply `POS_PORTAL_OTP_CODE`, or prefer `pos-cli env add [environment] --url [url] --token [token]`, which needs neither a password nor a code. + +#### Instance Sessions + +An instance can require that it is used with a credential whose holder has proved a second factor — the year-long token in `.pos` is not one. This covers **every command that talks to the instance**, not just deploys: `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull`, the GUI. A token reaches every record in the instance through GraphQL and runs arbitrary Liquid through `exec liquid`, so reads are not exempt. + +The first command that needs one asks for a code: + + pos-cli deploy staging + This instance requires a two-factor code. + Two-factor code (or a recovery code): 123456 + +The Partner Portal decides how long the session lasts and pos-cli prints the expiry as it starts one (`Two-factor session started — it expires in 59 minutes.`). It is cached as `two_factor_session` inside that environment's entry in `.pos`, so every later command in that window runs without a prompt — one code unlocks the whole session, whichever command asked for it. Caching a session tightens `.pos` to owner-only (0600), since it now holds a credential shorter-lived than the year-long token. When settings come from `MPKIT_URL`/`MPKIT_EMAIL`/`MPKIT_TOKEN` there is no `.pos` entry to write to, and the session is kept only for the life of the process — enough for one long `sync`, but the next command asks again. + +For scripted runs, set `POS_PORTAL_OTP_CODE`: it works for **every** command, while the `--otp-code` flag exists only on `deploy`, `sync`, `gui serve`, `env add`, `env refresh-token` and `modules push`. A recovery code works in either and does not expire on a timer. If your orchestrator already holds a session token, `POS_PORTAL_SESSION_TOKEN` supplies it directly and skips the exchange entirely; pos-cli only reads that variable, and never writes a session token to its output. `deploy`, `sync` and `gui serve` ask up front, before doing any work; other commands ask at the moment the instance refuses, and then retry the request that was refused. `gui serve` asks even without `--sync`, because the GUI proxies every panel query through the same credential. If a session expires part-way through a `sync`, watch mode stops rather than prompting into a queue that is mid-flight — it reports what did not reach the instance and asks you to restart the command you started, which takes a code once, up front. Under `gui serve --sync` that stops the web server too: the same session the watcher was refused is one no panel query could have used either. + +pos-cli stops after three rejected codes. The Partner Portal locks an account for 15 minutes after five, and that counter is shared with the web UI, so the remaining attempts are left for you to spend deliberately. If the account is already locked, pos-cli says so and stops without asking for a code — while the lock holds, even a correct code is refused unread. + The configuration for your environments is stored in the `.pos` file. ### Syncing Changes diff --git a/bin/pos-cli-deploy.js b/bin/pos-cli-deploy.js index 41eccbf5..3b19ee91 100755 --- a/bin/pos-cli-deploy.js +++ b/bin/pos-cli-deploy.js @@ -4,6 +4,7 @@ import { program } from '../lib/program.js'; import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; import deployStrategy from '../lib/deploy/strategy.js'; +import { ensureSessionForCommand } from '../lib/twoFactorSession.js'; program .name('pos-cli deploy') @@ -14,6 +15,10 @@ program .option('-p --partial-deploy', 'Partial deployment, does not remove data from directories missing from the build') .option('--dry-run', 'Validate the release on the server without applying any changes') .option('-v, --verbose', 'Show full file paths in deploy report (default: summary only)') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE' + ) .action(async (environment, params) => { if (params.force) logger.Warn('-f flag is deprecated and does not do anything.'); @@ -30,7 +35,10 @@ program MARKETPLACE_EMAIL: authData.email, MARKETPLACE_TOKEN: authData.token, MARKETPLACE_URL: authData.url, - PARTNER_PORTAL_HOST: authData.partner_portal_url, + // Only when there is one: process.env stringifies, so assigning undefined here sets + // the literal "undefined", which Portal.url() and the Gateway would both read as a + // real portal URL. + ...(authData.partner_portal_url ? { PARTNER_PORTAL_HOST: authData.partner_portal_url } : {}), MARKETPLACE_ENV: environment, CI: process.env.CI === 'true', // TODO: Get rid off global system env, make it normal argument to function. @@ -39,6 +47,10 @@ program VERBOSE: !!params.verbose }); + // Before any work or any spinner: if this instance needs a two-factor session, ask for + // the code now rather than partway through the upload. + await ensureSessionForCommand(authData, params); + deployStrategy.run({ strategy, opts: { env, authData, params } }); }); diff --git a/bin/pos-cli-env-add.js b/bin/pos-cli-env-add.js index b375599b..91b77ca3 100755 --- a/bin/pos-cli-env-add.js +++ b/bin/pos-cli-env-add.js @@ -1,8 +1,7 @@ #!/usr/bin/env node import { program } from '../lib/program.js'; -import ServerError from '../lib/ServerError.js'; -import logger from '../lib/logger.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; import addEnv from '../lib/envs/add.js'; program.showHelpAfterError(); @@ -20,14 +19,15 @@ program '--token ', 'if you have a token you can add it directly to pos-cli configuration without connecting to portal' ) + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing' + ) .action(async (environment, params) => { try { await addEnv(environment, params); } catch (e) { - if (ServerError.isNetworkError(e)) - await ServerError.handler(e); - else - await logger.Error(e); + await reportCommandError(e); } }); diff --git a/bin/pos-cli-env-refresh-token.js b/bin/pos-cli-env-refresh-token.js index 73eff094..acf34d3b 100644 --- a/bin/pos-cli-env-refresh-token.js +++ b/bin/pos-cli-env-refresh-token.js @@ -1,21 +1,21 @@ import { program } from '../lib/program.js'; -import logger from '../lib/logger.js'; import { fetchSettings } from '../lib/settings.js'; import refreshToken from '../lib/envs/refreshToken.js'; -import ServerError from '../lib/ServerError.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; program .name('pos-cli env refresh-token') .arguments('[environment]', 'name of environment. Example: staging') - .action(async (environment, _params) => { + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only used by environments that store an email; you are prompted for one when it is missing' + ) + .action(async (environment, params) => { try { const authData = await fetchSettings(environment); - await refreshToken(environment, authData); + await refreshToken(environment, authData, { otpCode: params.otpCode }); } catch (e) { - if (ServerError.isNetworkError(e)) - await ServerError.handler(e); - else - await logger.Error(e); + await reportCommandError(e); process.exit(1); } }); diff --git a/bin/pos-cli-gui-serve.js b/bin/pos-cli-gui-serve.js index cd24eefb..3d7ea6cc 100755 --- a/bin/pos-cli-gui-serve.js +++ b/bin/pos-cli-gui-serve.js @@ -8,6 +8,7 @@ import { fetchSettings } from '../lib/settings.js'; import { start as server } from '../lib/server.js'; import logger from '../lib/logger.js'; import ServerError from '../lib/ServerError.js'; +import { ensureSessionForCommand } from '../lib/twoFactorSession.js'; const DEFAULT_CONCURRENCY = 3; @@ -18,8 +19,13 @@ program .option('-b, --host ', 'use HOST', 'localhost') .option('-o, --open', 'when ready, open default browser with graphiql') .option('-s, --sync', 'Sync files') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for the session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE' + ) .action(async (environment, params) => { const authData = await fetchSettings(environment, program); + const partnerPortalHost = process.env.PARTNER_PORTAL_HOST || authData.partner_portal_url; const env = Object.assign(process.env, { MARKETPLACE_EMAIL: authData.email, @@ -27,9 +33,23 @@ program MARKETPLACE_URL: authData.url, HOST: params.host, PORT: params.port, - CONCURRENCY: process.env.CONCURRENCY || DEFAULT_CONCURRENCY + CONCURRENCY: process.env.CONCURRENCY || DEFAULT_CONCURRENCY, + // watch.js and server.js both rebuild their Gateway settings from these variables, + // losing the environment's partner_portal_url on the way. A Gateway recovers it from + // .pos by URL, but Portal.url() — which every other portal call reads — cannot, so a + // private-stack instance would step up against the public portal without this. Same + // export `sync` and `deploy` make, and omitted rather than set to undefined for the + // same reason: process.env would store the string "undefined". + ...(partnerPortalHost ? { PARTNER_PORTAL_HOST: partnerPortalHost } : {}) }); + // Asked for before anything else, and not only when --sync is on: the GUI proxies + // every panel query through the same credential, and SwaggerProxy.client below already + // calls the instance. Without this the first step-up would be triggered by a browser + // request or a file save and raise a readline prompt from inside a running web server, + // where nobody is watching stdin. Here it is an ordinary prompt on an idle terminal. + await ensureSessionForCommand(authData, params); + try { const client = await SwaggerProxy.client(environment); server(env, client); @@ -47,7 +67,12 @@ program } if (params.sync){ - const { watcher, liveReloadServer } = await watch(env, true, false); + const { watcher, liveReloadServer } = await watch(env, true, false, { + // Names this command, not `pos-cli sync`, so an expired session tells the + // operator to restart the thing they actually started — the GUI server comes + // down with the watcher, so `pos-cli sync` alone would not bring it back. + restartCommand: ['pos-cli gui serve', environment, '--sync'].filter(Boolean).join(' ') + }); setupGracefulShutdown({ watcher, liveReloadServer, context: 'GUI' }); } } catch (e) { diff --git a/bin/pos-cli-modules-list.js b/bin/pos-cli-modules-list.js index becb3aad..448abb37 100755 --- a/bin/pos-cli-modules-list.js +++ b/bin/pos-cli-modules-list.js @@ -5,6 +5,7 @@ import { program } from '../lib/program.js'; import Gateway from '../lib/proxy.js'; import logger from '../lib/logger.js'; import { fetchSettings } from '../lib/settings.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; program .name('pos-cli modules list') @@ -22,7 +23,7 @@ program logger.Info(`\t- ${module}`, { hideTimestamp: true }); }); } - }).catch(logger.Debug); + }).catch(error => reportCommandError(error, { prefix: 'Listing modules failed' })); }); program.parse(process.argv); diff --git a/bin/pos-cli-modules-push.js b/bin/pos-cli-modules-push.js index 56a32eae..7b2df4e1 100644 --- a/bin/pos-cli-modules-push.js +++ b/bin/pos-cli-modules-push.js @@ -13,6 +13,10 @@ program .requiredOption('--email ', 'Partner Portal account email. Example: foo@example.com') .option('--path ', 'module root directory, default is current directory') .option('--name ', 'name of the module you would like to publish') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing' + ) .action(async (params) => { if (params.path) process.chdir(params.path); checkParams(params); diff --git a/bin/pos-cli-sync.js b/bin/pos-cli-sync.js index a96148a1..8f5a4e49 100755 --- a/bin/pos-cli-sync.js +++ b/bin/pos-cli-sync.js @@ -6,6 +6,7 @@ import { start as watchStart, setupGracefulShutdown, sendFile } from '../lib/wat import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; import Gateway from '../lib/proxy.js'; +import { ensureSessionForCommand } from '../lib/twoFactorSession.js'; const DEFAULT_CONCURRENCY = 3; @@ -17,15 +18,30 @@ program .option('-o, --open', 'When ready, open default browser with instance') .option('-f, --file-path ', 'sync single file and exit') .option('-l, --livereload', 'Use livereload') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE' + ) .action(async (environment, params) => { const authData = await fetchSettings(environment); + const partnerPortalHost = process.env.PARTNER_PORTAL_HOST || authData.partner_portal_url; const env = Object.assign(process.env, { MARKETPLACE_EMAIL: authData.email, MARKETPLACE_TOKEN: authData.token, MARKETPLACE_URL: authData.url, - CONCURRENCY: process.env.CONCURRENCY || params.concurrency + CONCURRENCY: process.env.CONCURRENCY || params.concurrency, + // watch.js rebuilds the Gateway's settings from these env vars, losing the + // environment's partner_portal_url on the way. The Gateway can recover it from .pos + // by URL, but Portal.url() — which every other portal call reads — cannot, so it is + // still exported here. Same export `deploy` does, and for the same reason it is + // omitted rather than set to undefined: process.env would store "undefined". + ...(partnerPortalHost ? { PARTNER_PORTAL_HOST: partnerPortalHost } : {}) }); + // Asked for before the watcher starts and before any spinner: sync then runs + // unattended for hours, and a prompt raised underneath a spinner is painted over. + await ensureSessionForCommand(authData, params); + // Handle single file sync if (params.filePath) { const gateway = new Gateway({ @@ -48,7 +64,13 @@ program } // Continue with watch mode - const { watcher, liveReloadServer } = await watchStart(env, params.directAssetsUpload, params.livereload); + const { watcher, liveReloadServer } = await watchStart(env, params.directAssetsUpload, params.livereload, { + // Named so an expired session prints a line the operator can copy verbatim: it has + // to restart *this* run, and the environment is not recoverable from the message + // otherwise. Only watch mode gets this — the `sync -f` path above is one request and + // steps up in place, which is right for a command that exits straight after. + restartCommand: ['pos-cli sync', environment].filter(Boolean).join(' ') + }); setupGracefulShutdown({ watcher, liveReloadServer, context: 'Sync' }); diff --git a/lib/dns/auth.js b/lib/dns/auth.js index 32b538a0..e236280e 100644 --- a/lib/dns/auth.js +++ b/lib/dns/auth.js @@ -21,10 +21,10 @@ const resolveSettings = (envName, label) => { return settings; }; -const authenticateInteractively = async (baseUrl, email) => { +const authenticateInteractively = async (baseUrl, email, otpCode) => { await logger.Info(`Authenticating ${email} on ${baseUrl}`); const password = await readPassword(); - return DnsPortalClient.authenticate(baseUrl, email, password); + return DnsPortalClient.authenticate(baseUrl, email, password, { otpCode, interactive: true }); }; // Resolves everything a dns command needs to talk to one portal ("source" or "target"): @@ -34,6 +34,7 @@ const resolvePortalContext = async (envName, { portalUrl, token, email, + otpCode, instanceUuid, label = 'portal', readOnly = false, @@ -62,7 +63,7 @@ const resolvePortalContext = async (envName, { 'which would corrupt --json output — pass --token or use a stored environment token instead.' ); } - authToken = await authenticateInteractively(baseUrl, email); + authToken = await authenticateInteractively(baseUrl, email, otpCode); } if (!authToken) { throw new Error( @@ -80,7 +81,7 @@ const resolvePortalContext = async (envName, { if (!(error instanceof PortalAuthError) || email || !fallbackEmail || !tty || !interactive) throw error; await logger.Warn(error.message); - authToken = await authenticateInteractively(baseUrl, fallbackEmail); + authToken = await authenticateInteractively(baseUrl, fallbackEmail, otpCode); client = new DnsPortalClient({ baseUrl, token: authToken, readOnly }); await client.listInstances(); } diff --git a/lib/dns/portalClient.js b/lib/dns/portalClient.js index ca68593f..70c3afff 100644 --- a/lib/dns/portalClient.js +++ b/lib/dns/portalClient.js @@ -1,11 +1,9 @@ import { apiRequest } from '../apiRequest.js'; +import { withTwoFactor } from '../utils/twoFactor.js'; +import { normalizeBaseUrl } from '../utils/url.js'; const DESTRUCTIVE_PREFIX = 'Destructive DNS change blocked'; -// Canonical portal base url — auth.js compares portalUrl values (same-instance guard, -// protected-host check), so every construction path must normalize identically. -const normalizeBaseUrl = (url) => String(url).replace(/\/+$/, ''); - class PortalAuthError extends Error { constructor(portalUrl) { super( @@ -112,14 +110,21 @@ class DnsPortalClient { this.readOnly = readOnly; } - static async authenticate(baseUrl, email, password) { + // otpCode/interactive are passed through to withTwoFactor: a 2FA account answers a + // password-only request with 401 two_factor_required, which would otherwise be reported + // as an expired token. `interactive: false` (a --json run, where a prompt would corrupt + // the output) turns that into an explanatory error instead of a prompt. + static async authenticate(baseUrl, email, password, { otpCode, interactive } = {}) { const base = normalizeBaseUrl(baseUrl); try { - const response = await apiRequest({ - method: 'POST', - uri: `${base}/api/authenticate`, - body: { email, password } - }); + const response = await withTwoFactor( + code => apiRequest({ + method: 'POST', + uri: `${base}/api/authenticate`, + body: code ? { email, password, otp_code: code } : { email, password } + }), + { otpCode, interactive } + ); if (!response || !response.auth_token) throw new PortalAuthError(base); return response.auth_token; } catch (error) { diff --git a/lib/envs/add.js b/lib/envs/add.js index 4caf0924..7787a474 100644 --- a/lib/envs/add.js +++ b/lib/envs/add.js @@ -1,8 +1,7 @@ -import Portal from '../portal.js'; import logger from '../logger.js'; import * as validate from '../validators/index.js'; import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js'; -import { readPassword } from '../utils/password.js'; +import { passwordLogin } from './passwordLogin.js'; const checkParams = (env, params) => { if (params.email) validate.email(params.email); @@ -18,13 +17,6 @@ const saveToken = (settings, token) => { logger.Success(`Environment ${settings.url} as ${settings.environment} has been added successfully.`); }; -const login = async (email, password, url) => { - return Portal.login(email, password, url) - .then(response => { - if (response) return Promise.resolve(response[0].token); - }); -}; - const addEnv = async (environment, params) => { logger.Debug(`[addEnv] Adding environment: ${environment}`); logger.Debug(`[addEnv] URL: ${params.url}`); @@ -48,15 +40,7 @@ const addEnv = async (environment, params) => { } else if (!params.email){ token = await deviceAuthorizationFlow(params.url); } else { - logger.Info( - `Please make sure that you have a permission to deploy. \n You can verify it here: ${Portal.url()}/me/permissions`, - { hideTimestamp: true } - ); - - const password = await readPassword(); - logger.Info(`Asking ${Portal.url()} for access token...`); - - token = await login(params.email, password, params.url); + token = await passwordLogin({ email: params.email, url: params.url, otpCode: params.otpCode }); } if (token) { diff --git a/lib/envs/passwordLogin.js b/lib/envs/passwordLogin.js new file mode 100644 index 00000000..c946fb64 --- /dev/null +++ b/lib/envs/passwordLogin.js @@ -0,0 +1,35 @@ +import Portal from '../portal.js'; +import logger from '../logger.js'; +import { readPassword } from '../utils/password.js'; +import { withTwoFactor } from '../utils/twoFactor.js'; + +/** + * Mints a long-lived Partner Portal token from an email and password. + * + * Shared by `env add` and `env refresh-token`, which want the same thing and differ only + * in what they do with the token afterwards — keeping one copy is what stops the two + * commands drifting in how they authenticate. + * + * The token this mints is good for a year against every instance the user can deploy to, + * so the portal asks a 2FA account for its second factor before issuing one; withTwoFactor + * does the prompting and the retries. + * + * @param {{ email: string, url: string, otpCode?: string }} params + * @returns {Promise} the token, or undefined when the portal returned none + */ +const passwordLogin = async ({ email, url, otpCode }) => { + logger.Info( + `Please make sure that you have a permission to deploy. \n You can verify it here: ${Portal.url()}/me/permissions`, + { hideTimestamp: true } + ); + + const password = await readPassword(); + logger.Info(`Asking ${Portal.url()} for access token...`); + + return withTwoFactor( + code => Portal.login(email, password, url, code).then(response => (response ? response[0].token : undefined)), + { otpCode } + ); +}; + +export { passwordLogin }; diff --git a/lib/envs/refreshToken.js b/lib/envs/refreshToken.js index 4086dc61..7313bede 100644 --- a/lib/envs/refreshToken.js +++ b/lib/envs/refreshToken.js @@ -1,30 +1,14 @@ -import Portal from '../portal.js'; import logger from '../logger.js'; -import { readPassword } from '../utils/password.js'; import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js'; +import { passwordLogin } from './passwordLogin.js'; -const login = async (email, password, url) => { - return Portal.login(email, password, url) - .then(response => { - if (response) return Promise.resolve(response[0].token); - }); -}; - -const refreshToken = async (environment, authData) => { +const refreshToken = async (environment, authData, { otpCode } = {}) => { let token; if (!authData.email) { token = await deviceAuthorizationFlow(authData.url); } else { - logger.Info( - `Please make sure that you have a permission to deploy. \n You can verify it here: ${Portal.url()}/me/permissions`, - { hideTimestamp: true } - ); - - const password = await readPassword(); - logger.Info(`Asking ${Portal.url()} for access token...`); - - token = await login(authData.email, password, authData.url); + token = await passwordLogin({ email: authData.email, url: authData.url, otpCode }); } if (token) { diff --git a/lib/modules.js b/lib/modules.js index e20562fd..766a29fd 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -11,6 +11,8 @@ import { presignUrlForPortal } from './presignUrl.js'; import { uploadFile } from './s3UploadFile.js'; import waitForStatus from './data/waitForStatus.js'; import { readPassword } from './utils/password.js'; +import { withTwoFactor } from './utils/twoFactor.js'; +import { reportCommandError } from './reportCommandError.js'; import ServerError from './ServerError.js'; import { POS_MODULE_FILE as moduleManifestFileName, POS_MODULE_LOCK_FILE as moduleLockFileName } from './modules/paths.js'; @@ -159,18 +161,19 @@ const getModule = async (token, name) => { const getToken = async (params) => { const password = process.env.POS_PORTAL_PASSWORD || await readPassword(); logger.Info(`Asking ${Portal.url()} for access token...`); - return portalAuthToken(params.email, password); + return portalAuthToken(params.email, password, params.otpCode); }; -const portalAuthToken = async (email, password) => { +const portalAuthToken = async (email, password, otpCode) => { try { - const token = await Portal.jwtToken(email, password); + const token = await withTwoFactor(code => Portal.jwtToken(email, password, code), { otpCode }); return token.auth_token; } catch (e) { - if (ServerError.isNetworkError(e)) - await ServerError.handler(e); - else - process.exit(1); + // A TwoFactorError carries a multi-line, actionable message and must be printed, not + // swallowed by the silent exit below — reportCommandError owns that rule for every + // command, network failures included. + if (e.name === 'TwoFactorError' || ServerError.isNetworkError(e)) await reportCommandError(e); + else process.exit(1); } }; diff --git a/lib/ora.js b/lib/ora.js index 543f6f03..9d8af52a 100644 --- a/lib/ora.js +++ b/lib/ora.js @@ -32,6 +32,34 @@ import ora from 'ora'; * The cost of opting out of discardStdin is cosmetic: keys typed during a spinner echo * over the spinner line. */ -const spinner = (options = {}) => ora({ discardStdin: false, ...options }); +// Every spinner this module has handed out. A spinner repaints its line on a timer, so +// anything else that writes to the terminal while one is up -- a prompt, above all -- is +// overwritten between keystrokes. pauseActiveSpinners lets that code clear the line first. +// +// Which of them are actually drawing is ora's own `isSpinning`, so there is nothing to +// keep in step here: no wrapping of start/stop, and no assumption about which of +// succeed/fail/warn routes through which. It is also the more accurate answer -- off a +// TTY, start() prints one line and never begins repainting, and such a spinner must not +// be "resumed" into printing it twice. +const created = new Set(); + +const spinner = (options = {}) => { + const instance = ora({ discardStdin: false, ...options }); + created.add(instance); + return instance; +}; + +/** + * Clears every spinner that is currently drawing and returns a function that restarts + * them. Use it around anything that needs the terminal to itself -- notably the + * two-factor prompt, which is otherwise painted over and looks like a hang. + */ +const pauseActiveSpinners = () => { + const paused = [...created].filter(instance => instance.isSpinning); + paused.forEach(instance => instance.stop()); + + return () => paused.forEach(instance => instance.start()); +}; export default spinner; +export { pauseActiveSpinners }; diff --git a/lib/portal.js b/lib/portal.js index c73f253f..440d3647 100644 --- a/lib/portal.js +++ b/lib/portal.js @@ -1,24 +1,62 @@ import { apiRequest } from './apiRequest.js'; import logger from './logger.js'; +import { normalizeBaseUrl } from './utils/url.js'; const Portal = { url: () => { return process.env.PARTNER_PORTAL_HOST || 'https://partners.platformos.com'; }, - login: (email, password, url) => { + // otpCode travels in its own header rather than as a third colon-delimited field of + // UserAuthorization: a password may contain a colon, and there would be no telling which + // segment was which. + login: (email, password, url, otpCode) => { logger.Debug('Portal.login ' + email + ' to ' + Portal.url()); + const headers = { UserAuthorization: `${email}:${password}`, InstanceDomain: url }; + if (otpCode) headers.UserOtpCode = otpCode; + return apiRequest({ uri: `${Portal.url()}/api/user_tokens`, - headers: { UserAuthorization: `${email}:${password}`, InstanceDomain: url } + headers }); }, - jwtToken: (email, password) => { + jwtToken: (email, password, otpCode) => { + const formData = { email: email, password: password }; + if (otpCode) formData.otp_code = otpCode; + return apiRequest({ method: 'POST', uri: `${Portal.url()}/api/authenticate`, - formData: { email: email, password: password } + formData + }); + }, + // What the Portal knows about a token, including whether its holder must prove a second + // factor before deploying and whether they already have. The Instance asks this same + // endpoint when it validates the token, so both sides read one verdict. + tokenInfo: ({ portalUrl, token }) => { + const base = normalizeBaseUrl(portalUrl || Portal.url()); + + return apiRequest({ + method: 'GET', + uri: `${base}/oauth/token/info`, + headers: { Authorization: `Bearer ${token}` } + }); + }, + + // Exchanges a credential the caller already holds for a short-lived two-factor session + // an Instance will accept for a deploy. Deliberately a Portal call and not an Instance + // one: Instances run tenant-authored code, so a code that travelled through one could be + // harvested and replayed inside the thirty seconds it stays valid. + twoFactorSession: ({ portalUrl, token, instanceDomain, otpCode }) => { + const base = normalizeBaseUrl(portalUrl || Portal.url()); + logger.Debug(`[Portal.twoFactorSession] Requesting a session from ${base} for ${instanceDomain}`); + + return apiRequest({ + method: 'POST', + uri: `${base}/api/two_factor_session`, + headers: { Authorization: `Bearer ${token}` }, + body: { instance_domain: instanceDomain, otp_code: otpCode || undefined } }); }, findModules: (token, name) => { diff --git a/lib/proxy.js b/lib/proxy.js index 1f5002d8..2ae40098 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -1,44 +1,111 @@ import { apiRequest } from './apiRequest.js'; import logger from './logger.js'; +import Portal from './portal.js'; +import { isSessionLive, portalUrlFor, readSession, sessionInterruptedMessage, startSession } from './twoFactorSession.js'; +import { isTwoFactorRequired, TwoFactorError } from './utils/twoFactor.js'; import pkg from '../package.json' with { type: 'json' }; const version = pkg.version; class Gateway { - constructor({ url, token, email }, client) { + constructor({ url, token, email, partner_portal_url, restartCommand }, client) { this.url = url; this.api_url = `${url}/api/app_builder`; this.private_api_url = `${url}/api/private`; this.client = client; - + this.token = token; + // Which portal to step up against. An operator's explicit override comes first, then + // the environment .pos registered this URL under — which is how a private-stack + // instance finds its own portal even when the caller rebuilt these settings from + // MARKETPLACE_* variables along the way (sync, push and the GUI server do). + this.partnerPortalUrl = partner_portal_url || process.env.PARTNER_PORTAL_HOST || portalUrlFor(url) || Portal.url(); + + // No Authorization here: the credential is resolved per request by request(), which is + // the only place that knows whether a two-factor session is standing in for the token. this.defaultHeaders = { - Authorization: `Token ${token}`, InstanceDomain: url, 'User-Agent': `pos-cli/${version}`, From: email }; - const censored = Object.assign({}, this.defaultHeaders, { Authorization: 'Token: ' }); - logger.Debug(`Request headers: ${JSON.stringify(censored, null, 2)}`); + // Set only by a caller that must not be prompted where it stands — watch mode, whose + // queue is mid-flight and whose operator may not be at the keyboard. When it is set, an + // instance asking for a session ends the run with a message naming this command to run + // again, instead of stepping up mid-queue. Left unset everywhere else, so every short + // command keeps stepping up in place and retrying the request that was refused. + this.restartCommand = restartCommand; + + // Resolved once, not per request: readSession re-reads and re-parses .pos on every + // call, and a deploy polls its status hundreds of times. Nothing is missed by not + // re-reading — a step-up below assigns this.session, and one that ages out is handled + // by authorizationHeader falling back to the token. + this.session = readSession(url, this.partnerPortalUrl); + + logger.Debug(`Request headers: ${JSON.stringify(this.defaultHeaders, null, 2)}`); + } + + // The credential to present: a two-factor session when one is in force for this + // instance, otherwise the long-lived token from .pos. A session that ages out during a + // long run falls back to the token, which is what makes the instance answer + // two_factor_required and so drives the step-up below. + authorizationHeader() { + const credential = isSessionLive(this.session) ? this.session.token : this.token; + return { Authorization: `Token ${credential}` }; + } + + // Every Gateway request goes through here so there is exactly one place that knows how + // to answer an Instance asking for a second factor: step up with the Portal, then retry + // the request that was refused. Only the two_factor_required body triggers it, so an + // expired or revoked token still fails as the authentication error it is. + // + // It is also where the shared headers are applied, so no caller has to pass them. + async request(options) { + const withAuth = () => ({ + ...options, + headers: { ...this.defaultHeaders, ...options.headers, ...this.authorizationHeader() } + }); + + try { + return await apiRequest(withAuth()); + } catch (error) { + if (!isTwoFactorRequired(error)) throw error; + + // this.session is set only if this run ever held one, which is what tells "it aged + // out under us" apart from "this instance wanted one and we never had it" — the two + // need different first sentences, and only this side knows which happened. + if (this.restartCommand) { + throw new TwoFactorError( + sessionInterruptedMessage({ command: this.restartCommand, expired: !!this.session }) + ); + } + + this.session = await startSession({ + portalUrl: this.partnerPortalUrl, + instanceUrl: this.url, + token: this.token + }); + + return apiRequest(withAuth()); + } } cloneInstanceStatus(id) { - return apiRequest({ method: 'GET', uri: `${this.api_url}/instance_clone_imports/${id}`, headers: this.defaultHeaders }); + return this.request({ method: 'GET', uri: `${this.api_url}/instance_clone_imports/${id}` }); } cloneInstanceInit(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_imports`, json: formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/instance_clone_imports`, json: formData }); } cloneInstanceExport(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_exports`, json: formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/instance_clone_exports`, json: formData }); } appExportStart(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases/backup`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/marketplace_releases/backup`, formData }); } appExportStatus(id) { - return apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/marketplace_releases/${id}` }); } dataExportStart(export_internal, csv_import = false) { @@ -47,7 +114,7 @@ class Gateway { if (csv_import) { uri += '?csv_export=true'; } - return apiRequest({ method: 'POST', uri, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri, formData }); } dataExportStatus(id, csv_import = false) { @@ -55,11 +122,11 @@ class Gateway { if (csv_import) { uri += '?csv_export=true'; } - return apiRequest({ uri, headers: this.defaultHeaders }); + return this.request({ uri }); } dataImportStart(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/imports`, json: formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/imports`, json: formData }); } dataImportStatus(id, csv_import = false) { @@ -67,33 +134,32 @@ class Gateway { if (csv_import) { uri += '?csv_import=true'; } - return apiRequest({ uri, headers: this.defaultHeaders }); + return this.request({ uri }); } dataUpdate(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/data_updates`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/data_updates`, formData }); } dataClean(confirmation, include_schema) { const uri = `${this.api_url}/data_clean`; - return apiRequest({ + return this.request({ method: 'POST', uri, - json: { confirmation, include_schema }, - headers: this.defaultHeaders + json: { confirmation, include_schema } }); } dataCleanStatus(id) { - return apiRequest({ method: 'GET', uri: `${this.api_url}/data_clean/${id}`, headers: this.defaultHeaders }); + return this.request({ method: 'GET', uri: `${this.api_url}/data_clean/${id}` }); } ping() { - return apiRequest({ uri: `${this.api_url}/logs`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/logs` }); } logs(json, { signal } = {}) { - return apiRequest({ uri: `${this.api_url}/logs?last_id=${json.lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); + return this.request({ uri: `${this.api_url}/logs?last_id=${json.lastId}`, json: true, forever: true, signal }); } logsv2(params) { @@ -107,77 +173,75 @@ class Gateway { } getInstance() { - return apiRequest({ uri: `${this.api_url}/instance`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/instance` }); } getStatus(id) { - return apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, forever: true, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/marketplace_releases/${id}`, forever: true }); } graph(json) { - return apiRequest({ method: 'POST', uri: `${this.url}/api/graph`, json, forever: true, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.url}/api/graph`, json, forever: true }); } liquid(json) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/liquid_exec`, json, forever: true, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/liquid_exec`, json, forever: true }); } test(name) { - return apiRequest({ uri: `${this.url}/_tests/run.js?name=${name}`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.url}/_tests/run.js?name=${name}` }); } testRunAsync() { - return apiRequest({ uri: `${this.url}/_tests/run_async`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.url}/_tests/run_async` }); } listModules() { - return apiRequest({ uri: `${this.api_url}/installed_modules`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/installed_modules` }); } removeModule(formData) { - return apiRequest({ method: 'DELETE', uri: `${this.api_url}/installed_modules`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'DELETE', uri: `${this.api_url}/installed_modules`, formData }); } listMigrations() { - return apiRequest({ uri: `${this.api_url}/migrations`, headers: this.defaultHeaders }); + return this.request({ uri: `${this.api_url}/migrations` }); } generateMigration(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/migrations`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/migrations`, formData }); } runMigration(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/migrations/run`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/migrations/run`, formData }); } sendManifest(manifest, releaseId) { const json = { manifest }; if (releaseId) json.marketplace_release_id = releaseId; - return apiRequest({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json }); } sync(formData) { - return apiRequest({ + return this.request({ method: 'PUT', uri: `${this.api_url}/marketplace_releases/sync`, formData, - forever: true, - headers: this.defaultHeaders + forever: true }); } delete(formData) { - return apiRequest({ + return this.request({ method: 'DELETE', uri: `${this.api_url}/marketplace_releases/sync`, formData, - forever: true, - headers: this.defaultHeaders + forever: true }); } push(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases`, formData, headers: this.defaultHeaders }); + return this.request({ method: 'POST', uri: `${this.api_url}/marketplace_releases`, formData }); } } diff --git a/lib/reportCommandError.js b/lib/reportCommandError.js new file mode 100644 index 00000000..f6762f40 --- /dev/null +++ b/lib/reportCommandError.js @@ -0,0 +1,27 @@ +import logger from './logger.js'; +import ServerError from './ServerError.js'; + +/** + * The one way a command reports a failure it cannot handle. + * + * A TwoFactorError already carries a multi-line, actionable message, and it must not be + * passed to logger.Error as an object: the formatter JSON-encodes an Error's message, + * turning the line breaks into escaped \n. Network and HTTP failures keep going to + * ServerError, which knows how to explain a 502 or a refused connection. + * + * @param {Error} error + * @param {{ prefix?: string, exit?: boolean }} options `prefix` names the operation that + * failed; `exit` is passed through to logger.Error for callers that keep running. + */ +const reportCommandError = async (error, { prefix, exit = true } = {}) => { + if (error?.name === 'TwoFactorError') { + return logger.Error(error.message, { hideTimestamp: true, exit }); + } + + if (ServerError.isNetworkError(error)) return ServerError.handler(error); + + const message = prefix ? `${prefix}: ${error?.message || error}` : error; + return logger.Error(message, { exit }); +}; + +export { reportCommandError }; diff --git a/lib/twoFactorSession.js b/lib/twoFactorSession.js new file mode 100644 index 00000000..58f5c303 --- /dev/null +++ b/lib/twoFactorSession.js @@ -0,0 +1,283 @@ +import fs from 'fs'; +import files from './files.js'; +import Portal from './portal.js'; +import logger from './logger.js'; +import { withTwoFactor } from './utils/twoFactor.js'; +import { normalizeBaseUrl as normalize } from './utils/url.js'; +import { reportCommandError } from './reportCommandError.js'; + +// The field a session is stored under, inside the environment's entry in .pos. Snake case +// to match the neighbours (url, token, email, partner_portal_url). +const SESSION_FIELD = 'two_factor_session'; + +// An already-minted session, for a caller that has one and no way to be prompted: a CI job +// handed one by whatever orchestrator did the step-up, or a test. Read-only — pos-cli never +// writes it, because a child process cannot export back to the shell that ran it and +// printing a live credential to stdout would put it in the job log. +const SESSION_TOKEN_ENV_VAR = 'POS_PORTAL_SESSION_TOKEN'; + +// Settings do not always have a .pos behind them: MPKIT_URL/EMAIL/TOKEN take precedence +// over the file (see settings.js) and CI commonly sets only those. A session started in +// that mode has nowhere on disk to live, so it lives here for the life of the process — +// which is what stops a `sync` running for hours from prompting once per changed file. +const inMemory = new Map(); + +// Finds the .pos entry a set of settings came from, matched on the instance URL rather +// than on the environment name: the name does not survive every path settings take, since +// watch.js, push.js and the GUI server rebuild them from MARKETPLACE_* variables that +// carry the URL and nothing else. +const findEnvironment = (instanceUrl, portalUrl) => { + const config = files.getConfig() || {}; + const url = normalize(instanceUrl); + const matches = Object.keys(config).filter(name => normalize(config[name]?.url) === url); + if (!matches.length) return null; + + // One URL can appear under two environments while a domain is moved between portals — + // which is exactly what the `dns` commands do — and a session proved to one portal is + // not a credential for the other. Prefer the entry whose portal the caller named. + const samePortal = portalUrl && + matches.find(name => normalize(config[name].partner_portal_url) === normalize(portalUrl)); + + return { config, name: samePortal || matches[0] }; +}; + +// A minute of slack so a session cannot expire midway through a deploy that just passed +// this check. A session with no expiry at all is one the caller injected: its lifetime is +// unknown here, so it is trusted and the instance is left to reject it if it is stale. +const EXPIRY_MARGIN_MS = 60 * 1000; + +const isSessionLive = (session) => { + if (!session || !session.token) return false; + if (!session.expires_at) return true; + + const expiresAt = Date.parse(session.expires_at); + return !Number.isNaN(expiresAt) && expiresAt - EXPIRY_MARGIN_MS > Date.now(); +}; + +// How much longer a session has, in words, read off the expiry the Portal reported. +// +// The lifetime is the Portal's to choose — it stamps expires_at when it mints the session +// — and it has been changed before, so pos-cli never states it as a constant of its own: +// every duration it prints about a session comes back through here. Rounded to whole +// minutes or hours, because this only ever feeds a sentence telling an operator roughly +// how long they have. +const describeLifetime = (expiresAt) => { + const expires = Date.parse(expiresAt ?? ''); + if (Number.isNaN(expires)) return null; + + const minutes = Math.round((expires - Date.now()) / 60_000); + if (minutes < 1) return 'less than a minute'; + if (minutes < 120) return `${minutes} minute${minutes === 1 ? '' : 's'}`; + + return `${Math.round(minutes / 60)} hours`; +}; + +/** + * What a long-running command says when the instance stops accepting its requests for want + * of a two-factor session, in place of stepping up where it stands. + * + * Every short command steps up in place, and should: it is one request, the prompt is the + * only thing on screen, and the operator is sitting in front of it. Watch mode is none of + * those — CONCURRENCY uploads are in flight and get refused together, file events keep + * arriving behind them, and the run may have been left alone for hours. A prompt raised + * into that interleaves with the `[Sync] Synced:` lines and blocks a queue that keeps + * filling. So it stops and asks to be restarted instead: a restart mints the session up + * front, before the watcher and before any spinner, which is where the prompt belongs. + */ +const sessionInterruptedMessage = ({ command, expired = true }) => + (expired + ? 'Your two-factor session has expired, so the instance stopped accepting changes.' + : 'This instance requires a two-factor session and this run has not got one.') + + '\nAny change that has just been reported as failed did not reach the instance.' + + `\nRun \`${command}\` again — it asks for a code once, before watching starts.` + + '\nThen re-save the files you changed in the meantime: sync only sends a file when it changes.'; + +const readSession = (instanceUrl, portalUrl) => { + const injected = process.env[SESSION_TOKEN_ENV_VAR]; + if (injected) return { token: injected }; + + const found = findEnvironment(instanceUrl, portalUrl); + const stored = found ? found.config[found.name][SESSION_FIELD] : inMemory.get(normalize(instanceUrl)); + + return isSessionLive(stored) ? stored : null; +}; + +// Stores a session against the environment it belongs to, or removes it when `session` is +// null. Both directions are the same read-modify-write, so they share one. +// +// Rewrites the whole file from the object just read, so every other environment and every +// field pos-cli does not know about survives — .pos is hand-edited, and storeEnvironment's +// rebuild-from-known-keys would drop anything it had not heard of. +// +// The mode is set on every write, not just on create: .pos predates holding anything +// shorter-lived than a year-long token and existing ones are 0644. Passing it to +// writeFileSync closes the window where a newly created file sits at 0644; the chmod is +// what fixes the existing ones. +const persistSession = (instanceUrl, portalUrl, session) => { + const found = findEnvironment(instanceUrl, portalUrl); + if (!found) { + if (session) inMemory.set(normalize(instanceUrl), session); + else inMemory.delete(normalize(instanceUrl)); + return; + } + + try { + const configPath = files.getConfigPath(process.env.CONFIG_FILE_PATH); + const config = found.config; + if (session) config[found.name] = { ...config[found.name], [SESSION_FIELD]: session }; + else delete config[found.name][SESSION_FIELD]; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + fs.chmodSync(configPath, 0o600); + } catch (error) { + // Losing the cache costs a prompt on the next command, which is not worth failing for + // — but the session just minted is still good for this process. + logger.Debug(`[twoFactorSession] Could not persist session: ${error.message}`); + if (session) inMemory.set(normalize(instanceUrl), session); + } +}; + +const clearSession = (instanceUrl, portalUrl) => { + inMemory.delete(normalize(instanceUrl)); + persistSession(instanceUrl, portalUrl, null); +}; + +// The portal an instance was registered against, for a caller that was handed a URL and a +// token and nothing else. Same lookup as the session itself, so a private-stack instance +// steps up against its own portal without PARTNER_PORTAL_HOST having to be threaded +// through every process boundary. +const portalUrlFor = (instanceUrl) => { + const found = findEnvironment(instanceUrl); + return found ? found.config[found.name].partner_portal_url : undefined; +}; + +// Prompts for a code and trades it with the Portal for a session token. withTwoFactor does +// the prompting, the retries and the lockout handling: the step-up endpoint answers with +// the same two_factor_required / _invalid / _locked bodies as every other Portal endpoint +// that can refuse a code. +const mintSession = async ({ portalUrl, instanceUrl, token, otpCode, interactive }) => { + const response = await withTwoFactor( + code => Portal.twoFactorSession({ portalUrl, token, instanceDomain: instanceUrl, otpCode: code }), + { + otpCode, + interactive, + prelude: 'This instance requires a two-factor code.', + // Not the usual "use a long-lived token" advice: a long-lived token is precisely + // what this instance has just refused, so pointing at one would send the operator + // in a circle. + // + // Deliberately carries no duration. The lifetime is the Portal's and is not knowable + // at this point — nothing has been minted yet, and the token-info response says + // nothing about a session that does not exist. It is reported below instead, off the + // real session's expires_at, so no number pos-cli prints can drift from the Portal. + unattendedHint: + '\nA session is short-lived, so an unattended run needs a fresh code once it expires; ' + + `a recovery code works and does not expire on a timer, and ${SESSION_TOKEN_ENV_VAR} accepts a session ` + + 'that was minted elsewhere.' + } + ); + + if (!response || !response.token) { + throw new Error(`${normalize(portalUrl)} did not return a two-factor session token.`); + } + + const session = { token: response.token, expires_at: response.expires_at }; + persistSession(instanceUrl, portalUrl, session); + + // Said out loud rather than only at Debug: this lands immediately after the operator + // typed a code, the one moment when knowing how long it bought them is worth a line — + // and it is the only place the lifetime is stated, so it cannot contradict the Portal. + const lifetime = describeLifetime(session.expires_at); + if (lifetime) await logger.Info(`Two-factor session started — it expires in ${lifetime}.`, { hideTimestamp: true }); + + logger.Debug(`[twoFactorSession] Session stored, expires ${session.expires_at}`); + return session; +}; + +// A step-up in progress, per instance. sync runs its queue at CONCURRENCY (3 by default), +// so an instance that wants a session refuses that many uploads at once — and without this +// each one would open its own readline over the same stdin and mint its own session. The +// first caller prompts; the rest wait on its answer. Same shape as watch.js's shared +// refreshDirectUploadData promise, for the same reason. +const inFlight = new Map(); + +const startSession = (args) => { + const key = normalize(args.instanceUrl); + const pending = inFlight.get(key); + if (pending) return pending; + + const promise = mintSession(args).finally(() => inFlight.delete(key)); + inFlight.set(key, promise); + return promise; +}; + +/** + * Makes sure a two-factor session exists before a command that needs one starts working. + * + * Every command reaches the instance through Gateway, which steps up on demand, so this is + * not what makes the rule hold — it is what makes the prompt land at a sensible moment for + * the two long-running commands. + * + * Called at the top of `deploy` and `sync`, deliberately before any spinner is up: a + * spinner repaints its line on a timer, so a prompt raised underneath one is painted over + * and the command looks like it has hung. It also means the operator is asked once, up + * front, rather than partway through an upload. + * + * Returns null when no session is needed — the account is not enrolled, or its Partner + * does not require one — in which case the long-lived token keeps working as before. + */ +const ensureSession = async ({ portalUrl, instanceUrl, token, otpCode, interactive }) => { + const existing = readSession(instanceUrl, portalUrl); + if (existing) { + logger.Debug('[twoFactorSession] Reusing a stored session'); + return existing; + } + + let info; + try { + info = await Portal.tokenInfo({ portalUrl, token }); + } catch (error) { + // A Portal that cannot answer is not a reason to refuse to deploy: the instance is the + // side that actually enforces this, and it will ask for a session if it wants one. + logger.Debug(`[twoFactorSession] Could not read token info: ${error.message}`); + return null; + } + + if (!info || !info.two_factor_required || info.two_factor_session) return null; + + return startSession({ portalUrl, instanceUrl, token, otpCode, interactive }); +}; + +/** + * The `deploy`/`sync` front door for ensureSession: same call, plus the reporting policy + * both commands need. A TwoFactorError already carries a multi-line, actionable message, + * and letting it escape a commander action prints a stack trace over it; anything else + * keeps its existing behaviour. + */ +const ensureSessionForCommand = async (authData, { otpCode } = {}) => { + try { + await ensureSession({ + portalUrl: authData.partner_portal_url, + instanceUrl: authData.url, + token: authData.token, + otpCode + }); + } catch (error) { + if (error.name !== 'TwoFactorError') throw error; + + await reportCommandError(error); + } +}; + +export { + SESSION_TOKEN_ENV_VAR, + describeLifetime, + isSessionLive, + ensureSession, + ensureSessionForCommand, + clearSession, + portalUrlFor, + readSession, + sessionInterruptedMessage, + startSession +}; diff --git a/lib/utils/twoFactor.js b/lib/utils/twoFactor.js new file mode 100644 index 00000000..927e971c --- /dev/null +++ b/lib/utils/twoFactor.js @@ -0,0 +1,211 @@ +import rl from 'readline'; +import logger from '../logger.js'; +import { pauseActiveSpinners } from '../ora.js'; + +// The Partner Portal names a two-factor failure in the 401 body precisely so a client can +// pick its next move instead of reporting the password as wrong. Every other failure -- a +// wrong password above all -- stays a bodiless 401. +const TWO_FACTOR_REQUIRED = 'two_factor_required'; // no code was sent +const TWO_FACTOR_INVALID = 'two_factor_invalid'; // wrong code, attempts left +const TWO_FACTOR_LOCKED = 'two_factor_locked'; // budget spent, retrying is pointless + +const OTP_CODE_ENV_VAR = 'POS_PORTAL_OTP_CODE'; + +// The portal locks an account for 15 minutes after 5 wrong codes. That budget is per +// account and is shared with every other surface, the web UI included, so stopping at 3 +// leaves the operator attempts to spend elsewhere rather than locking them out of the +// portal over a mistyped digit here. +const MAX_ATTEMPTS = 3; + +class TwoFactorError extends Error { + constructor(message) { + super(message); + this.name = 'TwoFactorError'; + } +} + +const errorCode = (error) => { + if (!error || error.statusCode !== 401) return null; + const body = error.response?.body; + return body && typeof body === 'object' ? body.error : null; +}; + +const isTwoFactorRequired = (error) => errorCode(error) === TWO_FACTOR_REQUIRED; +const isTwoFactorInvalid = (error) => errorCode(error) === TWO_FACTOR_INVALID; +const isTwoFactorLocked = (error) => errorCode(error) === TWO_FACTOR_LOCKED; + +const isUnauthorized = (error) => !!error && error.statusCode === 401; + +// Portals older than the two_factor_invalid/two_factor_locked codes answer a wrong code +// with a bodiless 401, which is also what a wrong password looks like. pos-cli talks to +// private-stack deployments that upgrade on their own schedule, so the old inference has +// to stay: a code we sent ourselves can only have been refused for being wrong, because +// the portal would not have asked for one at all unless the password had passed. +const isRejectedCode = (error, codeWasSent) => + codeWasSent && (isTwoFactorInvalid(error) || (isUnauthorized(error) && !errorCode(error))); + +// Authenticator apps display codes in groups ("123 456") and recovery codes get pasted +// with stray whitespace. The portal compares the string it is handed, so normalize here. +const normalizeCode = (code) => String(code ?? '').replace(/\s+/g, ''); + +const presetCode = (otpCode) => normalizeCode(otpCode || process.env[OTP_CODE_ENV_VAR] || '') || null; + +const OTP_PROMPT = 'Two-factor code (or a recovery code): '; + +// One readline interface serves every attempt of a retry loop. Creating a fresh one per +// prompt does not work: an interface built over process.stdin after an earlier one was +// closed fires 'close' immediately instead of reading, so the second prompt would abort +// rather than ask -- exactly the case a user hits after mistyping their first code. +// +// The prompt deliberately echoes, unlike the password one: a TOTP code is single-use and +// expires in 30 seconds, and seeing the digits is what lets an operator catch a typo +// before it costs one of the five attempts the portal allows. +const createOtpPrompt = () => { + const reader = rl.createInterface({ input: process.stdin, output: process.stdout }); + let closed = false; + let pending = null; + + // A stdin that ends while a prompt is up fires 'close' and never calls the question + // callback; resolving null there is what keeps the loop from hanging forever. + reader.on('close', () => { + closed = true; + const resolve = pending; + pending = null; + if (resolve) resolve(null); + }); + + return { + ask: () => new Promise(resolve => { + if (closed) return resolve(null); + + pending = resolve; + reader.question(OTP_PROMPT, code => { + pending = null; + logger.Log(''); + resolve(normalizeCode(code)); + }); + }), + close: () => reader.close() + }; +}; + +const sourceOfPreset = (otpCode) => (otpCode ? '--otp-code' : OTP_CODE_ENV_VAR); + +// The right unattended advice differs by caller, and getting it wrong is worse than +// giving none: a long-lived token is the answer when the code is gating a *login*, and +// exactly the wrong answer when it is gating a deploy, which such a token can no longer do. +const PASSWORD_PRELUDE = + 'This account has two-factor authentication enabled. Your password was accepted.'; + +const TOKEN_HINT = + '\nFor unattended use prefer a long-lived token: `pos-cli env add --url --token ` needs no password and no code.'; + +const nonInteractiveMessage = (rejectedPreset, otpCode, unattendedHint = TOKEN_HINT) => + (rejectedPreset + ? `The two-factor code supplied via ${sourceOfPreset(otpCode)} was rejected by the Partner Portal.` + + '\nA TOTP code is only valid for about 30 seconds — generate a fresh one, or use one of your recovery codes.' + : 'This Partner Portal account has two-factor authentication enabled, and there is no terminal to prompt for a code on.' + + `\nPass --otp-code , set ${OTP_CODE_ENV_VAR}, or run the command in an interactive terminal.`) + + unattendedHint; + +const attemptsLeftWarning = (attempts) => + `That code was not accepted (attempt ${attempts} of ${MAX_ATTEMPTS}).`; + +const exhaustedMessage = () => + `Two-factor authentication failed ${MAX_ATTEMPTS} times, so pos-cli stopped trying.` + + '\nThe Partner Portal locks an account for 15 minutes after 5 wrong codes — the remaining attempts are left for you to spend deliberately.' + + '\nCheck that your authenticator app clock is in sync, or use one of the recovery codes you saved when you enabled 2FA.'; + +// The portal has told us the budget is already spent, so every further code would be +// refused unread. Stopping here also stops the hammering that keeps the lock alive. +const lockedMessage = () => + 'Too many two-factor attempts — the Partner Portal has locked this account for 15 minutes.' + + '\nFurther codes are refused unread until the lock expires, so pos-cli stopped rather than retrying.'; + +/** + * Runs a portal request that authenticates with an email and password, supplying a + * second factor when the portal asks for one. + * + * `run` is called with the code to send (null when there is none) and must reject with + * the error apiRequest throws, so the 401 body can be read. + * + * @param {(code: string|null) => Promise} run + * @param {{ otpCode?: string, interactive?: boolean, unattendedHint?: string, prelude?: string }} options + * @returns {Promise} whatever `run` resolves to + */ +const withTwoFactor = async (run, { otpCode, interactive, unattendedHint, prelude = PASSWORD_PRELUDE } = {}) => { + const preset = presetCode(otpCode); + + let failure; + try { + return await run(preset); + } catch (error) { + failure = error; + } + + // Retrying a locked account only refreshes the reason it is locked, so stop at once — + // whether the lock was already there or the preset code just earned it. + if (isTwoFactorLocked(failure)) throw new TwoFactorError(lockedMessage()); + + // Without this a wrong --otp-code would surface as the generic "check if your + // email/password are correct", since a rejected code is a 401 like any other. + const rejectedPreset = isRejectedCode(failure, Boolean(preset)); + if (!isTwoFactorRequired(failure) && !rejectedPreset) throw failure; + + const canPrompt = interactive ?? Boolean(process.stdin.isTTY); + if (!canPrompt) throw new TwoFactorError(nonInteractiveMessage(rejectedPreset, otpCode, unattendedHint)); + + if (rejectedPreset) { + await logger.Warn(`The two-factor code supplied via ${sourceOfPreset(otpCode)} was not accepted.`); + } else { + // Reaching here means the primary credential was accepted and only the second factor + // is outstanding, which is worth saying: otherwise a prompt appearing after a password + // reads as "that password was wrong, try again". + await logger.Info(prelude, { hideTimestamp: true }); + } + + // A deploy or sync is mid-spinner when the Instance asks for a second factor, and a + // spinner repaints over anything else on the line -- the prompt included, which made it + // look like the command had hung with no explanation. + const resumeSpinners = pauseActiveSpinners(); + const prompt = createOtpPrompt(); + try { + let attempts = 0; + while (attempts < MAX_ATTEMPTS) { + const code = await prompt.ask(); + if (code === null) throw new TwoFactorError(nonInteractiveMessage(false, otpCode, unattendedHint)); + if (!code) { + await logger.Warn('No code entered — press Ctrl+C to abort.'); + continue; + } + + attempts += 1; + try { + return await run(code); + } catch (error) { + // Anything that is not a 401 (a 500, a network drop) is the caller's problem, not + // a wrong code — do not burn attempts on it. + if (!isUnauthorized(error)) throw error; + if (isTwoFactorLocked(error)) throw new TwoFactorError(lockedMessage()); + if (attempts < MAX_ATTEMPTS) await logger.Warn(attemptsLeftWarning(attempts)); + } + } + + throw new TwoFactorError(exhaustedMessage()); + } finally { + prompt.close(); + resumeSpinners(); + } +}; + +export { + MAX_ATTEMPTS, + TOKEN_HINT, + OTP_CODE_ENV_VAR, + TwoFactorError, + isTwoFactorInvalid, + isTwoFactorLocked, + isTwoFactorRequired, + normalizeCode, + withTwoFactor +}; diff --git a/lib/utils/url.js b/lib/utils/url.js new file mode 100644 index 00000000..95fc4dab --- /dev/null +++ b/lib/utils/url.js @@ -0,0 +1,11 @@ +// One canonical form for a portal or instance base URL. +// +// Several places compare two URLs for equality rather than just fetching them: the dns +// commands guard against pointing a domain at the portal it already lives on, and the +// two-factor session store matches a set of settings back to the .pos entry it came from. +// A trailing slash is a formatting difference, not a different host, so every construction +// path has to normalize identically — otherwise two spellings of one portal compare +// unequal and a stored session stops matching the entry that wrote it. +const normalizeBaseUrl = (url) => String(url || '').replace(/\/+$/, ''); + +export { normalizeBaseUrl }; diff --git a/lib/watch.js b/lib/watch.js index d0ab9599..45e31da1 100644 --- a/lib/watch.js +++ b/lib/watch.js @@ -17,6 +17,7 @@ import { manifestGenerateForAssets } from './assets/manifest.js'; import { uploadFileFormData } from './s3UploadFile.js'; import { presignDirectory } from './presignUrl.js'; import shouldBeSynced from '../lib/shouldBeSynced.js'; +import { reportCommandError } from './reportCommandError.js'; // Custom error class to indicate an error has already been logged class AlreadyLoggedError extends Error { @@ -34,12 +35,49 @@ const failSync = async (logMessage, errorMessage = logMessage) => { throw new AlreadyLoggedError(errorMessage); }; +// An expired two-factor session is not this one file's problem: every request after it is +// refused the same way, so the queue would otherwise spend the rest of the run logging one +// refusal per changed file. The first one ends the run instead, carrying the message that +// says what to do about it (twoFactorSession.sessionInterruptedMessage). +// +// Latched because the queue has CONCURRENCY requests in flight and the instance refuses +// them together — without it the operator reads the same four lines three times. +// +// This ends the process, and under `gui serve --sync` that takes the web server with it. +// Deliberate: the GUI proxies its own requests through the same credential, so a session +// the watcher has just been refused is one no panel query can use either. Staying up would +// serve a GUI that fails everything, with the explanation already scrolled out of sight. +let sessionEnded = false; +const isSessionError = error => error?.name === 'TwoFactorError'; +const stopForSession = async error => { + if (sessionEnded) return; + sessionEnded = true; + await reportCommandError(error); +}; + const filePathUnixified = filePath => filePath .replace(/\\/g, '/') .replace(new RegExp(`^${dir.APP}/`), '') .replace(new RegExp(`^${dir.LEGACY_APP}/`), ''); -const moduleAssetRegex = new RegExp('^modules/\\w+/public/assets'); +// Which module files are assets. This has to accept exactly what deploy's +// `modules/*/{private,public}/assets/**` glob accepts, or the two commands +// disagree about what a file is: anything sync fails to recognize here goes out +// through pushFile as an ordinary code file instead, which does not preserve it +// byte for byte and leaves its Content-Type to be derived remotely rather than +// sent with the upload — enough to silently break a .js or .css asset. +// +// Two mismatches lived here. `\w+` matched neither hyphens nor dots, though +// module directory names are arbitrary and hyphens are the norm +// ("common-styling"); and only `public` was listed, though assets are served +// from `private/assets` just the same — both land at the same CDN path, since +// the manifest strips either prefix. +const moduleAssetRegex = /^modules\/[^/]+\/(?:public|private)\/assets\//; + +// The module-relative part of an asset path, with the public/private prefix +// removed: `modules/x/private/assets/js` -> `modules/x/js`. Anchored so a +// directory further down that happens to be called `public` is left alone. +const moduleAssetPrefixRegex = /^(modules\/[^/]+)\/(?:public|private)\/assets/; // Paths that must never be watched. chokidar v4+ dropped fsevents, so on macOS // each watched directory costs one file descriptor (kqueue). Pruning these keeps @@ -116,6 +154,7 @@ const pushFile = async (gateway, syncedFilePath) => { logger.Success(`[Sync] Synced: ${filePath}`); } } catch (e) { + if (isSessionError(e)) return stopForSession(e); // Handle validation errors (422) with custom formatting if (e.statusCode === 422 && e.response && e.response.body) { const body = e.response.body; @@ -146,6 +185,7 @@ const deleteFile = async (gateway, syncedFilePath) => { logger.Success(`[Sync] Deleted: ${filePath}`); } } catch (e) { + if (isSessionError(e)) return stopForSession(e); if (e.statusCode === 422 && e.response && e.response.body) { const body = e.response.body; const error = body.error || (body.errors && body.errors.join(', ')); @@ -195,7 +235,9 @@ const manifestSend = debounce( // would be unhandled and kill the process. sendManifestBatch is async, so a // throw while building the manifest arrives as a rejection too. sendManifestBatch(gateway).catch(e => - logger.Error(`[Sync] Failed to update assets manifest: ${e.message || e}`, { exit: false, notify: false }) + isSessionError(e) + ? stopForSession(e) + : logger.Error(`[Sync] Failed to update assets manifest: ${e.message || e}`, { exit: false, notify: false }) ); }, 1000, @@ -207,7 +249,7 @@ const manifestAddAsset = path => manifestFilesToAdd.push(path); const assetUploadData = normalizedPath => { const fileSubdir = normalizedPath.startsWith('app/assets') ? path.dirname(normalizedPath).replace('app/assets', '') - : '/' + path.dirname(normalizedPath).replace('/public/assets', ''); + : '/' + path.dirname(normalizedPath).replace(moduleAssetPrefixRegex, '$1'); const key = directUploadData.fields.key.replace('assets/${filename}', `assets${fileSubdir}/\${filename}`); const data = { ...directUploadData, fields: { ...directUploadData.fields, key } }; logger.Debug(data); @@ -231,8 +273,29 @@ const uploadAsset = async (gateway, filePath, normalizedPath) => { } }; +// A module can carry the same asset under both public/assets and private/assets, +// and both resolve to one CDN path — so one of them has to win. deploy picks the +// private copy and skips the public one (packAssets' publicAssetsSameAsPrivate), +// so uploading the public copy here would serve content that the next deploy +// silently replaces. Return the private twin's path when there is one. +const privateAssetTwin = normalizedPath => { + const twin = normalizedPath.replace(/^(modules\/[^/]+)\/public\/(assets\/)/, '$1/private/$2'); + if (twin === normalizedPath) return null; + // path.normalize gives back OS-native separators for the existsSync check. + const nativeTwin = path.normalize(twin); + return fs.existsSync(nativeTwin) ? twin : null; +}; + const sendAsset = async (gateway, filePath) => { const normalizedPath = filePath.replace(/\\/g, '/'); + const shadowedBy = privateAssetTwin(normalizedPath); + if (shadowedBy) { + logger.Warn( + `[Sync] Skipped: ${normalizedPath} — ${shadowedBy} exists and deploy serves that copy at the same path. Edit it instead.` + ); + return; + } + try { await uploadAsset(gateway, filePath, normalizedPath); manifestAddAsset(filePath); @@ -241,6 +304,7 @@ const sendAsset = async (gateway, filePath) => { } catch (e) { logger.Debug(e.message); logger.Debug(e.stack); + if (isSessionError(e)) return stopForSession(e); // Network connection errors should not kill sync — it may be a transient failure if (e.name === 'RequestError') { await failSync(`[Sync] Failed to sync: ${normalizedPath}`, e.message); @@ -275,14 +339,18 @@ const refreshDirectUploadData = gateway => { return directUploadDataRefresh; }; -const start = async (env, directAssetsUpload, liveReload) => { +// `restartCommand`, when given, is the command an expired session tells the operator to +// run again — the caller names it because only the caller knows whether this watcher +// belongs to `pos-cli sync` or to `pos-cli gui serve --sync`. Omitting it keeps the +// Gateway's default behaviour of stepping up in place. +const start = async (env, directAssetsUpload, liveReload, { restartCommand } = {}) => { const program = { email: env.MARKETPLACE_EMAIL, token: env.MARKETPLACE_TOKEN, url: env.MARKETPLACE_URL, concurrency: env.CONCURRENCY }; - const gateway = new Gateway(program); + const gateway = new Gateway({ ...program, restartCommand }); const ignoreList = files.getIgnoreList(); const push = directAssetsUpload ? pushFileDirectAssets : pushFile; let liveReloadServer; @@ -317,6 +385,10 @@ const start = async (env, directAssetsUpload, liveReload) => { if (directAssetsUpload) await fetchDirectUploadData(gateway); await gateway.ping(); } catch (e) { + // Reached when the instance wants a session before the watcher is even up: with + // restartCommand set the Gateway refuses to prompt here too, and this is a plain stop + // rather than a stack trace out of the commander action. + if (isSessionError(e)) return stopForSession(e); if (ServerError.isNetworkError(e)) { await ServerError.handler(e); process.exit(1); diff --git a/test/unit/env-add-unit.test.js b/test/unit/env-add-unit.test.js index 5eedac81..320f3d23 100644 --- a/test/unit/env-add-unit.test.js +++ b/test/unit/env-add-unit.test.js @@ -26,7 +26,7 @@ vi.mock('#lib/portal.js', async () => { interval: 1 }), fetchDeviceAccessToken: () => Promise.resolve({ access_token: mockAccessToken }), - login: () => Promise.resolve([{ token: mockAccessToken }]) + login: vi.fn(() => Promise.resolve([{ token: mockAccessToken }])) } }; }); @@ -44,6 +44,28 @@ vi.mock('#lib/logger.js', async () => { }; }); +vi.mock('#lib/utils/password.js', () => ({ + readPassword: vi.fn(() => Promise.resolve('test-password')) +})); + +// Stands in for the readline prompt withTwoFactor() puts up; answers are queued per test. +const otpAnswers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (otpAnswers.length) return callback(otpAnswers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + vi.mock('#lib/validators/index.js', () => ({ existence: { directoryExists: () => true, fileExists: () => true }, url: () => true, @@ -53,26 +75,45 @@ vi.mock('#lib/validators/index.js', () => ({ })); let addEnv; +let mockPortal; let originalCwd; +let originalIsTTY; let tempDir; beforeAll(async () => { const addMod = await import('#lib/envs/add.js'); addEnv = addMod.default; + + mockPortal = (await import('#lib/portal.js')).default; }); beforeEach(() => { originalCwd = process.cwd(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-test-')); process.chdir(tempDir); + + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + otpAnswers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + mockPortal.login.mockReset(); + mockPortal.login.mockResolvedValue([{ token: mockAccessToken }]); }); afterEach(() => { process.chdir(originalCwd); + process.stdin.isTTY = originalIsTTY; + delete process.env.POS_PORTAL_OTP_CODE; fs.rmSync(tempDir, { recursive: true, force: true }); mockAccessToken = 'mock-token-12345'; }); +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + describe('env add with mocked portal', () => { test('creates .pos file with token from device authorization flow', async () => { const environment = 'staging'; @@ -229,4 +270,44 @@ describe('env add with mocked portal', () => { // Restore original mock Portal.default.requestDeviceAuthorization = originalRequestDeviceAuth; }); + + test('sends --otp-code to the portal without prompting', async () => { + await addEnv('staging', { + url: 'https://staging.example.com', + email: 'user@example.com', + otpCode: '123 456' + }); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com/', '123456' + ); + expect(settingsFromDotPos('staging')['token']).toBe('mock-token-12345'); + }); + + test('prompts for a code when the portal answers two_factor_required, then stores the token', async () => { + otpAnswers.push('654321'); + mockPortal.login.mockImplementation((_email, _password, _url, otpCode) => { + if (!otpCode) return Promise.reject(twoFactorRequired()); + return Promise.resolve([{ token: 'token-behind-2fa' }]); + }); + + await addEnv('staging', { url: 'https://staging.example.com', email: 'user@example.com' }); + + expect(mockPortal.login).toHaveBeenCalledTimes(2); + expect(settingsFromDotPos('staging')['token']).toBe('token-behind-2fa'); + }); + + test('fails with an actionable error instead of prompting when stdin is not a terminal', async () => { + process.stdin.isTTY = false; + mockPortal.login.mockRejectedValue(twoFactorRequired()); + + await expect( + addEnv('staging', { url: 'https://staging.example.com', email: 'user@example.com' }) + ).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('POS_PORTAL_OTP_CODE') + }); + + expect(fs.existsSync('.pos')).toBe(false); + }); }); diff --git a/test/unit/env-refresh-token-unit.test.js b/test/unit/env-refresh-token-unit.test.js index a744cbf8..d2b4f24f 100644 --- a/test/unit/env-refresh-token-unit.test.js +++ b/test/unit/env-refresh-token-unit.test.js @@ -31,6 +31,7 @@ vi.mock('#lib/portal.js', async () => { vi.mock('#lib/logger.js', () => ({ default: { + Log: vi.fn(), Success: vi.fn(), Debug: vi.fn(), Info: vi.fn(), @@ -43,10 +44,35 @@ vi.mock('#lib/utils/password.js', () => ({ readPassword: vi.fn(() => Promise.resolve('test-password')) })); +// Stands in for the readline prompt withTwoFactor() puts up; answers are queued per test. +const otpAnswers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (otpAnswers.length) return callback(otpAnswers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + let refreshToken; let mockLogger; let mockPortal; let originalCwd; +let originalIsTTY; let tempDir; beforeAll(async () => { @@ -67,6 +93,12 @@ beforeEach(() => { vi.clearAllMocks(); + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + otpAnswers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + mockPortal.login.mockResolvedValue([{ token: 'refreshed-token-12345' }]); + mockPortal.requestDeviceAuthorization.mockResolvedValue({ verification_uri_complete: 'http://example.com/xxxx', device_code: 'device_code', @@ -76,6 +108,8 @@ beforeEach(() => { afterEach(() => { process.chdir(originalCwd); + process.stdin.isTTY = originalIsTTY; + delete process.env.POS_PORTAL_OTP_CODE; fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -99,7 +133,7 @@ describe('env refresh-token', () => { const token = await refreshToken(environment, authData); expect(token).toBe('refreshed-token-12345'); - expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com'); + expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com', null); expect(mockPortal.requestDeviceAuthorization).not.toHaveBeenCalled(); const settings = settingsFromDotPos(environment); @@ -153,4 +187,49 @@ describe('env refresh-token', () => { expect.anything() ); }); + + test('sends --otp-code to the portal without prompting', async () => { + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await refreshToken('staging', authData, { otpCode: '123 456' }); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com', '123456' + ); + }); + + test('reads a code from POS_PORTAL_OTP_CODE when no flag is given', async () => { + process.env.POS_PORTAL_OTP_CODE = '654321'; + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await refreshToken('staging', authData); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com', '654321' + ); + }); + + test('prompts for a code when the portal answers two_factor_required', async () => { + otpAnswers.push('654321'); + mockPortal.login.mockImplementation((_email, _password, _url, otpCode) => { + if (!otpCode) return Promise.reject(twoFactorRequired()); + return Promise.resolve([{ token: 'token-behind-2fa' }]); + }); + + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + const token = await refreshToken('staging', authData); + + expect(token).toBe('token-behind-2fa'); + expect(settingsFromDotPos('staging').token).toBe('token-behind-2fa'); + }); + + test('leaves the stored token alone when 2FA cannot be answered', async () => { + process.stdin.isTTY = false; + mockPortal.login.mockRejectedValue(twoFactorRequired()); + + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await expect(refreshToken('staging', authData)).rejects.toMatchObject({ name: 'TwoFactorError' }); + expect(fs.existsSync('.pos')).toBe(false); + }); }); diff --git a/test/unit/proxy.test.js b/test/unit/proxy.test.js new file mode 100644 index 00000000..2e00a1de --- /dev/null +++ b/test/unit/proxy.test.js @@ -0,0 +1,155 @@ +/** + * Gateway's two-factor step-up policy. + * + * Every short command steps up where it stands and retries the request that was refused. + * A long-running caller — watch mode, under `pos-cli sync` or `pos-cli gui serve --sync` — + * cannot: its queue has CONCURRENCY uploads in flight, file events keep arriving behind + * them, and nobody may be watching stdin. Those callers pass `restartCommand`, and the + * Gateway then refuses rather than prompting from inside a running watcher or web server. + */ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +vi.mock('#lib/logger.js', () => ({ + default: { Debug: vi.fn(), Info: vi.fn(), Warn: vi.fn(), Success: vi.fn(), Error: vi.fn() } +})); + +vi.mock('#lib/apiRequest.js', () => ({ apiRequest: vi.fn() })); + +// Left real apart from the one call a step-up would make, so "did it try to step up?" is +// answerable by asking whether the portal was contacted at all. +vi.mock('#lib/portal.js', () => ({ + default: { + url: () => 'https://partners.platformos.com', + tokenInfo: vi.fn(), + twoFactorSession: vi.fn() + } +})); + +import { apiRequest } from '#lib/apiRequest.js'; +import Portal from '#lib/portal.js'; +import Gateway from '#lib/proxy.js'; + +const PORTAL = 'http://portal.test'; +const INSTANCE = 'http://shop.example.com'; + +// What the instance answers with when it wants a session it has not been given. +const sessionRequired = () => + Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['...'] } } + }); + +const inOneHour = () => new Date(Date.now() + 3600_000).toISOString(); + +let workdir; +let originalIsTTY; + +const writeConfig = (entry) => + fs.writeFileSync( + path.join(workdir, '.pos'), + JSON.stringify({ staging: { url: INSTANCE, token: 'long-lived', email: 'a@b.c', partner_portal_url: PORTAL, ...entry } }, null, 2) + ); + +const settings = (extra = {}) => ({ url: INSTANCE, token: 'long-lived', email: 'a@b.c', partner_portal_url: PORTAL, ...extra }); + +beforeEach(() => { + workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-proxy-')); + process.env.CONFIG_FILE_PATH = path.join(workdir, '.pos'); + writeConfig(); + + // Off, so a step-up that does get attempted fails fast instead of blocking on a prompt. + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + delete process.env.POS_PORTAL_OTP_CODE; + delete process.env.POS_PORTAL_SESSION_TOKEN; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.stdin.isTTY = originalIsTTY; + delete process.env.CONFIG_FILE_PATH; + fs.rmSync(workdir, { recursive: true, force: true }); +}); + +describe('a Gateway with no restartCommand', () => { + test('steps up with the portal and retries the request that was refused', async () => { + apiRequest.mockRejectedValueOnce(sessionRequired()).mockResolvedValueOnce('served'); + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + process.env.POS_PORTAL_OTP_CODE = '123456'; + + await expect(new Gateway(settings()).ping()).resolves.toBe('served'); + + expect(Portal.twoFactorSession).toHaveBeenCalledTimes(1); + expect(apiRequest).toHaveBeenCalledTimes(2); + }); +}); + +describe('a Gateway given a restartCommand', () => { + test('refuses instead of prompting, and never contacts the portal', async () => { + apiRequest.mockRejectedValue(sessionRequired()); + + const error = await new Gateway(settings({ restartCommand: 'pos-cli sync staging' })) + .ping() + .catch(e => e); + + expect(error.name).toBe('TwoFactorError'); + expect(error.message).toContain('`pos-cli sync staging` again'); + // The refused request is not retried either — there is no new credential to retry with. + expect(apiRequest).toHaveBeenCalledTimes(1); + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); + + // The message has to name the command that was actually started: restarting `pos-cli + // sync` would not bring back a web server that came down with the watcher. + test('names the caller that started the run', async () => { + apiRequest.mockRejectedValue(sessionRequired()); + + const error = await new Gateway(settings({ restartCommand: 'pos-cli gui serve staging --sync' })) + .ping() + .catch(e => e); + + expect(error.message).toContain('`pos-cli gui serve staging --sync` again'); + }); + + test('says the session expired when the run was holding one', async () => { + writeConfig({ two_factor_session: { token: 'session-token', expires_at: inOneHour() } }); + apiRequest.mockRejectedValue(sessionRequired()); + + const error = await new Gateway(settings({ restartCommand: 'pos-cli sync staging' })) + .ping() + .catch(e => e); + + expect(error.message).toContain('has expired'); + }); + + // Reached when the up-front step-up was skipped because the portal could not be asked. + // Calling that "expired" would describe a session that never existed. + test('says a session is required when the run never held one', async () => { + apiRequest.mockRejectedValue(sessionRequired()); + + const error = await new Gateway(settings({ restartCommand: 'pos-cli sync staging' })) + .ping() + .catch(e => e); + + expect(error.message).toContain('requires a two-factor session'); + expect(error.message).not.toContain('has expired'); + }); + + // The policy is only about the two_factor_required body. An expired or revoked token is + // a different problem with a different answer, and must keep failing as itself. + test('leaves every other failure alone', async () => { + const unauthorized = Object.assign(new Error('Unauthorized'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: '' } + }); + apiRequest.mockRejectedValue(unauthorized); + + await expect(new Gateway(settings({ restartCommand: 'pos-cli sync staging' })).ping()) + .rejects.toBe(unauthorized); + }); +}); diff --git a/test/unit/twoFactor.test.js b/test/unit/twoFactor.test.js new file mode 100644 index 00000000..fcbfe184 --- /dev/null +++ b/test/unit/twoFactor.test.js @@ -0,0 +1,428 @@ +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// A controllable stand-in for the readline prompt. Answers are queued per test; an +// exhausted queue emits 'close' instead, which is what a drained or closed stdin does. +// +// It also reproduces the Node behaviour that makes reusing one interface necessary: an +// interface built over process.stdin *after* an earlier one was closed fires 'close' +// immediately instead of reading. Without that, a prompt-per-attempt implementation looks +// fine under test and then aborts on the first retry in a real terminal. +const answers = []; +const readlineState = { interfacesCreated: 0, anyClosed: false }; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + const bornClosed = readlineState.anyClosed; + readlineState.interfacesCreated += 1; + return { + on: (event, handler) => { + handlers[event] = handler; + if (event === 'close' && bornClosed) handler(); + }, + close: () => { + readlineState.anyClosed = true; + handlers.close?.(); + }, + question: (_prompt, callback) => { + if (answers.length) return callback(answers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +vi.mock('#lib/logger.js', () => ({ + default: { + Log: vi.fn(), + Debug: vi.fn(), + Info: vi.fn(), + Warn: vi.fn(), + Success: vi.fn(), + Error: vi.fn() + } +})); + +const { + MAX_ATTEMPTS, + OTP_CODE_ENV_VAR, + isTwoFactorInvalid, + isTwoFactorLocked, + isTwoFactorRequired, + normalizeCode, + withTwoFactor +} = await import('#lib/utils/twoFactor.js'); + +// Shape of what apiRequest throws for the portal's "send me a code" answer. +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + +const twoFactorBody = (code, message) => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: code, errors: [message] } } +}); + +const twoFactorInvalid = () => twoFactorBody('two_factor_invalid', 'Invalid two-factor code'); +const twoFactorLocked = () => twoFactorBody('two_factor_locked', 'Too many two-factor attempts'); + +// A bodiless 401 — what a wrong password gets, and what a portal too old to send +// two_factor_invalid/two_factor_locked answers a wrong code with. +const unauthorized = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: '' } +}); + +let originalIsTTY; + +beforeEach(() => { + answers.length = 0; + readlineState.interfacesCreated = 0; + readlineState.anyClosed = false; + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + delete process.env[OTP_CODE_ENV_VAR]; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.stdin.isTTY = originalIsTTY; + delete process.env[OTP_CODE_ENV_VAR]; +}); + +describe('portal error codes', () => { + test('each matcher recognises only its own code on a 401', () => { + expect(isTwoFactorRequired(twoFactorRequired())).toBe(true); + expect(isTwoFactorInvalid(twoFactorInvalid())).toBe(true); + expect(isTwoFactorLocked(twoFactorLocked())).toBe(true); + + expect(isTwoFactorRequired(twoFactorInvalid())).toBe(false); + expect(isTwoFactorInvalid(twoFactorLocked())).toBe(false); + expect(isTwoFactorLocked(twoFactorRequired())).toBe(false); + }); + + test('none of them match a bodiless 401, a non-401, or a 401 HTML page', () => { + for (const matcher of [isTwoFactorRequired, isTwoFactorInvalid, isTwoFactorLocked]) { + expect(matcher(unauthorized())).toBe(false); + expect(matcher(null)).toBe(false); + expect(matcher({ statusCode: 403, response: { body: { error: 'two_factor_required' } } })).toBe(false); + expect(matcher({ statusCode: 401, response: { body: 'two_factor_locked' } })).toBe(false); + } + }); +}); + +describe('normalizeCode', () => { + test('strips the whitespace authenticator apps and copy-paste introduce', () => { + expect(normalizeCode(' 123 456 ')).toBe('123456'); + expect(normalizeCode('abcd-efgh\n')).toBe('abcd-efgh'); + expect(normalizeCode(undefined)).toBe(''); + }); +}); + +describe('withTwoFactor', () => { + test('passes no code and never prompts for an account without 2FA', async () => { + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(null); + }); + + test('sends a code given as an option without prompting', async () => { + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run, { otpCode: '123 456' })).resolves.toBe('token'); + + expect(run).toHaveBeenCalledWith('123456'); + expect(answers.length).toBe(0); + }); + + test(`sends a code from ${OTP_CODE_ENV_VAR} when no option is given`, async () => { + process.env[OTP_CODE_ENV_VAR] = '654321'; + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledWith('654321'); + }); + + test('prompts for a code when the portal asks for one, then retries', async () => { + answers.push('123 456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + return `token-for-${code}`; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token-for-123456'); + + expect(run).toHaveBeenNthCalledWith(1, null); + expect(run).toHaveBeenNthCalledWith(2, '123456'); + }); + + test('re-prompts after a wrong code and succeeds on a later attempt', async () => { + answers.push('000000', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code !== '123456') throw twoFactorInvalid(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledTimes(3); + // Every attempt must share one readline interface — a second one built over an + // already-closed process.stdin would abort instead of asking again. + expect(readlineState.interfacesCreated).toBe(1); + }); + + test(`gives up after ${MAX_ATTEMPTS} wrong codes and explains the portal lockout`, async () => { + answers.push('000000', '111111', '222222', '333333'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + throw twoFactorInvalid(); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('locks an account for 15 minutes') + }); + + // One password-only probe plus exactly MAX_ATTEMPTS codes — the 4th answer is + // never read, so the portal's 5-attempt budget is not spent here. + expect(run).toHaveBeenCalledTimes(MAX_ATTEMPTS + 1); + expect(answers).toEqual(['333333']); + }); + + test('reports a rejected --otp-code as a code problem, not a password problem', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw twoFactorInvalid(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('--otp-code') + }); + }); + + // pos-cli talks to private-stack portals that upgrade on their own schedule. + describe('against a portal too old to send two_factor_invalid', () => { + test('still blames a rejected preset code rather than the password', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw unauthorized(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('--otp-code') + }); + }); + + test('still re-prompts after a wrong typed code', async () => { + answers.push('000000', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code !== '123456') throw unauthorized(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + }); + + // The inference only holds for a code we sent: a bodiless 401 with no code in play + // is a wrong password and must stay one. + test('leaves a bodiless 401 alone when no code was ever sent', async () => { + const run = vi.fn(async () => { throw unauthorized(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 401 }); + + expect(run).toHaveBeenCalledTimes(1); + }); + }); + + describe('two_factor_locked', () => { + test('stops immediately instead of prompting when the account is already locked', async () => { + const run = vi.fn(async () => { throw twoFactorLocked(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('locked this account for 15 minutes') + }); + + expect(run).toHaveBeenCalledTimes(1); + expect(readlineState.interfacesCreated).toBe(0); + }); + + test('stops mid-loop when an attempt earns the lock, leaving later answers unread', async () => { + answers.push('000000', '111111', '222222'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code === '000000') throw twoFactorInvalid(); + throw twoFactorLocked(); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('refused unread') + }); + + // The password probe plus two codes — the third answer is never asked for. + expect(run).toHaveBeenCalledTimes(3); + expect(answers).toEqual(['222222']); + }); + + test('stops rather than prompting when a preset code earns the lock', async () => { + const run = vi.fn(async () => { throw twoFactorLocked(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError' + }); + + expect(readlineState.interfacesCreated).toBe(0); + }); + }); + + test('prompts to replace a rejected preset code when there is a terminal', async () => { + answers.push('123456'); + const run = vi.fn(async code => { + if (code !== '123456') throw unauthorized(); + return 'token'; + }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).resolves.toBe('token'); + + expect(run).toHaveBeenNthCalledWith(1, '000000'); + expect(run).toHaveBeenNthCalledWith(2, '123456'); + }); + + test('explains what to set instead of prompting when stdin is not a terminal', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining(OTP_CODE_ENV_VAR) + }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('honours an explicit interactive:false even on a terminal (--json runs)', async () => { + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run, { interactive: false })).rejects.toMatchObject({ + name: 'TwoFactorError' + }); + }); + + test('aborts instead of looping when stdin closes at the prompt', async () => { + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ name: 'TwoFactorError' }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('re-prompts without spending an attempt when the answer is empty', async () => { + answers.push('', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + // The empty line never reached the portal. + expect(run).toHaveBeenCalledTimes(2); + }); + + test('propagates non-401 failures untouched', async () => { + const run = vi.fn(async () => { + throw Object.assign(new Error('Request failed with status 500'), { + name: 'StatusCodeError', + statusCode: 500 + }); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 500 }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('does not spend attempts on a server error raised after a code was entered', async () => { + answers.push('123456', '654321'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + throw Object.assign(new Error('boom'), { name: 'StatusCodeError', statusCode: 500 }); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 500 }); + + expect(run).toHaveBeenCalledTimes(2); + expect(answers).toEqual(['654321']); + }); +}); + +describe('Portal requests carry the code', () => { + const PORTAL = 'https://portal.example.com'; + let Portal; + + beforeEach(async () => { + process.env.PARTNER_PORTAL_HOST = PORTAL; + Portal = (await import('#lib/portal.js')).default; + nock.cleanAll(); + }); + + afterEach(() => { + delete process.env.PARTNER_PORTAL_HOST; + nock.cleanAll(); + }); + + test('GET /api/user_tokens sends the code in the UserOtpCode header', async () => { + const scope = nock(PORTAL, { + reqheaders: { + UserAuthorization: 'user@example.com:secret', + UserOtpCode: '123456' + } + }).get('/api/user_tokens').reply(200, [{ token: 'access-token' }]); + + await expect(Portal.login('user@example.com', 'secret', 'https://example.com/', '123456')) + .resolves.toEqual([{ token: 'access-token' }]); + + scope.done(); + }); + + test('GET /api/user_tokens omits the header when there is no code', async () => { + const scope = nock(PORTAL, { badheaders: ['UserOtpCode'] }) + .get('/api/user_tokens').reply(200, [{ token: 'access-token' }]); + + await Portal.login('user@example.com', 'secret', 'https://example.com/'); + + scope.done(); + }); + + test('POST /api/authenticate sends the code as otp_code', async () => { + const scope = nock(PORTAL) + .post('/api/authenticate', body => /name="otp_code"[\s\S]*123456/.test(body)) + .reply(200, { auth_token: 'jwt' }); + + await expect(Portal.jwtToken('user@example.com', 'secret', '123456')) + .resolves.toEqual({ auth_token: 'jwt' }); + + scope.done(); + }); + + test('POST /api/authenticate omits otp_code when there is no code', async () => { + const scope = nock(PORTAL) + .post('/api/authenticate', body => !/name="otp_code"/.test(body)) + .reply(200, { auth_token: 'jwt' }); + + await Portal.jwtToken('user@example.com', 'secret'); + + scope.done(); + }); +}); diff --git a/test/unit/twoFactorSession.test.js b/test/unit/twoFactorSession.test.js new file mode 100644 index 00000000..4d19f635 --- /dev/null +++ b/test/unit/twoFactorSession.test.js @@ -0,0 +1,384 @@ +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const answers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (answers.length) return callback(answers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +vi.mock('#lib/logger.js', () => ({ + default: { Log: vi.fn(), Debug: vi.fn(), Info: vi.fn(), Warn: vi.fn(), Success: vi.fn(), Error: vi.fn() } +})); + +vi.mock('#lib/portal.js', () => ({ + default: { + url: () => 'https://partners.platformos.com', + tokenInfo: vi.fn(), + twoFactorSession: vi.fn() + } +})); + +const Portal = (await import('#lib/portal.js')).default; +const logger = (await import('#lib/logger.js')).default; +const { + SESSION_TOKEN_ENV_VAR, + clearSession, + describeLifetime, + ensureSession, + portalUrlFor, + readSession, + sessionInterruptedMessage, + startSession +} = await import('#lib/twoFactorSession.js'); + +const PORTAL = 'http://portal.test'; +const INSTANCE = 'http://shop.example.com'; + +// What the instance answers a write with when it wants a session. +const sessionRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['...'] } } +}); + +const inOneHour = () => new Date(Date.now() + 3600_000).toISOString(); + +let workdir; +let configPath; +let originalIsTTY; + +const writeConfig = (config) => fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); +const readConfig = () => JSON.parse(fs.readFileSync(configPath, 'utf8')); + +beforeEach(() => { + workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-pos-')); + configPath = path.join(workdir, '.pos'); + process.env.CONFIG_FILE_PATH = configPath; + writeConfig({ + staging: { url: INSTANCE, token: 'long-lived', email: 'a@b.c', partner_portal_url: PORTAL } + }); + + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + answers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + delete process.env[SESSION_TOKEN_ENV_VAR]; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.stdin.isTTY = originalIsTTY; + delete process.env.CONFIG_FILE_PATH; + delete process.env[SESSION_TOKEN_ENV_VAR]; + fs.rmSync(workdir, { recursive: true, force: true }); +}); + +describe('the session store', () => { + test('round-trips a session through the environment entry in .pos', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession(INSTANCE, PORTAL).token).toBe('session-token'); + expect(readConfig().staging.two_factor_session.token).toBe('session-token'); + }); + + // .pos now holds something shorter-lived than the year-long token, and existing files + // were written world-readable. + test('tightens .pos to owner-only when it stores a session', async () => { + fs.chmodSync(configPath, 0o644); + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + // storeEnvironment rebuilds an entry from four known keys; this writer must not, or a + // hand-added field would disappear the first time a session was cached. + test('preserves other environments and unknown fields', async () => { + writeConfig({ + staging: { url: INSTANCE, token: 'long-lived', email: 'a@b.c', partner_portal_url: PORTAL, note: 'keep me' }, + production: { url: 'http://prod.example.com', token: 'other' } + }); + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + const config = readConfig(); + expect(config.staging.note).toBe('keep me'); + expect(config.staging.token).toBe('long-lived'); + expect(config.production).toEqual({ url: 'http://prod.example.com', token: 'other' }); + expect(config.production.two_factor_session).toBeUndefined(); + }); + + test('treats an expired session as no session', async () => { + Portal.twoFactorSession.mockResolvedValue({ + token: 'session-token', + expires_at: new Date(Date.now() - 1000).toISOString() + }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession(INSTANCE, PORTAL)).toBeNull(); + }); + + test('scopes a session to the environment whose URL matches', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession('http://other.example.com', PORTAL)).toBeNull(); + // Trailing slashes are a formatting difference, not a different instance. + expect(readSession(`${INSTANCE}/`, `${PORTAL}/`).token).toBe('session-token'); + }); + + // The same URL under two portals is what a domain migration looks like mid-flight, and a + // session proved to one portal is not a credential for the other. + test('keeps two portals serving one URL apart', async () => { + writeConfig({ + old: { url: INSTANCE, token: 't1', partner_portal_url: PORTAL }, + new: { url: INSTANCE, token: 't2', partner_portal_url: 'http://other-portal.test' } + }); + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: 'http://other-portal.test', instanceUrl: INSTANCE, token: 't2', otpCode: '123456' }); + + expect(readConfig().new.two_factor_session.token).toBe('session-token'); + expect(readConfig().old.two_factor_session).toBeUndefined(); + expect(readSession(INSTANCE, PORTAL)).toBeNull(); + }); + + test('clearSession removes only the session', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + clearSession(INSTANCE, PORTAL); + + expect(readSession(INSTANCE, PORTAL)).toBeNull(); + expect(readConfig().staging.token).toBe('long-lived'); + }); + + // sync runs its queue at CONCURRENCY, so an instance that wants a session refuses that + // many uploads at once. Without a shared step-up each one would open its own readline + // over the same stdin and mint its own session. + test('shares one step-up across callers that race for the same instance', async () => { + let resolvePortal; + Portal.twoFactorSession.mockImplementation(() => new Promise(resolve => { resolvePortal = resolve; })); + + const args = { portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }; + const all = Promise.all([startSession(args), startSession(args), startSession(args)]); + resolvePortal({ token: 'session-token', expires_at: inOneHour() }); + + const sessions = await all; + expect(Portal.twoFactorSession).toHaveBeenCalledTimes(1); + expect(sessions.map(s => s.token)).toEqual(['session-token', 'session-token', 'session-token']); + }); + + // ...and the sharing must not outlive the request, or the next command would be handed + // a stale promise instead of prompting. + test('starts a fresh step-up once the previous one has settled', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(Portal.twoFactorSession).toHaveBeenCalledTimes(2); + }); + + // MPKIT_* settings take precedence over .pos and often come with no file at all, so a + // session started that way has to survive in the process or `sync` would re-prompt per + // changed file. + test('falls back to memory when no .pos entry matches', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + const unregistered = 'http://not-in-dot-pos.example.com'; + + await startSession({ portalUrl: PORTAL, instanceUrl: unregistered, token: 'long-lived', otpCode: '123456' }); + + expect(readSession(unregistered, PORTAL).token).toBe('session-token'); + expect(readConfig().staging.two_factor_session).toBeUndefined(); + }); +}); + +describe(`${SESSION_TOKEN_ENV_VAR}`, () => { + test('is used in preference to anything on disk, and is never written', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + process.env[SESSION_TOKEN_ENV_VAR] = 'injected-token'; + + expect(readSession(INSTANCE, PORTAL).token).toBe('injected-token'); + expect(readConfig().staging.two_factor_session.token).toBe('session-token'); + }); + + // Its lifetime is not knowable here — the instance is left to reject it if it is stale. + test('short-circuits ensureSession without asking the portal anything', async () => { + process.env[SESSION_TOKEN_ENV_VAR] = 'injected-token'; + + const session = await ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }); + + expect(session.token).toBe('injected-token'); + expect(Portal.tokenInfo).not.toHaveBeenCalled(); + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); +}); + +describe('portalUrlFor', () => { + test('recovers the portal an instance was registered against', () => { + expect(portalUrlFor(INSTANCE)).toBe(PORTAL); + expect(portalUrlFor(`${INSTANCE}/`)).toBe(PORTAL); + expect(portalUrlFor('http://unknown.example.com')).toBeUndefined(); + }); +}); + +describe('ensureSession', () => { + test('does nothing when the portal does not require a second factor', async () => { + Portal.tokenInfo.mockResolvedValue({ two_factor_required: false, two_factor_session: false }); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })).resolves.toBeNull(); + + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); + + test('prompts and starts a session when one is required', async () => { + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockImplementation(({ otpCode }) => { + if (!otpCode) return Promise.reject(sessionRequired()); + return Promise.resolve({ token: 'session-token', expires_at: inOneHour() }); + }); + answers.push('123456'); + + const session = await ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }); + + expect(session.token).toBe('session-token'); + expect(readSession(INSTANCE, PORTAL).token).toBe('session-token'); + }); + + test('reuses a stored session without asking the portal anything', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + vi.clearAllMocks(); + + const session = await ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }); + + expect(session.token).toBe('session-token'); + expect(Portal.tokenInfo).not.toHaveBeenCalled(); + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); + + // The instance is what actually enforces this; a Portal that cannot answer must not be + // able to block a deploy that would otherwise have been allowed. + test('proceeds when the portal cannot be reached', async () => { + Portal.tokenInfo.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })).resolves.toBeNull(); + }); + + test('refuses with guidance rather than prompting when there is no terminal', async () => { + process.stdin.isTTY = false; + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockRejectedValue(sessionRequired()); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })) + .rejects.toMatchObject({ name: 'TwoFactorError' }); + }); + + // A long-lived token is exactly what the instance just refused, so telling the operator + // to go and get one would send them in a circle. + test('does not advise a long-lived token when one is what was refused', async () => { + process.stdin.isTTY = false; + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockRejectedValue(sessionRequired()); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })) + .rejects.toMatchObject({ message: expect.not.stringContaining('--token') }); + }); +}); + +describe('the lifetime pos-cli reports', () => { + // The eight hours this used to hardcode is the Portal's own constant + // (AccessTokens::TwoFactorPolicy::SESSION_LIFETIME). Changing it there must not leave + // pos-cli quoting a number nobody honours, so every duration it prints is read back off + // the expires_at the Portal returned. + test('is read off the expiry the portal returned, not a constant of our own', async () => { + Portal.twoFactorSession.mockResolvedValue({ + token: 'session-token', + expires_at: new Date(Date.now() + 59 * 60_000).toISOString() + }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(logger.Info).toHaveBeenCalledWith(expect.stringContaining('expires in 59 minutes'), expect.anything()); + expect(logger.Info).not.toHaveBeenCalledWith(expect.stringContaining('8 hours'), expect.anything()); + }); + + test('follows the portal when it reports a different lifetime', async () => { + Portal.twoFactorSession.mockResolvedValue({ + token: 'session-token', + expires_at: new Date(Date.now() + 3 * 3600_000).toISOString() + }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(logger.Info).toHaveBeenCalledWith(expect.stringContaining('expires in 3 hours'), expect.anything()); + }); + + // A portal too old to send expires_at, and an injected session, both leave it unset — + // and inventing a duration for either is exactly the drift being removed here. + test('is left unsaid when the portal did not report one', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token' }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(describeLifetime(undefined)).toBeNull(); + expect(describeLifetime('not a date')).toBeNull(); + expect(logger.Info).not.toHaveBeenCalledWith(expect.stringContaining('expires in'), expect.anything()); + }); + + // The unattended advice is written before anything has been minted, so there is no + // expiry for it to quote — it must not fall back to a guess. + test('is absent from the advice printed when there is no terminal to prompt on', async () => { + process.stdin.isTTY = false; + Portal.twoFactorSession.mockRejectedValue(sessionRequired()); + + const error = await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }) + .catch(e => e); + + expect(error.name).toBe('TwoFactorError'); + expect(error.message).toContain('A session is short-lived'); + expect(error.message).not.toMatch(/\d+\s*hours/); + }); +}); + +describe('sessionInterruptedMessage', () => { + test('tells an operator whose session ran out to restart that exact command', () => { + const message = sessionInterruptedMessage({ command: 'pos-cli sync staging' }); + + expect(message).toContain('has expired'); + expect(message).toContain('`pos-cli sync staging` again'); + expect(message).toContain('re-save the files you changed'); + }); + + // Reached when the up-front step-up was skipped because the Portal could not be asked. + // Calling that "expired" would describe a session that never existed. + test('says a session is required when the run never held one', () => { + const message = sessionInterruptedMessage({ command: 'pos-cli sync', expired: false }); + + expect(message).toContain('requires a two-factor session'); + expect(message).not.toContain('has expired'); + }); +}); diff --git a/test/unit/watch.test.js b/test/unit/watch.test.js index 471dcf1c..70ce082c 100644 --- a/test/unit/watch.test.js +++ b/test/unit/watch.test.js @@ -72,6 +72,7 @@ vi.mock('#lib/templates.js', () => ({ fillInTemplateValues: vi.fn().mockReturnVa // --- static imports (resolved after mocks) -------------------------------- import fs from 'fs'; +import path from 'path'; import logger from '#lib/logger.js'; import ServerError from '#lib/ServerError.js'; import Gateway from '#lib/proxy.js'; @@ -303,8 +304,14 @@ describe('asset sync', () => { }); gateway = { getInstance: vi.fn().mockResolvedValue({ id: 'inst-1' }), - sendManifest: vi.fn().mockResolvedValue({}) + sendManifest: vi.fn().mockResolvedValue({}), + // Present so the routing tests can assert an asset never takes the + // code-file path; the upload tests below never reach it. + sync: vi.fn().mockResolvedValue({}) }; + // The only fs.existsSync on this path is the public/private precedence check. + // Default to "no private twin" so every other test uploads as it always did. + vi.spyOn(fs, 'existsSync').mockReturnValue(false); }); afterEach(async () => { @@ -313,6 +320,7 @@ describe('asset sync', () => { // still scheduled, so it would never fire again in any later test. await vi.runOnlyPendingTimersAsync(); vi.useRealTimers(); + fs.existsSync.mockRestore(); }); test('uploads the asset and flushes the manifest on success', async () => { @@ -414,6 +422,79 @@ describe('asset sync', () => { }); }); + // A module directory name is not restricted to word characters, and hyphens are + // the norm ("common-styling", "oauth-github"). The asset matcher used `\w+`, so + // for those modules every asset was misrouted to pushFile and uploaded as a code + // file: it came back with line endings rewritten and a Content-Type derived + // remotely rather than the one sync sends, which is enough for a browser to + // refuse to execute a .js file that is otherwise byte-perfect. deploy globs + // `modules/*/...` and so never had the problem, which is why deploying the same + // file always appeared to "fix" it. + test.each([ + ['modules/common-styling/public/assets/js/styleguide.js', 'assets/modules/common-styling/js/${filename}'], + ['modules/oauth-github/public/assets/style/main.css', 'assets/modules/oauth-github/style/${filename}'], + ['modules/pos.module/public/assets/js/app.js', 'assets/modules/pos.module/js/${filename}'], + // private/assets is served from the same CDN path as public/assets — deploy + // packs `{public,private}/assets/**` — so sync has to upload it the same way. + ['modules/common-styling/private/assets/js/styleguide.js', 'assets/modules/common-styling/js/${filename}'], + ['modules/user/private/assets/style/main.css', 'assets/modules/user/style/${filename}'], + // A directory named "public" below the asset root is part of the asset path, + // not the module's public/private split. + ['modules/user/public/assets/public/app.js', 'assets/modules/user/public/${filename}'] + ])('uploads %s directly instead of sending it as a code file', async (assetPath, key) => { + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, assetPath); + + expect(gateway.sync).not.toHaveBeenCalled(); + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + expect(uploadFileFormData).toHaveBeenLastCalledWith(assetPath, { + url: 'https://s3.example.com/bucket', + fields: { key } + }); + expect(gateway.sendManifest).toHaveBeenCalledTimes(1); + }); + + // deploy resolves a public/private collision in favour of the private copy + // (packAssets skips the public one), and both land on one CDN path. Uploading + // the public copy would put content there that the next deploy replaces. + test('skips a public asset that a private copy shadows, as deploy does', async () => { + vi.spyOn(fs, 'existsSync').mockImplementation( + (p) => path.normalize(p) === path.normalize('modules/theme/private/assets/js/app.js') + ); + + await sendFile(gateway, 'modules/theme/public/assets/js/app.js'); + + expect(uploadFileFormData).not.toHaveBeenCalled(); + expect(gateway.sync).not.toHaveBeenCalled(); + expect(logger.Warn).toHaveBeenCalledWith(expect.stringContaining('modules/theme/private/assets/js/app.js')); + }); + + test('uploads the private copy of a shadowed asset', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, 'modules/theme/private/assets/js/app.js'); + + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + }); + + test('uploads a public asset when the module has no private copy of it', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, 'modules/theme/public/assets/js/app.js'); + + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + }); + + test('still sends non-asset files in a hyphenated module as code files', async () => { + await sendFile(gateway, 'modules/common-styling/public/views/pages/index.liquid'); + + expect(uploadFileFormData).not.toHaveBeenCalled(); + expect(gateway.sync).toHaveBeenCalledTimes(1); + }); + test('keeps the batch for the next flush when registering the assets fails', async () => { uploadFileFormData.mockResolvedValue(true); gateway.sendManifest.mockRejectedValueOnce(new Error('502 Bad Gateway')); @@ -500,6 +581,25 @@ describe('start', () => { expect(mockGatewayInstance.ping).toHaveBeenCalled(); }); + // Refusing to step up mid-run is the Gateway's policy to enforce, so the command the + // operator gets told to restart has to reach it. `gui serve --sync` names itself rather + // than `pos-cli sync`, because restarting sync alone would not bring the web server back. + test('hands the Gateway the command to name when a session expires mid-run', async () => { + await start(env, false, false, { restartCommand: 'pos-cli gui serve staging --sync' }); + + expect(Gateway).toHaveBeenCalledWith( + expect.objectContaining({ restartCommand: 'pos-cli gui serve staging --sync' }) + ); + }); + + // Left unset by callers that are a single request: those step up in place, which is the + // right thing when the prompt is the only thing on screen. + test('leaves the restart command unset when the caller did not name one', async () => { + await start(env, false, false); + + expect(Gateway).toHaveBeenCalledWith(expect.objectContaining({ restartCommand: undefined })); + }); + test('calls ServerError.handler and exits on network error during ping', async () => { const networkErr = Object.assign(new Error('Connection refused'), { name: 'RequestError' }); mockGatewayInstance.ping.mockRejectedValue(networkErr); @@ -620,3 +720,49 @@ describe('handleWatcherError', () => { ); }); }); + +// --- expired two-factor session ------------------------------------------- + +describe('an expired two-factor session', () => { + // What Gateway throws in place of stepping up, once watch mode has set restartCommand: + // a prompt cannot be raised into a queue that has CONCURRENCY uploads in flight and + // file events still arriving behind them. + const sessionExpired = () => + Object.assign( + new Error( + 'Your two-factor session has expired, so the instance stopped accepting changes.' + + '\nRun `pos-cli sync staging` again — it asks for a code once, before watching starts.' + ), + { name: 'TwoFactorError' } + ); + + test('ends the run once, instead of blaming every queued file in turn', async () => { + vi.clearAllMocks(); + vi.spyOn(fs, 'createReadStream').mockReturnValue('mock-stream'); + const gateway = { sync: vi.fn().mockRejectedValue(sessionExpired()) }; + + // Resolves rather than throwing: the queue callback still has to run, and there is + // nothing here for it to retry. + await expect(pushFile(gateway, 'app/views/pages/a.liquid')).resolves.toBeUndefined(); + + expect(logger.Error).toHaveBeenCalledWith( + expect.stringContaining('Your two-factor session has expired'), + expect.objectContaining({ hideTimestamp: true }) + ); + expect(logger.Error).toHaveBeenCalledWith( + expect.stringContaining('pos-cli sync staging'), + expect.anything() + ); + // Never routed through the per-file handlers — those would report it against the file, + // which had nothing wrong with it. + expect(ServerError.handler).not.toHaveBeenCalled(); + + // The rest of the in-flight batch is refused together; the operator must not read the + // same four lines once per file. + logger.Error.mockClear(); + await pushFile(gateway, 'app/views/pages/b.liquid'); + expect(logger.Error).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); +});