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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 34 additions & 34 deletions scripts/npm/configure-staged-publishing-browser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -52,6 +51,7 @@ import {
DEFAULT_PROFILE_DIR,
openNpmBrowserSession,
optIntoChallengeCooldown,
pauseForChallenge,
} from '../fleet/publish-infra/npm/browser-session.mts'

export { DEFAULT_PROFILE_DIR }
Expand All @@ -77,7 +77,6 @@ import {
closeStrayBlankPages,
dismissSiteNotifications,
isPolledPageOnTarget,
pauseForOperatorInPlace,
removeOperatorOverlay,
syncOperatorOverlay,
} from './configure-staged-publishing-operator.mts'
Expand Down Expand Up @@ -163,10 +162,11 @@ async function settleAccessPage(page: Page): Promise<void> {
* 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.
Expand All @@ -187,7 +187,7 @@ export async function waitForAccessPage(
const pause =
opts.pause ??
(async config => {
await pauseForOperatorInPlace({ ...config, page })
await pauseForChallenge(page, { ...config, announced: false })
Comment thread
jdalton marked this conversation as resolved.
})
const started = Date.now()
let navigated = opts.navigate === false
Expand Down Expand Up @@ -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,
})
Expand Down
96 changes: 29 additions & 67 deletions scripts/npm/configure-staged-publishing-operator.mts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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)
}
37 changes: 37 additions & 0 deletions test/scripts/npm/configure-staged-publishing-browser.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading