diff --git a/scripts/npm/configure-staged-publishing-browser.mts b/scripts/npm/configure-staged-publishing-browser.mts index 2cb3df407..41ca81998 100644 --- a/scripts/npm/configure-staged-publishing-browser.mts +++ b/scripts/npm/configure-staged-publishing-browser.mts @@ -12,33 +12,32 @@ * retry ladder against a bot challenge earns a rate limit. Each poll prints * how long the run has waited and how long remains, so the wait is visible * rather than a silent hang, and nothing is written while a challenge is - * outstanding. - * A signed-in session is NOT the same as a readable page, which is why every - * read goes through {@link waitForAccessPage} first. `/-/whoami` can answer - * with the username while npmjs still serves the sign-in / one-time-password - * interstitial for the access page, and that interstitial comes back through - * the spiferack fetch as HTTP 200 JSON. Reading it as a payload is how a - * package the operator was still signing in for got reported `unreadable`. - * So the wait polls until the page is authenticated AND settled on the access - * URL, and it never navigates while the operator holds the window — a `goto` - * mid-wait would wipe a half-typed one-time password. + * outstanding. A signed-in session is NOT the same as a readable page, which + * is why every read goes through {@link waitForAccessPage} first. `/-/whoami` + * can answer with the username while npmjs still serves the sign-in / + * one-time-password interstitial for the access page, and that interstitial + * comes back through the spiferack fetch as HTTP 200 JSON. Reading it as a + * payload is how a package the operator was still signing in for got reported + * `unreadable`. So the wait polls until the page is authenticated AND settled + * on the access URL, and it never navigates while the operator holds the + * window — a `goto` mid-wait would wipe a half-typed one-time password. * NOTHING here navigates after that first `goto`, and that is the module's - * load-bearing invariant rather than a nicety. The fleet's shared - * `pauseForChallenge` reloads the page on every fresh pause, and the fleet's - * `driveVerifiedSave` re-navigates on every attempt; together they produced a - * loop on a live run where the reload closed the trusted-publisher form it - * had just opened, and the rapid reload traffic PROVOKED the very Cloudflare - * challenges it was pausing for. So the pause here is - * {@link pauseForOperatorInPlace} — the fleet's operator UX, its gate block, - * its desktop ping and its budget, with the `goto` removed — and the write - * goes through `./configure-staged-publishing-write.mts`, which opens the - * form once and treats an in-place RE-READ as the arbiter of success rather - * than the click. - * During a REAL challenge or two-factor step-up the Socket shield is injected - * into the page as an operator-attention cue. It is best-effort garnish: - * `pointer-events: none` so it can never swallow the verify click, wrapped in - * try/catch so a page that refuses evaluation cannot break the wait, and - * removed the moment readiness clears. + * load-bearing invariant rather than a nicety. A reload closed the + * trusted-publisher form on a live run, and the rapid reload traffic PROVOKED + * the very Cloudflare challenges it was pausing for. The pause is the fleet's + * `pauseForChallenge`, called directly. This module used to hand-copy it to + * strip its `goto`; the fleet pause now waits in place itself, so the copy + * only stopped the challenge rhythm's later fixes — the holding screen, the + * rerun hint, the injectable clock — from reaching this repo. The write still + * goes through `./configure-staged-publishing-write.mts` rather than the + * fleet's `driveVerifiedSave`, which DOES still re-navigate on every attempt: + * it opens the form once and treats an in-place RE-READ as the arbiter of + * success rather than the click. During a REAL challenge or two-factor + * step-up the Socket shield is injected into the page as an + * operator-attention cue. It is best-effort garnish: `pointer-events: none` + * so it can never swallow the verify click, wrapped in try/catch so a page + * that refuses evaluation cannot break the wait, and removed the moment + * readiness clears. */ import { MILLISECONDS_PER_SECOND } from '@socketsecurity/lib-stable/constants/time' @@ -52,6 +51,7 @@ import { DEFAULT_PROFILE_DIR, openNpmBrowserSession, optIntoChallengeCooldown, + pauseForChallenge, } from '../fleet/publish-infra/npm/browser-session.mts' export { DEFAULT_PROFILE_DIR } @@ -77,7 +77,6 @@ import { closeStrayBlankPages, dismissSiteNotifications, isPolledPageOnTarget, - pauseForOperatorInPlace, removeOperatorOverlay, syncOperatorOverlay, } from './configure-staged-publishing-operator.mts' @@ -163,10 +162,11 @@ async function settleAccessPage(page: Page): Promise { * pattern that earns a challenge in the first place. `navigate: false` skips * even that first one, for a caller whose page is already there. * - * A real challenge or step-up pauses through {@link pauseForOperatorInPlace}, - * which keeps the fleet's operator UX — the 🖐 gate block, the desktop ping, - * the cooldown opt-in, the budget — and drops its reload. The Socket shield - * goes up for those two states and comes down for everything else. + * A real challenge or step-up pauses through the fleet's `pauseForChallenge`, + * which owns the operator UX — the 🖐 gate block, the desktop ping, the + * cooldown opt-in, the holding screen, the budget — and waits in place without + * navigating. The Socket shield goes up for those two states and comes down for + * everything else. * * @throws {Error} When the wait outlasts {@link WAIT_FOR_OPERATOR_MS}, when the * session is signed out, or when npm answers with a real HTTP error. @@ -187,7 +187,7 @@ export async function waitForAccessPage( const pause = opts.pause ?? (async config => { - await pauseForOperatorInPlace({ ...config, page }) + await pauseForChallenge(page, { ...config, announced: false }) }) const started = Date.now() let navigated = opts.navigate === false @@ -415,11 +415,11 @@ export async function applyStagedPublishing( desired, label: target.name, pause: async () => { - await pauseForOperatorInPlace({ + await pauseForChallenge(page, { + announced: false, budgetMs, elapsedMs: Date.now() - started, label: target.name, - page, pollMs: OPERATOR_POLL_MS, url: target.settingsUrl, }) diff --git a/scripts/npm/configure-staged-publishing-operator.mts b/scripts/npm/configure-staged-publishing-operator.mts index 3b2097c68..c65e1d72e 100644 --- a/scripts/npm/configure-staged-publishing-operator.mts +++ b/scripts/npm/configure-staged-publishing-operator.mts @@ -1,34 +1,32 @@ /** - * @file What the configurator does while it is WAITING ON A PERSON: the pause - * itself, and the Socket shield that asks for their attention. - * Both exist because of the same live failure. The fleet's shared - * `pauseForChallenge` is the right operator experience — it owns the 🖐 gate - * block, the desktop ping, the cross-call pause tracker, the progress cadence - * and the budget — but on a fresh pause it re-navigates to the URL. On a live - * run that reload closed the trusted-publisher form the write lane had just - * opened, and the resulting reload loop is itself the traffic shape npm's bot - * management answers with a challenge: settings page → form opens → reload → - * challenge → pause → reload, once per lap. So the pause here composes the - * same gate tick the fleet pause is built on and simply does not navigate. - * The overlay is the other half of the same moment. When a person genuinely - * has to act, the shield says so from the middle of the window instead of - * from a terminal they are not looking at. It is garnish and is written that - * way: every failure swallowed, nothing load-bearing behind it. - * The same best-effort layer keeps the driven window legible: npm's stacked - * site-notification banners get dismissed once the page is settled, and stray - * `about:blank` tabs get closed so the run drives ONE page. Neither is ever a - * readiness signal — the payload decides that — but a session juggling pages - * can poll the wrong one, so the page being polled is asserted to be the one - * holding the access URL. + * @file What the configurator SHOWS the operator while it is waiting on them, + * and what it does to keep the driven window legible. The pause itself is not + * here: it belongs to the fleet's `pauseForChallenge`, which this module used + * to hand-copy. That copy existed for one reason. The fleet pause + * re-navigated to the URL on a fresh pause, and on a live run that reload + * closed the trusted-publisher form the write lane had just opened — settings + * page → form opens → reload → challenge → pause → reload, once per lap, + * which is itself the traffic shape npm's bot management answers with a + * challenge. The fleet pause now waits IN PLACE and never navigates, so the + * copy had nothing left to diverge for and only kept this repo from receiving + * the challenge rhythm's later fixes. The no-navigation invariant is still + * asserted here — see + * `test/scripts/npm/configure-staged-publishing-browser.test.mts`, which + * drives the delegated pause and counts `goto` calls — because it is the + * fleet's contract now rather than this module's workaround. The overlay is + * what stays. When a person genuinely has to act, the shield says so from the + * middle of the window instead of from a terminal they are not looking at. It + * is garnish and is written that way: every failure swallowed, nothing + * load-bearing behind it. The same best-effort layer keeps the driven window + * legible: npm's stacked site-notification banners get dismissed once the + * page is settled, and stray `about:blank` tabs get closed so the run drives + * ONE page. Neither is ever a readiness signal — the payload decides that — + * but a session juggling pages can poll the wrong one, so the page being + * polled is asserted to be the one holding the access URL. */ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { sleep } from '@socketsecurity/lib-stable/promises/timers' - import type { Page } from 'playwright-core' -import { optIntoChallengeCooldown } from '../fleet/publish-infra/npm/browser-session.mts' -import { tickChallengeGate } from '../fleet/publish-infra/npm/challenge-gate.mts' import { buildOperatorOverlayInjectionScript, buildOperatorOverlayRemovalScript, @@ -37,13 +35,12 @@ import { import type { AccessPageReadiness } from './configure-staged-publishing-session.mts' -const logger = getDefaultLogger() - /** - * The operator wait a caller can substitute. One tick: announce if this is a - * fresh pause, keep the cooldown opt-in ticked, sleep. Injected rather than - * imported at the call site so a wait loop's no-navigation invariant is - * testable with a fake page and no gate files. + * The operator wait a caller can substitute, shaped to what a readiness poll + * knows rather than to the fleet pause's own parameter list. The production + * implementation delegates to `pauseForChallenge`; this seam exists so a wait + * loop's no-navigation invariant is also testable without touching the gate + * files or the operator's desktop. */ export type OperatorPause = (config: { budgetMs: number @@ -189,38 +186,3 @@ export function isPolledPageOnTarget( } return pageUrl.split('?')[0] === settingsUrl.split('?')[0] } - -/** - * One tick of the operator pause, WITHOUT the fleet pause's reload. The window - * is brought forward instead, which gets the operator's attention without - * touching the page's state. - * - * @throws {Error} When the challenge outlasts its budget. - */ -export async function pauseForOperatorInPlace(config: { - budgetMs: number - elapsedMs: number - label: string - page: Page - pollMs: number - url: string -}): Promise { - const cfg = { __proto__: null, ...config } as typeof config - const tick = await tickChallengeGate(cfg.page, { - budgetMs: cfg.budgetMs, - fallbackElapsedMs: cfg.elapsedMs, - pkg: cfg.label, - url: cfg.url, - }) - if (tick.expiredMessage !== undefined) { - throw new Error(tick.expiredMessage) - } - if (tick.freshPause) { - logger.warn( - `${cfg.label}: human verification is on screen. Solve it in the Chrome window — the run is waiting in place and will not reload the page.`, - ) - await cfg.page.bringToFront().catch(() => {}) - } - await optIntoChallengeCooldown(cfg.page) - await sleep(cfg.pollMs) -} diff --git a/test/scripts/npm/configure-staged-publishing-browser.test.mts b/test/scripts/npm/configure-staged-publishing-browser.test.mts index 6693915a2..815d027c0 100644 --- a/test/scripts/npm/configure-staged-publishing-browser.test.mts +++ b/test/scripts/npm/configure-staged-publishing-browser.test.mts @@ -46,6 +46,13 @@ interface FakePage { // The narrowest page a wait loop touches: navigate, settle, fetch, report a // URL, come to the front. Enough to drive the loop; nothing that needs a // browser. +// +// It also carries what the DELEGATED fleet pause touches, so a test can drive +// the real pause instead of injecting one: an empty `locator` for the +// cooldown-opt-in probe, and a `mainFrame` whose `evaluate` swallows the +// holding-screen source. The screen is injected through the frame rather than +// `page.evaluate` precisely so it cannot eat a body sequence, and this double +// keeps that separation honest. function makeFakePage(bodies: readonly string[]): FakePage { const state = { gotoCount: 0, index: 0 } const page = { @@ -65,6 +72,17 @@ function makeFakePage(bodies: readonly string[]): FakePage { state.gotoCount += 1 return undefined }, + // No controls on the page: the cooldown opt-in and the notification + // banners both read as absent, which is a quiet no-op in each. + locator: () => ({ + count: async () => 0, + first: () => ({ + check: async () => {}, + click: async () => {}, + isChecked: async () => false, + }), + }), + mainFrame: () => ({ evaluate: async () => undefined }), url: () => ACCESS_URL, waitForLoadState: async () => {}, } as unknown as Page @@ -121,6 +139,25 @@ describe('waitForAccessPage navigation', () => { expect(fake.gotoCount).toBe(0) }) + test('the DELEGATED fleet pause never navigates either', async () => { + // The invariant, asserted against the pause the run actually uses rather + // than an injected stand-in. Every other test here hands in a fake `pause`, + // which proves the loop's own restraint but says nothing about the pause it + // delegates to — and this module used to hand-copy that pause for exactly + // one reason: the fleet's version reloaded the URL on every fresh pause, + // which closed the trusted-publisher form and provoked more challenges. + // The fleet pause now waits in place, so the copy is gone; if it ever + // regains a `goto`, this is the test that says so instead of a live run. + const fake = makeFakePage([ + CHALLENGE_BODY, + CHALLENGE_BODY, + CHALLENGE_BODY, + READY_BODY, + ]) + await waitForAccessPage(fake.page, TARGET, { budgetMs: 5000, pollMs: 1 }) + expect(fake.gotoCount).toBe(1) + }) + test('a banner-laden access page resolves without ever pausing', async () => { // The false positive, end to end: the run used to announce human // verification against this exact body and wait out its whole budget.