From 8934b7116ea537446f95e5896a271d2716dacc09 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 13:55:41 -0500 Subject: [PATCH 01/22] feat(rechallenge): add types and error classes --- src/lib/rechallenge/errors.ts | 52 +++++++++++++++++++++++++++++++++++ src/lib/rechallenge/index.ts | 2 ++ src/lib/rechallenge/types.ts | 45 ++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/lib/rechallenge/errors.ts create mode 100644 src/lib/rechallenge/index.ts create mode 100644 src/lib/rechallenge/types.ts diff --git a/src/lib/rechallenge/errors.ts b/src/lib/rechallenge/errors.ts new file mode 100644 index 000000000..bfef60d4e --- /dev/null +++ b/src/lib/rechallenge/errors.ts @@ -0,0 +1,52 @@ +import type { RechallengeStatus } from './types'; + +export class RechallengeError extends Error { + public readonly scope: string; + constructor( message: string, scope: string ) { + super( message ); + this.name = 'RechallengeError'; + this.scope = scope; + } +} + +export class RechallengeUnsupportedVersionError extends RechallengeError { + constructor( version: string, scope: string ) { + super( + `Server requested rechallenge version "${ version }" but this CLI only supports v2. Update vip-cli.`, + scope + ); + this.name = 'RechallengeUnsupportedVersionError'; + } +} + +export class RechallengeTerminalError extends RechallengeError { + public readonly status: RechallengeStatus; + constructor( status: RechallengeStatus, scope: string, detail?: string ) { + super( + `Step-up verification did not complete (status=${ status })${ + detail ? `: ${ detail }` : '' + }.`, + scope + ); + this.name = 'RechallengeTerminalError'; + this.status = status; + } +} + +export class RechallengeAbortedError extends RechallengeError { + constructor( scope: string ) { + super( 'Step-up verification was cancelled.', scope ); + this.name = 'RechallengeAbortedError'; + } +} + +export class RechallengeHttpError extends RechallengeError { + public readonly statusCode: number; + public readonly bodyText: string; + constructor( statusCode: number, bodyText: string, scope: string ) { + super( `Step-up verification request failed (HTTP ${ statusCode }): ${ bodyText }`, scope ); + this.name = 'RechallengeHttpError'; + this.statusCode = statusCode; + this.bodyText = bodyText; + } +} diff --git a/src/lib/rechallenge/index.ts b/src/lib/rechallenge/index.ts new file mode 100644 index 000000000..bfde7f838 --- /dev/null +++ b/src/lib/rechallenge/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './errors'; diff --git a/src/lib/rechallenge/types.ts b/src/lib/rechallenge/types.ts new file mode 100644 index 000000000..950a14935 --- /dev/null +++ b/src/lib/rechallenge/types.ts @@ -0,0 +1,45 @@ +export const ELEVATED_PERMISSION_ERROR_CODE = 'elevated-permission-required'; +export const RECHALLENGE_VERSION = 'v2'; +export const CLIENT_TYPE = 'cli'; + +export type RechallengeStatus = 'pending' | 'verified' | 'expired' | 'failed' | 'cancelled'; + +/** Shape of `errors[0].extensions.rechallenge` from the API. */ +export interface RechallengeExtension { + version: string; + createSessionPath: string; + statusPathTemplate: string; + exchangePathTemplate: string; + elevatedHeaderName: string; +} + +/** Response from POST {createSessionPath}. */ +export interface RechallengeSession { + challengeId: string; + status: RechallengeStatus; + verificationUrl: string; + pollIntervalSeconds: number; + expiresAt: string; // ISO-8601 +} + +/** Response from GET {statusPathTemplate}. */ +export interface RechallengeSessionStatus { + challengeId: string; + status: RechallengeStatus; + expiresAt: string; + verifiedAt?: string; + provider?: 'passkeys' | 'totp' | 'sso-saml' | 'unknown'; + pollIntervalSeconds: number; + statusReason?: { code: string; message: string }; +} + +/** Response from POST {exchangePathTemplate}. */ +export interface ElevatedTokenExchangeResponse { + elevatedToken: ElevatedToken; +} + +export interface ElevatedToken { + token: string; + expiresAt: string; // ISO-8601 + purpose: string; +} From 8655df4430d518f292e16a90095467923ad46a54 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 14:03:02 -0500 Subject: [PATCH 02/22] feat(rechallenge): add keychain-backed per-scope elevated-token cache --- __tests__/lib/rechallenge/token-cache.test.ts | 104 +++++++++++++++++ src/lib/rechallenge/index.ts | 1 + src/lib/rechallenge/token-cache.ts | 105 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 __tests__/lib/rechallenge/token-cache.test.ts create mode 100644 src/lib/rechallenge/token-cache.ts diff --git a/__tests__/lib/rechallenge/token-cache.test.ts b/__tests__/lib/rechallenge/token-cache.test.ts new file mode 100644 index 000000000..ecb308203 --- /dev/null +++ b/__tests__/lib/rechallenge/token-cache.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import keychain from '../../../src/lib/keychain'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { ElevatedToken } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/keychain', () => { + const store = new Map< string, string >(); + return { + __esModule: true, + default: { + getPassword: jest.fn( ( service: string ) => + Promise.resolve( store.get( service ) ?? null ) + ), + setPassword: jest.fn( ( service: string, password: string ) => { + store.set( service, password ); + return Promise.resolve( true ); + } ), + deletePassword: jest.fn( ( service: string ) => { + const had = store.delete( service ); + return Promise.resolve( had ); + } ), + __store: store, + }, + }; +} ); + +function makeToken( overrides: Partial< ElevatedToken > = {} ): ElevatedToken { + return { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + ...overrides, + }; +} + +describe( 'rechallenge token cache', () => { + beforeEach( async () => { + await tokenCache.clearAll(); + tokenCache._resetInMemoryForTests(); + jest.clearAllMocks(); + } ); + + it( 'returns null when no token has been stored for a scope', async () => { + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + } ); + + it( 'stores and retrieves a token by scope', async () => { + const token = makeToken(); + await tokenCache.set( 'updateDefensiveModeStatus', token ); + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toEqual( token ); + } ); + + it( 'keeps tokens isolated by scope', async () => { + const a = makeToken( { token: 'A' } ); + const b = makeToken( { token: 'B' } ); + await tokenCache.set( 'updateDefensiveModeStatus', a ); + await tokenCache.set( 'updateDefensiveModeConfig', b ); + expect( ( await tokenCache.get( 'updateDefensiveModeStatus' ) )?.token ).toBe( 'A' ); + expect( ( await tokenCache.get( 'updateDefensiveModeConfig' ) )?.token ).toBe( 'B' ); + } ); + + it( 'returns null and self-evicts when token is expired', async () => { + const expired = makeToken( { + expiresAt: new Date( Date.now() - 1_000 ).toISOString(), + } ); + await tokenCache.set( 'updateDefensiveModeStatus', expired ); + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + // Eviction writes through to keychain so the expired entry can't reappear. + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( keychain.deletePassword ).toHaveBeenCalled(); + } ); + + it( 'clearAll removes every scope', async () => { + await tokenCache.set( 'a', makeToken() ); + await tokenCache.set( 'b', makeToken() ); + await tokenCache.clearAll(); + expect( await tokenCache.get( 'a' ) ).toBeNull(); + expect( await tokenCache.get( 'b' ) ).toBeNull(); + } ); + + it( 'clearScope removes only the requested scope', async () => { + await tokenCache.set( 'a', makeToken( { token: 'A' } ) ); + await tokenCache.set( 'b', makeToken( { token: 'B' } ) ); + await tokenCache.clearScope( 'a' ); + expect( await tokenCache.get( 'a' ) ).toBeNull(); + expect( ( await tokenCache.get( 'b' ) )?.token ).toBe( 'B' ); + } ); + + it( 'resets and purges keychain when stored blob is malformed JSON', async () => { + // Force a corrupt blob to land in the mock store. We need the + // keychain mock to return invalid JSON on the next read. + const keychainMock = keychain as typeof keychain & { + getPassword: jest.Mock; + deletePassword: jest.Mock; + }; + keychainMock.getPassword.mockResolvedValueOnce( 'not-valid-json{' ); + tokenCache._resetInMemoryForTests(); + + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + expect( keychainMock.deletePassword ).toHaveBeenCalled(); + } ); +} ); diff --git a/src/lib/rechallenge/index.ts b/src/lib/rechallenge/index.ts index bfde7f838..14fb821c7 100644 --- a/src/lib/rechallenge/index.ts +++ b/src/lib/rechallenge/index.ts @@ -1,2 +1,3 @@ +export { default as tokenCache } from './token-cache'; export * from './types'; export * from './errors'; diff --git a/src/lib/rechallenge/token-cache.ts b/src/lib/rechallenge/token-cache.ts new file mode 100644 index 000000000..89c3897e3 --- /dev/null +++ b/src/lib/rechallenge/token-cache.ts @@ -0,0 +1,105 @@ +import debugLib from 'debug'; + +import { API_HOST, PRODUCTION_API_HOST } from '../api'; +import keychain from '../keychain'; + +import type { ElevatedToken } from './types'; + +const debug = debugLib( '@automattic/vip:rechallenge:cache' ); +const BASE_SERVICE = 'vip-go-cli:elevated'; + +function serviceName(): string { + if ( API_HOST === PRODUCTION_API_HOST ) { + return BASE_SERVICE; + } + const sanitized = API_HOST.replace( /[^a-z0-9]/gi, '-' ); + return `${ BASE_SERVICE }:${ sanitized }`; +} + +type Blob = Record< string, ElevatedToken >; + +let inMemory: Blob | null = null; + +async function read(): Promise< Blob > { + if ( inMemory ) { + return inMemory; + } + const raw = await keychain.getPassword( serviceName() ); + if ( ! raw ) { + inMemory = {}; + return inMemory; + } + try { + const parsed = JSON.parse( raw ) as Blob; + inMemory = typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch ( err ) { + debug( 'Failed to parse elevated token blob; resetting (%o)', err ); + inMemory = {}; + await keychain.deletePassword( serviceName() ); + } + return inMemory; +} + +async function write( blob: Blob ): Promise< void > { + inMemory = blob; + if ( Object.keys( blob ).length === 0 ) { + await keychain.deletePassword( serviceName() ); + return; + } + await keychain.setPassword( serviceName(), JSON.stringify( blob ) ); +} + +function isExpired( token: ElevatedToken ): boolean { + const exp = Date.parse( token.expiresAt ); + if ( Number.isNaN( exp ) ) { + return true; + } + // Treat tokens within the next 5 seconds as effectively expired. + return Date.now() >= exp - 5_000; +} + +async function get( scope: string ): Promise< ElevatedToken | null > { + const blob = await read(); + const token = blob[ scope ]; + if ( ! token ) { + return null; + } + if ( isExpired( token ) ) { + debug( 'Cached elevated token for %s is expired; evicting', scope ); + const { [ scope ]: _evicted, ...rest } = blob; + await write( rest ); + return null; + } + return token; +} + +async function set( scope: string, token: ElevatedToken ): Promise< void > { + const blob = await read(); + blob[ scope ] = token; + await write( blob ); +} + +async function clearScope( scope: string ): Promise< void > { + const blob = await read(); + if ( scope in blob ) { + const { [ scope ]: _removed, ...rest } = blob; + await write( rest ); + } +} + +async function clearAll(): Promise< void > { + inMemory = {}; + await keychain.deletePassword( serviceName() ); +} + +function _resetInMemoryForTests(): void { + inMemory = null; +} + +export default { + get, + set, + clearScope, + clearAll, + _resetInMemoryForTests, +}; From 35f9916dff3cc55609cc9d9fa0d6b9ed491fa203 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 14:16:51 -0500 Subject: [PATCH 03/22] feat(rechallenge): add REST client for Parker v2 session endpoints --- __tests__/lib/rechallenge/client.test.ts | 135 +++++++++++++++++++++++ src/lib/rechallenge/client.ts | 66 +++++++++++ 2 files changed, 201 insertions(+) create mode 100644 __tests__/lib/rechallenge/client.test.ts create mode 100644 src/lib/rechallenge/client.ts diff --git a/__tests__/lib/rechallenge/client.test.ts b/__tests__/lib/rechallenge/client.test.ts new file mode 100644 index 000000000..513bc7743 --- /dev/null +++ b/__tests__/lib/rechallenge/client.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import http from '../../../src/lib/api/http'; +import * as client from '../../../src/lib/rechallenge/client'; +import { RechallengeHttpError } from '../../../src/lib/rechallenge/errors'; + +jest.mock( '../../../src/lib/api/http' ); +const mockHttp = http as unknown as jest.Mock; + +function jsonResponse( status: number, body: unknown ) { + return Promise.resolve( { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve( body ), + text: () => Promise.resolve( JSON.stringify( body ) ), + } as unknown as Response ); +} + +describe( 'rechallenge client.createSession', () => { + beforeEach( () => mockHttp.mockReset() ); + + it( 'POSTs the create-session path with clientType and requestedOperation', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 201, { + challengeId: 'rch_abc', + status: 'pending', + verificationUrl: 'https://example.com/verify', + pollIntervalSeconds: 2, + expiresAt: new Date( Date.now() + 900_000 ).toISOString(), + } ) + ); + + const session = await client.createSession( { + path: '/rechallenge/v2/sessions', + requestedOperation: 'updateDefensiveModeStatus', + } ); + + expect( mockHttp ).toHaveBeenCalledTimes( 1 ); + const [ path, options ] = mockHttp.mock.calls[ 0 ] as [ string, Record< string, unknown > ]; + expect( path ).toBe( '/rechallenge/v2/sessions' ); + expect( options.method ).toBe( 'POST' ); + const headers = options.headers as Record< string, string >; + expect( headers[ 'Idempotency-Key' ] ).toMatch( /^[a-f0-9-]{36}$/ ); + expect( options.body ).toEqual( { + clientType: 'cli', + requestedOperation: 'updateDefensiveModeStatus', + } ); + expect( session.challengeId ).toBe( 'rch_abc' ); + } ); + + it( 'throws RechallengeHttpError on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 500, { message: 'boom' } ) ); + await expect( + client.createSession( { + path: '/rechallenge/v2/sessions', + requestedOperation: 'updateDefensiveModeStatus', + } ) + ).rejects.toBeInstanceOf( RechallengeHttpError ); + } ); +} ); + +describe( 'rechallenge client.getSessionStatus', () => { + beforeEach( () => mockHttp.mockReset() ); + + it( 'GETs the status template with challengeId substituted', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 200, { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date().toISOString(), + pollIntervalSeconds: 2, + provider: 'passkeys', + } ) + ); + const status = await client.getSessionStatus( { + template: '/rechallenge/v2/sessions/{challengeId}', + challengeId: 'rch_abc', + } ); + expect( mockHttp ).toHaveBeenCalledWith( + '/rechallenge/v2/sessions/rch_abc', + expect.objectContaining( { method: 'GET' } ) + ); + expect( status.status ).toBe( 'verified' ); + } ); + + it( 'throws RechallengeHttpError on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 404, { message: 'not found' } ) ); + await expect( + client.getSessionStatus( { + template: '/rechallenge/v2/sessions/{challengeId}', + challengeId: 'rch_abc', + scope: 'updateDefensiveModeStatus', + } ) + ).rejects.toBeInstanceOf( RechallengeHttpError ); + } ); +} ); + +describe( 'rechallenge client.exchange', () => { + beforeEach( () => mockHttp.mockReset() ); + + it( 'POSTs the exchange template and returns elevatedToken', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 200, { + elevatedToken: { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + }, + } ) + ); + const exchange = await client.exchange( { + template: '/rechallenge/v2/sessions/{challengeId}/exchange', + challengeId: 'rch_abc', + } ); + expect( mockHttp ).toHaveBeenCalledWith( + '/rechallenge/v2/sessions/rch_abc/exchange', + expect.objectContaining( { method: 'POST' } ) + ); + expect( exchange.elevatedToken.token ).toBe( 'jwt.payload.sig' ); + } ); + + it( 'throws RechallengeHttpError with bodyText on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 401, { message: 'unauthorized' } ) ); + const promise = client.exchange( { + template: '/rechallenge/v2/sessions/{challengeId}/exchange', + challengeId: 'rch_abc', + scope: 'updateDefensiveModeStatus', + } ); + await expect( promise ).rejects.toBeInstanceOf( RechallengeHttpError ); + await expect( promise ).rejects.toMatchObject( { + statusCode: 401, + bodyText: expect.stringContaining( 'unauthorized' ), + } ); + } ); +} ); diff --git a/src/lib/rechallenge/client.ts b/src/lib/rechallenge/client.ts new file mode 100644 index 000000000..bc52ca8bb --- /dev/null +++ b/src/lib/rechallenge/client.ts @@ -0,0 +1,66 @@ +import debugLib from 'debug'; +import { randomUUID } from 'node:crypto'; + +import { RechallengeHttpError } from './errors'; +import { CLIENT_TYPE } from './types'; +import http from '../api/http'; + +import type { + ElevatedTokenExchangeResponse, + RechallengeSession, + RechallengeSessionStatus, +} from './types'; +import type { Response } from 'node-fetch'; + +const debug = debugLib( '@automattic/vip:rechallenge:client' ); + +function fillTemplate( template: string, challengeId: string ): string { + return template.replaceAll( '{challengeId}', encodeURIComponent( challengeId ) ); +} + +async function parseOrThrow< T >( response: Response, scope: string ): Promise< T > { + if ( ! response.ok ) { + const text = await response.text(); + throw new RechallengeHttpError( response.status, text, scope ); + } + return ( await response.json() ) as T; +} + +export async function createSession( opts: { + path: string; + requestedOperation: string; +} ): Promise< RechallengeSession > { + debug( 'createSession scope=%s', opts.requestedOperation ); + const response = await http( opts.path, { + method: 'POST', + headers: { + // New UUID per call — intent is a fresh session per invocation, not request deduplication. + 'Idempotency-Key': randomUUID(), + }, + body: { + clientType: CLIENT_TYPE, + requestedOperation: opts.requestedOperation, + }, + } ); + return parseOrThrow< RechallengeSession >( response, opts.requestedOperation ); +} + +export async function getSessionStatus( opts: { + template: string; + challengeId: string; + scope?: string; +} ): Promise< RechallengeSessionStatus > { + const path = fillTemplate( opts.template, opts.challengeId ); + const response = await http( path, { method: 'GET' } ); + return parseOrThrow< RechallengeSessionStatus >( response, opts.scope ?? '' ); +} + +export async function exchange( opts: { + template: string; + challengeId: string; + scope?: string; +} ): Promise< ElevatedTokenExchangeResponse > { + const path = fillTemplate( opts.template, opts.challengeId ); + const response = await http( path, { method: 'POST' } ); + return parseOrThrow< ElevatedTokenExchangeResponse >( response, opts.scope ?? '' ); +} From a6df809452c1eaf7a2d3589c98700120114c8f6b Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 14:28:47 -0500 Subject: [PATCH 04/22] fix(rechallenge): satisfy tsc check-types in test files Co-Authored-By: Claude Sonnet 4.6 --- __tests__/lib/rechallenge/client.test.ts | 12 +++++++++--- __tests__/lib/rechallenge/token-cache.test.ts | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/rechallenge/client.test.ts b/__tests__/lib/rechallenge/client.test.ts index 513bc7743..eb532a651 100644 --- a/__tests__/lib/rechallenge/client.test.ts +++ b/__tests__/lib/rechallenge/client.test.ts @@ -17,7 +17,9 @@ function jsonResponse( status: number, body: unknown ) { } describe( 'rechallenge client.createSession', () => { - beforeEach( () => mockHttp.mockReset() ); + beforeEach( () => { + mockHttp.mockReset(); + } ); it( 'POSTs the create-session path with clientType and requestedOperation', async () => { mockHttp.mockReturnValueOnce( @@ -60,7 +62,9 @@ describe( 'rechallenge client.createSession', () => { } ); describe( 'rechallenge client.getSessionStatus', () => { - beforeEach( () => mockHttp.mockReset() ); + beforeEach( () => { + mockHttp.mockReset(); + } ); it( 'GETs the status template with challengeId substituted', async () => { mockHttp.mockReturnValueOnce( @@ -96,7 +100,9 @@ describe( 'rechallenge client.getSessionStatus', () => { } ); describe( 'rechallenge client.exchange', () => { - beforeEach( () => mockHttp.mockReset() ); + beforeEach( () => { + mockHttp.mockReset(); + } ); it( 'POSTs the exchange template and returns elevatedToken', async () => { mockHttp.mockReturnValueOnce( diff --git a/__tests__/lib/rechallenge/token-cache.test.ts b/__tests__/lib/rechallenge/token-cache.test.ts index ecb308203..82f64857a 100644 --- a/__tests__/lib/rechallenge/token-cache.test.ts +++ b/__tests__/lib/rechallenge/token-cache.test.ts @@ -91,9 +91,9 @@ describe( 'rechallenge token cache', () => { it( 'resets and purges keychain when stored blob is malformed JSON', async () => { // Force a corrupt blob to land in the mock store. We need the // keychain mock to return invalid JSON on the next read. - const keychainMock = keychain as typeof keychain & { - getPassword: jest.Mock; - deletePassword: jest.Mock; + const keychainMock = keychain as unknown as { + getPassword: jest.Mock< ( service: string ) => Promise< string | null > >; + deletePassword: jest.Mock< ( service: string ) => Promise< boolean > >; }; keychainMock.getPassword.mockResolvedValueOnce( 'not-valid-json{' ); tokenCache._resetInMemoryForTests(); From b2c46dec1a7078b8029197bd3265839538e8de5d Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 14:33:56 -0500 Subject: [PATCH 05/22] feat(rechallenge): orchestrate session create, poll, exchange --- __tests__/lib/rechallenge/flow.test.ts | 252 +++++++++++++++++++++++++ src/lib/rechallenge/flow.ts | 159 ++++++++++++++++ src/lib/rechallenge/open-browser.ts | 11 ++ 3 files changed, 422 insertions(+) create mode 100644 __tests__/lib/rechallenge/flow.test.ts create mode 100644 src/lib/rechallenge/flow.ts create mode 100644 src/lib/rechallenge/open-browser.ts diff --git a/__tests__/lib/rechallenge/flow.test.ts b/__tests__/lib/rechallenge/flow.test.ts new file mode 100644 index 000000000..b94d33753 --- /dev/null +++ b/__tests__/lib/rechallenge/flow.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import * as clientModule from '../../../src/lib/rechallenge/client'; +import { + RechallengeAbortedError, + RechallengeTerminalError, + RechallengeUnsupportedVersionError, +} from '../../../src/lib/rechallenge/errors'; +import { isInteractiveContext, runRechallenge } from '../../../src/lib/rechallenge/flow'; +import * as openBrowserModule from '../../../src/lib/rechallenge/open-browser'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { RechallengeExtension } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/rechallenge/client' ); +jest.mock( '../../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn( () => Promise.resolve() ), + clearScope: jest.fn(), + clearAll: jest.fn(), + }, +} ) ); +jest.mock( '../../../src/lib/rechallenge/open-browser', () => ( { + openBrowser: jest.fn( () => Promise.resolve() ), +} ) ); +jest.mock( '../../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +const mockCreate = clientModule.createSession as jest.MockedFunction< + typeof clientModule.createSession +>; +const mockGetStatus = clientModule.getSessionStatus as jest.MockedFunction< + typeof clientModule.getSessionStatus +>; +const mockExchange = clientModule.exchange as jest.MockedFunction< typeof clientModule.exchange >; +const mockSet = tokenCache.set as unknown as jest.Mock; +const mockOpenBrowser = openBrowserModule.openBrowser as jest.Mock; + +function rechallenge(): RechallengeExtension { + return { + version: 'v2', + createSessionPath: '/rechallenge/v2/sessions', + statusPathTemplate: '/rechallenge/v2/sessions/{challengeId}', + exchangePathTemplate: '/rechallenge/v2/sessions/{challengeId}/exchange', + elevatedHeaderName: 'x-elevated-token', + }; +} + +beforeEach( () => { + jest.clearAllMocks(); + mockCreate.mockResolvedValue( { + challengeId: 'rch_abc', + status: 'pending', + verificationUrl: 'https://example.com/verify', + pollIntervalSeconds: 0, // tight loop for tests + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + } ); + mockExchange.mockResolvedValue( { + elevatedToken: { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + }, + } ); +} ); + +describe( 'runRechallenge', () => { + it( 'rejects v1 with RechallengeUnsupportedVersionError', async () => { + await expect( + runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: { ...rechallenge(), version: 'v1' }, + interactive: false, + } ) + ).rejects.toBeInstanceOf( RechallengeUnsupportedVersionError ); + } ); + + it( 'polls until verified then exchanges and caches the token', async () => { + mockGetStatus + .mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'pending', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ) + .mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + provider: 'passkeys', + } ); + + const token = await runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { trackEvent } = require( '../../../src/lib/tracker' ) as { trackEvent: jest.Mock }; + expect( token.token ).toBe( 'jwt.payload.sig' ); + expect( mockGetStatus ).toHaveBeenCalledTimes( 2 ); + expect( mockExchange ).toHaveBeenCalledTimes( 1 ); + expect( mockSet ).toHaveBeenCalledWith( + 'updateDefensiveModeStatus', + expect.objectContaining( { token: 'jwt.payload.sig' } ) + ); + expect( trackEvent ).toHaveBeenCalledWith( + 'rechallenge_exchanged', + expect.objectContaining( { scope: 'updateDefensiveModeStatus' } ) + ); + } ); + + it( 'throws RechallengeTerminalError on non-verified terminal states', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'expired', + expiresAt: new Date( Date.now() - 1 ).toISOString(), + pollIntervalSeconds: 0, + statusReason: { code: 'expired', message: 'session expired' }, + } ); + + await expect( + runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ) + ).rejects.toBeInstanceOf( RechallengeTerminalError ); + } ); + + it( 'aborts when the abort signal fires', async () => { + const ac = new AbortController(); + mockGetStatus.mockImplementation( + () => + new Promise( resolve => { + setTimeout( + () => + resolve( { + challengeId: 'rch_abc', + status: 'pending', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ), + 5 + ); + } ) + ); + + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + signal: ac.signal, + } ); + setTimeout( () => ac.abort(), 10 ); + + await expect( pending ).rejects.toBeInstanceOf( RechallengeAbortedError ); + } ); + + it( 'does not call open() when interactive=false', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ); + await runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + expect( mockOpenBrowser ).not.toHaveBeenCalled(); + } ); + + it( 'calls open() with verificationUrl when interactive=true', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ); + await runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: true, + } ); + expect( mockOpenBrowser ).toHaveBeenCalledWith( 'https://example.com/verify' ); + } ); +} ); + +describe( 'isInteractiveContext', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + const originalIsTTY = process.stdout.isTTY; + + afterEach( () => { + process.env.VIP_NON_INTERACTIVE = originalEnv; + Object.defineProperty( process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + } ); + } ); + + it( 'returns false when VIP_NON_INTERACTIVE=1', () => { + process.env.VIP_NON_INTERACTIVE = '1'; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( false ); + } ); + + it( 'returns true for non-"1" values of VIP_NON_INTERACTIVE', () => { + process.env.VIP_NON_INTERACTIVE = '0'; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( true ); + } ); + + it( 'returns false when --non-interactive is in argv', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [ '--non-interactive' ] ) ).toBe( false ); + } ); + + it( 'returns false when stdout is not a TTY', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: false, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( false ); + } ); + + it( 'returns true when TTY, no flag, no env var', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( true ); + } ); +} ); diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts new file mode 100644 index 000000000..a34848903 --- /dev/null +++ b/src/lib/rechallenge/flow.ts @@ -0,0 +1,159 @@ +import chalk from 'chalk'; +import debugLib from 'debug'; + +import { trackEvent } from '../tracker'; +import * as client from './client'; +import { + RechallengeAbortedError, + RechallengeTerminalError, + RechallengeUnsupportedVersionError, +} from './errors'; +import { openBrowser } from './open-browser'; +import tokenCache from './token-cache'; +import { RECHALLENGE_VERSION } from './types'; + +import type { ElevatedToken, RechallengeExtension, RechallengeStatus } from './types'; + +const debug = debugLib( '@automattic/vip:rechallenge:flow' ); + +const TERMINAL: ReadonlySet< RechallengeStatus > = new Set( [ + 'verified', + 'expired', + 'failed', + 'cancelled', +] ); + +export interface RunRechallengeOptions { + requestedOperation: string; + rechallenge: RechallengeExtension; + interactive: boolean; + signal?: AbortSignal; +} + +function sleep( ms: number, signal?: AbortSignal ): Promise< void > { + return new Promise( ( resolve, reject ) => { + if ( signal?.aborted ) { + reject( new Error( 'aborted' ) ); + return; + } + const timer = setTimeout( () => { + signal?.removeEventListener( 'abort', onAbort ); + resolve(); + }, ms ); + function onAbort() { + clearTimeout( timer ); + reject( new Error( 'aborted' ) ); + } + signal?.addEventListener( 'abort', onAbort, { once: true } ); + } ); +} + +export async function runRechallenge( opts: RunRechallengeOptions ): Promise< ElevatedToken > { + const { requestedOperation, rechallenge, interactive, signal } = opts; + + if ( rechallenge.version !== RECHALLENGE_VERSION ) { + throw new RechallengeUnsupportedVersionError( rechallenge.version, requestedOperation ); + } + + await trackEvent( 'rechallenge_required', { scope: requestedOperation } ); + + const session = await client.createSession( { + path: rechallenge.createSessionPath, + requestedOperation, + } ); + await trackEvent( 'rechallenge_session_created', { scope: requestedOperation } ); + + const verificationUrl = session.verificationUrl; + const expiresIso = session.expiresAt; + if ( interactive ) { + await openBrowser( verificationUrl ); + console.warn( + chalk.yellow( '⚠' ), + `Step-up verification required for ${ chalk.bold( requestedOperation ) }.` + ); + console.warn( ` Opened ${ chalk.cyan( verificationUrl ) }` ); + console.warn( + ` If your browser did not open, copy and paste the URL above. Expires at ${ expiresIso }.` + ); + } else { + console.warn( + `Step-up verification required for ${ requestedOperation }. ` + + `Complete it at: ${ verificationUrl } (expires at ${ expiresIso }).` + ); + } + + const interval = Math.max( session.pollIntervalSeconds, 0 ) * 1000; + const deadline = Date.parse( session.expiresAt ); + if ( Number.isNaN( deadline ) ) { + throw new RechallengeTerminalError( + 'expired', + requestedOperation, + 'server returned unparseable expiresAt' + ); + } + + while ( true ) { + if ( signal?.aborted ) { + throw new RechallengeAbortedError( requestedOperation ); + } + + try { + await sleep( interval, signal ); + } catch { + throw new RechallengeAbortedError( requestedOperation ); + } + + if ( ! Number.isNaN( deadline ) && Date.now() > deadline ) { + throw new RechallengeTerminalError( + 'expired', + requestedOperation, + 'session window elapsed before completion' + ); + } + + const status = await client.getSessionStatus( { + template: rechallenge.statusPathTemplate, + challengeId: session.challengeId, + scope: requestedOperation, + } ); + + if ( ! TERMINAL.has( status.status ) ) { + debug( 'still %s; polling again', status.status ); + continue; + } + + if ( status.status === 'verified' ) { + const { elevatedToken } = await client.exchange( { + template: rechallenge.exchangePathTemplate, + challengeId: session.challengeId, + scope: requestedOperation, + } ); + await trackEvent( 'rechallenge_exchanged', { scope: requestedOperation } ); + await tokenCache.set( requestedOperation, elevatedToken ); + await trackEvent( 'rechallenge_verified', { + scope: requestedOperation, + provider: status.provider ?? 'unknown', + } ); + return elevatedToken; + } + + await trackEvent( `rechallenge_${ status.status }`, { + scope: requestedOperation, + } ); + throw new RechallengeTerminalError( + status.status, + requestedOperation, + status.statusReason?.message + ); + } +} + +export function isInteractiveContext( argvOrFlags: string[] = process.argv ): boolean { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) { + return false; + } + if ( argvOrFlags.includes( '--non-interactive' ) ) { + return false; + } + return Boolean( process.stdout.isTTY ); +} diff --git a/src/lib/rechallenge/open-browser.ts b/src/lib/rechallenge/open-browser.ts new file mode 100644 index 000000000..00382221d --- /dev/null +++ b/src/lib/rechallenge/open-browser.ts @@ -0,0 +1,11 @@ +import debugLib from 'debug'; + +const debug = debugLib( '@automattic/vip:rechallenge:open-browser' ); + +/** Opens a URL in the default browser. Wraps the ESM-only `open` package. */ +export async function openBrowser( url: string ): Promise< void > { + const { default: open } = await import( 'open' ); + await open( url, { wait: false } ).catch( ( err: unknown ) => { + debug( 'open() failed: %o', err ); + } ); +} From 3e6cc2f61a8b780dd54320eb55385059ecba3998 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 14:53:20 -0500 Subject: [PATCH 06/22] feat(rechallenge): add Apollo link that intercepts elevated-permission errors --- __tests__/lib/rechallenge/link.test.ts | 175 +++++++++++++++++++++++++ src/lib/rechallenge/flow.ts | 5 +- src/lib/rechallenge/index.ts | 1 + src/lib/rechallenge/link.ts | 152 +++++++++++++++++++++ src/lib/rechallenge/types.ts | 1 + 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 __tests__/lib/rechallenge/link.test.ts create mode 100644 src/lib/rechallenge/link.ts diff --git a/__tests__/lib/rechallenge/link.test.ts b/__tests__/lib/rechallenge/link.test.ts new file mode 100644 index 000000000..c3314d7e0 --- /dev/null +++ b/__tests__/lib/rechallenge/link.test.ts @@ -0,0 +1,175 @@ +import { ApolloLink, Observable, type ApolloClient } from '@apollo/client/core'; +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import gql from 'graphql-tag'; + +import * as flowModule from '../../../src/lib/rechallenge/flow'; +import createRechallengeLink from '../../../src/lib/rechallenge/link'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { RunRechallengeOptions } from '../../../src/lib/rechallenge/flow'; +import type { ElevatedToken } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/rechallenge/flow', () => ( { + runRechallenge: jest.fn(), + isInteractiveContext: () => false, +} ) ); +jest.mock( '../../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn( () => Promise.resolve() ), + clearScope: jest.fn(), + clearAll: jest.fn(), + }, +} ) ); + +const runRechallenge = flowModule.runRechallenge as jest.MockedFunction< + ( opts: RunRechallengeOptions ) => Promise< ElevatedToken > +>; +const tokenGet = tokenCache.get as jest.MockedFunction< + ( scope: string ) => Promise< ElevatedToken | null > +>; + +const MUTATION = gql` + mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput) { + updateDefensiveModeStatus(input: $input) { + success + message + } + } +`; + +const QUERY = gql` + query Foo { + foo + } +`; + +const ELEVATED_TOKEN: ElevatedToken = { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', +}; + +function elevatedRequiredResult(): ApolloLink.Result { + return { + data: null, + errors: [ + { + message: 'Elevated permission required', + extensions: { + code: 'elevated-permission-required', + rechallenge: { + version: 'v2', + createSessionPath: '/rechallenge/v2/sessions', + statusPathTemplate: '/rechallenge/v2/sessions/{challengeId}', + exchangePathTemplate: '/rechallenge/v2/sessions/{challengeId}/exchange', + elevatedHeaderName: 'x-elevated-token', + }, + }, + }, + ], + } as unknown as ApolloLink.Result; +} + +function successResult(): ApolloLink.Result { + return { data: { updateDefensiveModeStatus: { success: true, message: 'ok' } } }; +} + +function makeDownstream( responses: ApolloLink.Result[] ) { + const calls: { headers: Record< string, string > }[] = []; + const link = new ApolloLink( operation => { + const ctx = operation.getContext() as { headers?: Record< string, string > }; + calls.push( { headers: { ...( ctx.headers ?? {} ) } } ); + const result = responses.shift(); + return new Observable< ApolloLink.Result >( observer => { + if ( result ) { + observer.next( result ); + observer.complete(); + } else { + observer.error( new Error( 'no more queued responses' ) ); + } + } ); + } ); + return { link, calls }; +} + +// Apollo v4 requires a `client` in the execute context; use a null stand-in for unit tests. +const EXEC_CTX = { client: null as unknown as ApolloClient }; + +function executeLink( + link: ApolloLink, + request: Parameters< typeof ApolloLink.execute >[ 1 ] +): Promise< ApolloLink.Result > { + return new Promise< ApolloLink.Result >( ( resolve, reject ) => { + ApolloLink.execute( link, request, EXEC_CTX ).subscribe( { + next: resolve, + error: reject, + } ); + } ); +} + +beforeEach( () => { + jest.clearAllMocks(); + tokenGet.mockResolvedValue( null ); +} ); + +describe( 'rechallengeLink', () => { + it( 'passes queries through untouched', async () => { + const { link: downstream, calls } = makeDownstream( [ successResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: QUERY } ); + expect( result ).toEqual( successResult() ); + expect( calls ).toHaveLength( 1 ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBeUndefined(); + expect( runRechallenge ).not.toHaveBeenCalled(); + } ); + + it( 'attaches cached elevated token pre-flight when available', async () => { + tokenGet.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream, calls } = makeDownstream( [ successResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + await executeLink( link, { query: MUTATION } ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBe( ELEVATED_TOKEN.token ); + expect( runRechallenge ).not.toHaveBeenCalled(); + } ); + + it( 'on elevated-permission-required runs flow and retries with header', async () => { + runRechallenge.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream, calls } = makeDownstream( [ + elevatedRequiredResult(), + successResult(), + ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result ).toEqual( successResult() ); + expect( calls ).toHaveLength( 2 ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBeUndefined(); + expect( calls[ 1 ].headers[ 'x-elevated-token' ] ).toBe( ELEVATED_TOKEN.token ); + expect( runRechallenge ).toHaveBeenCalledWith( + expect.objectContaining( { + requestedOperation: 'updateDefensiveModeStatus', + } ) + ); + } ); + + it( 'propagates the original error when the flow fails', async () => { + runRechallenge.mockRejectedValueOnce( new Error( 'flow boom' ) ); + const { link: downstream } = makeDownstream( [ elevatedRequiredResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result.errors?.[ 0 ].extensions?.code ).toBe( 'elevated-permission-required' ); + } ); + + it( 'passes the second elevated-permission-required upstream without retrying again', async () => { + runRechallenge.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream } = makeDownstream( [ + elevatedRequiredResult(), + elevatedRequiredResult(), + ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result.errors?.[ 0 ].extensions?.code ).toBe( 'elevated-permission-required' ); + expect( runRechallenge ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts index a34848903..564568590 100644 --- a/src/lib/rechallenge/flow.ts +++ b/src/lib/rechallenge/flow.ts @@ -129,7 +129,10 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El scope: requestedOperation, } ); await trackEvent( 'rechallenge_exchanged', { scope: requestedOperation } ); - await tokenCache.set( requestedOperation, elevatedToken ); + await tokenCache.set( requestedOperation, { + ...elevatedToken, + headerName: rechallenge.elevatedHeaderName, + } ); await trackEvent( 'rechallenge_verified', { scope: requestedOperation, provider: status.provider ?? 'unknown', diff --git a/src/lib/rechallenge/index.ts b/src/lib/rechallenge/index.ts index 14fb821c7..d0bb94656 100644 --- a/src/lib/rechallenge/index.ts +++ b/src/lib/rechallenge/index.ts @@ -1,3 +1,4 @@ +export { default as rechallengeLink } from './link'; export { default as tokenCache } from './token-cache'; export * from './types'; export * from './errors'; diff --git a/src/lib/rechallenge/link.ts b/src/lib/rechallenge/link.ts new file mode 100644 index 000000000..6a7800d03 --- /dev/null +++ b/src/lib/rechallenge/link.ts @@ -0,0 +1,152 @@ +import { ApolloLink, Observable } from '@apollo/client/core'; +import debugLib from 'debug'; +import { Kind, OperationTypeNode } from 'graphql'; + +import { isInteractiveContext, runRechallenge } from './flow'; +import tokenCache from './token-cache'; +import { ELEVATED_PERMISSION_ERROR_CODE } from './types'; + +import type { ElevatedToken, RechallengeExtension } from './types'; +import type { DocumentNode, FieldNode, OperationDefinitionNode } from 'graphql'; + +const debug = debugLib( '@automattic/vip:rechallenge:link' ); + +function operationDefinition( doc: DocumentNode ): OperationDefinitionNode | undefined { + return doc.definitions.find( + ( def ): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION + ); +} + +function isMutation( doc: DocumentNode ): boolean { + return operationDefinition( doc )?.operation === OperationTypeNode.MUTATION; +} + +function primaryMutationFieldName( doc: DocumentNode ): string | null { + const op = operationDefinition( doc ); + if ( ! op || op.operation !== OperationTypeNode.MUTATION ) { + return null; + } + const first = op.selectionSet.selections.find( + ( sel ): sel is FieldNode => sel.kind === Kind.FIELD + ); + return first?.name.value ?? null; +} + +interface ElevatedPermissionPayload { + rechallenge: RechallengeExtension; +} + +function extractElevatedPermission( result: ApolloLink.Result ): ElevatedPermissionPayload | null { + const errors = result.errors ?? []; + for ( const err of errors ) { + const ext = ( err.extensions ?? {} ) as Record< string, unknown >; + if ( ext.code !== ELEVATED_PERMISSION_ERROR_CODE ) { + continue; + } + const rechallenge = ext.rechallenge as RechallengeExtension | undefined; + if ( rechallenge && typeof rechallenge.createSessionPath === 'string' ) { + return { rechallenge }; + } + } + return null; +} + +function attachElevatedHeader( + operation: ApolloLink.Operation, + headerName: string, + token: ElevatedToken +): void { + const ctx = operation.getContext() as { + headers?: Record< string, string >; + }; + const headers = { ...( ctx.headers ?? {} ) }; + headers[ headerName ] = token.token; + operation.setContext( { ...ctx, headers } ); +} + +const DEFAULT_HEADER = 'x-elevated-token'; + +export default function createRechallengeLink(): ApolloLink { + return new ApolloLink( ( operation, forward ) => { + const scope = primaryMutationFieldName( operation.query ); + const eligible = isMutation( operation.query ) && Boolean( scope ); + + return new Observable< ApolloLink.Result >( observer => { + let retrying = false; + let cancelled = false; + let innerSub: { unsubscribe(): void } | null = null; + + const preflight = async () => { + if ( ! eligible || ! scope ) { + return; + } + const cached = await tokenCache.get( scope ); + if ( cached ) { + attachElevatedHeader( operation, cached.headerName || DEFAULT_HEADER, cached ); + } + }; + + void preflight() + .catch( err => debug( 'preflight error: %o', err ) ) + .then( () => { + if ( cancelled || observer.closed ) { + return; + } + innerSub = forward( operation ).subscribe( { + next: result => { + if ( retrying || ! eligible || ! scope ) { + observer.next( result ); + return; + } + const elevated = extractElevatedPermission( result ); + if ( ! elevated ) { + observer.next( result ); + return; + } + + retrying = true; + const headerName = elevated.rechallenge.elevatedHeaderName || DEFAULT_HEADER; + + void runRechallenge( { + requestedOperation: scope, + rechallenge: elevated.rechallenge, + interactive: isInteractiveContext(), + } ) + .then( token => { + if ( cancelled || observer.closed ) { + return; + } + attachElevatedHeader( operation, headerName, token ); + innerSub = forward( operation ).subscribe( { + next: res => observer.next( res ), + error: err => observer.error( err ), + complete: () => observer.complete(), + } ); + } ) + .catch( err => { + debug( 'rechallenge flow failed: %o', err ); + if ( cancelled || observer.closed ) { + return; + } + // Surface the original elevated-permission error to upstream + // so errorLink and consumers see it. + observer.next( result ); + observer.complete(); + } ); + }, + error: err => observer.error( err ), + complete: () => { + if ( ! retrying ) { + observer.complete(); + } + }, + } ); + } ); + + return () => { + cancelled = true; + innerSub?.unsubscribe(); + }; + } ); + } ); +} diff --git a/src/lib/rechallenge/types.ts b/src/lib/rechallenge/types.ts index 950a14935..bdf3b6f12 100644 --- a/src/lib/rechallenge/types.ts +++ b/src/lib/rechallenge/types.ts @@ -42,4 +42,5 @@ export interface ElevatedToken { token: string; expiresAt: string; // ISO-8601 purpose: string; + headerName?: string; } From f03c4233e3dc4ca99cfdacc0954a032bc614251a Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:18:59 -0500 Subject: [PATCH 07/22] feat(api): insert rechallenge link into Apollo chain Also extracts API_HOST/API_URL/PRODUCTION_API_HOST into a leaf constants module and lazy-requires the rechallenge link inside API() to break a circular-dependency cycle that caused Jest mocks to misfire in the rechallenge test suite. --- src/lib/api.ts | 26 ++++++++++++++++++++------ src/lib/api/constants.ts | 3 +++ src/lib/api/feature-flags.ts | 17 +++++++++++++++-- src/lib/api/http.ts | 2 +- src/lib/rechallenge/token-cache.ts | 2 +- src/lib/token.ts | 2 +- 6 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 src/lib/api/constants.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index 3ce4d1636..5dff1f07a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,12 +14,12 @@ import { Kind, OperationTypeNode } from 'graphql'; import { FetchError } from 'node-fetch'; import http from './api/http'; +import { API_URL } from './api/constants'; -// Config -export const PRODUCTION_API_HOST = 'https://api.wpvip.com'; - -export const API_HOST = process.env.API_HOST || PRODUCTION_API_HOST; // NOSONAR -export const API_URL = `${ API_HOST }/graphql`; +// Config — re-exported from ./api/constants so modules in the rechallenge tree +// can import them without pulling in the full api.ts graph (which would create +// a circular dependency via the rechallenge link). +export { API_HOST, API_URL, PRODUCTION_API_HOST } from './api/constants'; let globalGraphQLErrorHandlingEnabled = true; @@ -145,8 +145,22 @@ export default function API( { attempts: shouldRetryRequest, } ); + // Lazy-require the rechallenge link to avoid a circular-dependency issue in + // Jest tests. Importing at module top-level would cause rechallenge/client.ts + // to be loaded during jest.setupMocks.js (via apiConfig → feature-flags → + // api.ts → link.ts → client.ts), preventing jest.mock('../api/http') from + // intercepting the http reference captured inside client.ts. A require() + // call inside the function body is resolved after all mocks are registered. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const createRechallengeLink = ( require( './rechallenge/link' ) as typeof import( './rechallenge/link' ) ).default; + return new ApolloClient( { - link: ApolloLink.from( [ errorLink, retryLink, httpLink ] ), + link: ApolloLink.from( [ + errorLink, + createRechallengeLink(), + retryLink, + httpLink, + ] ), cache: new InMemoryCache( { typePolicies: { WPSite: { diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts new file mode 100644 index 000000000..3043a89d4 --- /dev/null +++ b/src/lib/api/constants.ts @@ -0,0 +1,3 @@ +export const PRODUCTION_API_HOST = 'https://api.wpvip.com'; +export const API_HOST = process.env.API_HOST || PRODUCTION_API_HOST; // NOSONAR +export const API_URL = `${ API_HOST }/graphql`; diff --git a/src/lib/api/feature-flags.ts b/src/lib/api/feature-flags.ts index 6351accbd..bc1b9a6b7 100644 --- a/src/lib/api/feature-flags.ts +++ b/src/lib/api/feature-flags.ts @@ -5,7 +5,20 @@ import API from '../../lib/api'; import type { IsVipQuery, IsVipQueryVariables } from './feature-flags.generated'; -const api: ApolloClient = API( { silenceAuthErrors: true } ); +// Lazy-initialize the API client so that this module can be imported during the +// rechallenge module chain without triggering a circular-dependency crash. The +// cycle that existed before this change: +// api.ts → rechallenge/link.ts → flow.ts → tracker.ts → tracks.ts +// → cli/apiConfig.ts → api/feature-flags.ts → api.ts +// By deferring construction to the first call we ensure api.ts is fully +// evaluated before API() is invoked. +let api: ApolloClient | null = null; +function getApi(): ApolloClient { + if ( ! api ) { + api = API( { silenceAuthErrors: true } ); + } + return api; +} const isVipQuery = gql` query isVIP { @@ -16,7 +29,7 @@ const isVipQuery = gql` `; export function get(): Promise< ApolloClient.QueryResult< IsVipQuery > > { - return api.query< IsVipQuery, IsVipQueryVariables >( { + return getApi().query< IsVipQuery, IsVipQueryVariables >( { query: isVipQuery, fetchPolicy: 'cache-first', } ); diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index e7dda33eb..582a9c80f 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -6,7 +6,7 @@ import fetch, { type HeadersInit, } from 'node-fetch'; -import { API_HOST } from '../../lib/api'; +import { API_HOST } from './constants'; import env from '../../lib/env'; import { createProxyAgent } from '../../lib/http/proxy-agent'; import Token from '../../lib/token'; diff --git a/src/lib/rechallenge/token-cache.ts b/src/lib/rechallenge/token-cache.ts index 89c3897e3..24061d0d0 100644 --- a/src/lib/rechallenge/token-cache.ts +++ b/src/lib/rechallenge/token-cache.ts @@ -1,6 +1,6 @@ import debugLib from 'debug'; -import { API_HOST, PRODUCTION_API_HOST } from '../api'; +import { API_HOST, PRODUCTION_API_HOST } from '../api/constants'; import keychain from '../keychain'; import type { ElevatedToken } from './types'; diff --git a/src/lib/token.ts b/src/lib/token.ts index fa7c47199..8ee2dd88d 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,7 +1,7 @@ import { jwtDecode } from 'jwt-decode'; import { randomUUID } from 'node:crypto'; -import { API_HOST, PRODUCTION_API_HOST } from './api'; +import { API_HOST, PRODUCTION_API_HOST } from './api/constants'; import keychain from './keychain'; interface Payload { From a461f5215fa5c5fc22117fa24ee80483cfb9c8ae Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:25:50 -0500 Subject: [PATCH 08/22] feat(defensive-mode): add GraphQL helpers for status and config mutations --- __tests__/lib/defensive-mode/api.test.ts | 91 +++++++++++++++++ src/lib/defensive-mode/api.ts | 119 +++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 __tests__/lib/defensive-mode/api.test.ts create mode 100644 src/lib/defensive-mode/api.ts diff --git a/__tests__/lib/defensive-mode/api.test.ts b/__tests__/lib/defensive-mode/api.test.ts new file mode 100644 index 000000000..3c051b4f8 --- /dev/null +++ b/__tests__/lib/defensive-mode/api.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import * as apiModule from '../../../src/lib/api'; +import { + updateDefensiveModeStatus, + updateDefensiveModeConfig, +} from '../../../src/lib/defensive-mode/api'; + +jest.mock( '../../../src/lib/api' ); + +const mockedAPI = apiModule as unknown as { default: jest.Mock }; + +beforeEach( () => { + mockedAPI.default = jest.fn().mockReturnValue( { + mutate: jest.fn( () => + Promise.resolve( { + data: { + updateDefensiveModeStatus: { success: true, message: 'ok' }, + updateDefensiveModeConfig: { success: true, message: 'ok' }, + }, + } ) + ), + } ); +} ); + +describe( 'updateDefensiveModeStatus', () => { + it( 'sends appId, envId, enabled', async () => { + await updateDefensiveModeStatus( { appId: 1, envId: 2, enabled: true } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: Record< string, unknown >; + } + ).variables; + expect( variables ).toEqual( { + input: { id: 1, environmentId: 2, enabled: true }, + } ); + } ); +} ); + +describe( 'updateDefensiveModeConfig', () => { + it( 'sends the full config input', async () => { + await updateDefensiveModeConfig( { + appId: 1, + envId: 2, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: Record< string, unknown >; + } + ).variables; + expect( variables ).toEqual( { + input: { + id: 1, + environmentId: 2, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + }, + } ); + } ); + + it( 'omits optional thresholds when not provided', async () => { + await updateDefensiveModeConfig( { + appId: 1, + envId: 2, + enabled: false, + challengeType: 1, + } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: { input: Record< string, unknown > }; + } + ).variables; + expect( variables.input ).not.toHaveProperty( 'connectionThresholdAbsolute' ); + expect( variables.input ).not.toHaveProperty( 'connectionThresholdPercentage' ); + } ); +} ); diff --git a/src/lib/defensive-mode/api.ts b/src/lib/defensive-mode/api.ts new file mode 100644 index 000000000..e8ca07a46 --- /dev/null +++ b/src/lib/defensive-mode/api.ts @@ -0,0 +1,119 @@ +import gql from 'graphql-tag'; + +import API from '../api'; + +export const appQuery = ` + id + name + typeId + environments { + id + appId + name + primaryDomain { + name + } + type + defensiveMode { + config { + effective { + enabled + challengeType + connectionThresholdAbsolute + connectionThresholdPercentage + disableAtEpoch + keepEnabledUnderThresholdForSeconds + maxRequestRate + priorityBypass + } + stored { + enabled + challengeType + connectionThresholdAbsolute + connectionThresholdPercentage + } + } + } + } + organization { + id + name + } +`; + +const STATUS_MUTATION = gql` + mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput) { + updateDefensiveModeStatus(input: $input) { + success + message + } + } +`; + +const CONFIG_MUTATION = gql` + mutation UpdateDefensiveModeConfig($input: AppEnvironmentDefensiveModeConfigInput) { + updateDefensiveModeConfig(input: $input) { + success + message + } + } +`; + +export interface UpdateStatusInput { + appId: number; + envId: number; + enabled: boolean; +} + +export interface UpdateConfigInput { + appId: number; + envId: number; + enabled: boolean; + challengeType: number; + connectionThresholdAbsolute?: number; + connectionThresholdPercentage?: number; +} + +export async function updateDefensiveModeStatus( + input: UpdateStatusInput +): Promise< { success: boolean; message: string } > { + const api = API(); + const result = await api.mutate( { + mutation: STATUS_MUTATION, + variables: { + input: { id: input.appId, environmentId: input.envId, enabled: input.enabled }, + }, + } ); + return ( + result.data as { + updateDefensiveModeStatus: { success: boolean; message: string }; + } + ).updateDefensiveModeStatus; +} + +export async function updateDefensiveModeConfig( + input: UpdateConfigInput +): Promise< { success: boolean; message: string } > { + const api = API(); + const mutationInput: Record< string, unknown > = { + id: input.appId, + environmentId: input.envId, + enabled: input.enabled, + challengeType: input.challengeType, + }; + if ( input.connectionThresholdAbsolute !== undefined ) { + mutationInput.connectionThresholdAbsolute = input.connectionThresholdAbsolute; + } + if ( input.connectionThresholdPercentage !== undefined ) { + mutationInput.connectionThresholdPercentage = input.connectionThresholdPercentage; + } + const result = await api.mutate( { + mutation: CONFIG_MUTATION, + variables: { input: mutationInput }, + } ); + return ( + result.data as { + updateDefensiveModeConfig: { success: boolean; message: string }; + } + ).updateDefensiveModeConfig; +} From b8815af17bab7b83a1af9b144872fd8bccd113a7 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:27:51 -0500 Subject: [PATCH 09/22] feat(cli): add vip defensive-mode parent command --- src/bin/vip-defensive-mode.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/bin/vip-defensive-mode.js diff --git a/src/bin/vip-defensive-mode.js b/src/bin/vip-defensive-mode.js new file mode 100644 index 000000000..32c5334b6 --- /dev/null +++ b/src/bin/vip-defensive-mode.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; + +const usage = 'vip defensive-mode'; +const exampleUsage = 'vip @example-app.production defensive-mode'; + +const examples = [ + { + usage: `${ exampleUsage } enable`, + description: 'Enable defensive mode for the environment.', + }, + { + usage: `${ exampleUsage } disable`, + description: 'Disable defensive mode for the environment.', + }, + { + usage: `${ exampleUsage } configure --enabled=true --challenge-type=1`, + description: 'Update the defensive mode configuration non-interactively.', + }, +]; + +command( { + requiredArgs: 1, + usage, +} ) + .command( 'enable', 'Enable defensive mode (step-up auth required).' ) + .command( 'disable', 'Disable defensive mode (step-up auth required).' ) + .command( 'configure', 'Update the defensive mode configuration (step-up auth required).' ) + .examples( examples ) + .argv( process.argv ); From 20e4e0fce729f3e66a864d60e8d7f8d669c6a75d Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:29:48 -0500 Subject: [PATCH 10/22] feat(cli): add vip defensive-mode enable subcommand --- __tests__/bin/vip-defensive-mode-enable.js | 69 +++++++++++++++++++ src/bin/vip-defensive-mode-enable.js | 78 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 __tests__/bin/vip-defensive-mode-enable.js create mode 100644 src/bin/vip-defensive-mode-enable.js diff --git a/__tests__/bin/vip-defensive-mode-enable.js b/__tests__/bin/vip-defensive-mode-enable.js new file mode 100644 index 000000000..10b31aaaf --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-enable.js @@ -0,0 +1,69 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeEnableCommand } from '../../src/bin/vip-defensive-mode-enable'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeStatus } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeStatus: jest.fn( () => + Promise.resolve( { success: true, message: 'enabled' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +const mockUpdate = updateDefensiveModeStatus; +const mockTrack = trackEvent; + +describe( 'vip defensive-mode enable', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeEnableCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'calls updateDefensiveModeStatus with enabled=true', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; + await defensiveModeEnableCommand( [], opts ); + expect( mockUpdate ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: true, + } ); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_execute', + expect.any( Object ) + ); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_success', + expect.any( Object ) + ); + } ); +} ); diff --git a/src/bin/vip-defensive-mode-enable.js b/src/bin/vip-defensive-mode-enable.js new file mode 100644 index 000000000..d0250a2d4 --- /dev/null +++ b/src/bin/vip-defensive-mode-enable.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import command from '../lib/cli/command'; +import { formatEnvironment } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode enable'; +const exampleUsage = 'vip @example-app.production defensive-mode enable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Enable defensive mode for the environment (interactive).', + }, + { + usage: `${ exampleUsage } --skip-confirmation`, + description: 'Enable defensive mode without the production confirmation prompt.', + }, +]; + +export async function defensiveModeEnableCommand( _args, opt ) { + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_enable_command_execute', trackingParams ); + + if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { + const yes = await confirm( + `Enable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` + ); + if ( ! yes ) { + await trackEvent( 'defensive_mode_enable_command_cancelled', trackingParams ); + console.log( 'Command cancelled' ); + process.exit(); + } + } + + const result = await updateDefensiveModeStatus( { + appId: opt.app.id, + envId: opt.env.id, + enabled: true, + } ); + + if ( ! result.success ) { + await trackEvent( 'defensive_mode_enable_command_error', { + ...trackingParams, + error: result.message, + } ); + console.log( chalk.red( `Failed to enable defensive mode: ${ result.message }` ) ); + process.exit( 1 ); + } + + await trackEvent( 'defensive_mode_enable_command_success', trackingParams ); + console.log( + chalk.green( '✓' ), + `Defensive mode enabled for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .examples( examples ) + .argv( process.argv, defensiveModeEnableCommand ); From 2dc5804a9267fc065aeea3f7264f091a5c961c5c Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:30:26 -0500 Subject: [PATCH 11/22] feat(cli): add vip defensive-mode disable subcommand --- __tests__/bin/vip-defensive-mode-disable.js | 62 +++++++++++++++++ src/bin/vip-defensive-mode-disable.js | 74 +++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 __tests__/bin/vip-defensive-mode-disable.js create mode 100644 src/bin/vip-defensive-mode-disable.js diff --git a/__tests__/bin/vip-defensive-mode-disable.js b/__tests__/bin/vip-defensive-mode-disable.js new file mode 100644 index 000000000..a7b28f9d7 --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-disable.js @@ -0,0 +1,62 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeDisableCommand } from '../../src/bin/vip-defensive-mode-disable'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeStatus } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeStatus: jest.fn( () => + Promise.resolve( { success: true, message: 'disabled' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +describe( 'vip defensive-mode disable', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeDisableCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'calls updateDefensiveModeStatus with enabled=false', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; + await defensiveModeDisableCommand( [], opts ); + expect( updateDefensiveModeStatus ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: false, + } ); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_disable_command_success', + expect.any( Object ) + ); + } ); +} ); diff --git a/src/bin/vip-defensive-mode-disable.js b/src/bin/vip-defensive-mode-disable.js new file mode 100644 index 000000000..ede7c3d4c --- /dev/null +++ b/src/bin/vip-defensive-mode-disable.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import command from '../lib/cli/command'; +import { formatEnvironment } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode disable'; +const exampleUsage = 'vip @example-app.production defensive-mode disable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Disable defensive mode for the environment.', + }, +]; + +export async function defensiveModeDisableCommand( _args, opt ) { + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_disable_command_execute', trackingParams ); + + if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { + const yes = await confirm( + `Disable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` + ); + if ( ! yes ) { + await trackEvent( 'defensive_mode_disable_command_cancelled', trackingParams ); + console.log( 'Command cancelled' ); + process.exit(); + } + } + + const result = await updateDefensiveModeStatus( { + appId: opt.app.id, + envId: opt.env.id, + enabled: false, + } ); + + if ( ! result.success ) { + await trackEvent( 'defensive_mode_disable_command_error', { + ...trackingParams, + error: result.message, + } ); + console.log( chalk.red( `Failed to disable defensive mode: ${ result.message }` ) ); + process.exit( 1 ); + } + + await trackEvent( 'defensive_mode_disable_command_success', trackingParams ); + console.log( + chalk.green( '✓' ), + `Defensive mode disabled for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .examples( examples ) + .argv( process.argv, defensiveModeDisableCommand ); From 1109b2cd851ccb22ca11e573d8baecd040ca3df8 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:35:24 -0500 Subject: [PATCH 12/22] feat(cli): add vip defensive-mode configure subcommand Implements `vip defensive-mode configure` with full flag validation, interactive prompting for missing required flags, and non-interactive hard-error mode. --- __tests__/bin/vip-defensive-mode-configure.js | 119 ++++++++ src/bin/vip-defensive-mode-configure.js | 264 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 __tests__/bin/vip-defensive-mode-configure.js create mode 100644 src/bin/vip-defensive-mode-configure.js diff --git a/__tests__/bin/vip-defensive-mode-configure.js b/__tests__/bin/vip-defensive-mode-configure.js new file mode 100644 index 000000000..a01148391 --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-configure.js @@ -0,0 +1,119 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeConfigureCommand } from '../../src/bin/vip-defensive-mode-configure'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeConfig } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeConfig: jest.fn( () => + Promise.resolve( { success: true, message: 'configured' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn( () => Promise.resolve( true ) ), +} ) ); + +function baseOpts() { + return { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; +} + +describe( 'vip defensive-mode configure', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeConfigureCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'applies full input when all flags are supplied', async () => { + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '1', + connectionThresholdAbsolute: '1000', + connectionThresholdPercentage: '50', + } ); + expect( updateDefensiveModeConfig ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + } ); + } ); + + it( 'errors when required flags missing in non-interactive mode', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeConfig ).not.toHaveBeenCalled(); + } ); + + it( 'rejects non-boolean enabled values', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'maybe', + challengeType: '1', + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + } ); + + it( 'rejects non-integer challenge-type', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: 'oops', + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + } ); + + it( 'tracks success', async () => { + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'false', + challengeType: '1', + } ); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_configure_command_success', + expect.any( Object ) + ); + } ); +} ); diff --git a/src/bin/vip-defensive-mode-configure.js b/src/bin/vip-defensive-mode-configure.js new file mode 100644 index 000000000..6c70f2a09 --- /dev/null +++ b/src/bin/vip-defensive-mode-configure.js @@ -0,0 +1,264 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; +import { prompt } from 'enquirer'; + +import command from '../lib/cli/command'; +import { formatEnvironment } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeConfig } from '../lib/defensive-mode/api'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode configure'; +const exampleUsage = 'vip @example-app.production defensive-mode configure'; + +const examples = [ + { + usage: `${ exampleUsage } --enabled=true --challenge-type=1`, + description: 'Update defensive mode configuration non-interactively (minimal required flags).', + }, + { + usage: `${ exampleUsage } --enabled=true --challenge-type=2 --connection-threshold-absolute=5000 --connection-threshold-percentage=80`, + description: 'Update with explicit thresholds.', + }, +]; + +function isInteractive( opt ) { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) { + return false; + } + if ( opt.nonInteractive ) { + return false; + } + return Boolean( process.stdout.isTTY ); +} + +function parseBoolean( raw ) { + if ( raw === true || raw === false ) { + return raw; + } + if ( typeof raw !== 'string' ) { + return null; + } + const normalized = raw.trim().toLowerCase(); + if ( [ 'true', 'yes', '1', 'on', 'enable', 'enabled' ].includes( normalized ) ) { + return true; + } + if ( [ 'false', 'no', '0', 'off', 'disable', 'disabled' ].includes( normalized ) ) { + return false; + } + return null; +} + +function parsePositiveInt( raw ) { + if ( raw === undefined || raw === null || raw === '' ) { + return null; + } + const num = Number( raw ); + if ( ! Number.isInteger( num ) || num < 0 ) { + return null; + } + return num; +} + +function validateFlags( opt ) { + const errors = []; + + const enabled = opt.enabled === undefined ? null : parseBoolean( opt.enabled ); + if ( opt.enabled !== undefined && enabled === null ) { + errors.push( `Invalid value for --enabled: ${ opt.enabled }. Expected true or false.` ); + } + + const challengeType = + opt.challengeType === undefined ? null : parsePositiveInt( opt.challengeType ); + if ( opt.challengeType !== undefined && challengeType === null ) { + errors.push( + `Invalid value for --challenge-type: ${ opt.challengeType }. Expected a non-negative integer.` + ); + } + + const absolute = + opt.connectionThresholdAbsolute === undefined + ? undefined + : parsePositiveInt( opt.connectionThresholdAbsolute ); + if ( opt.connectionThresholdAbsolute !== undefined && absolute === null ) { + errors.push( + `Invalid value for --connection-threshold-absolute: ${ opt.connectionThresholdAbsolute }. Expected a non-negative integer.` + ); + } + + const percentage = + opt.connectionThresholdPercentage === undefined + ? undefined + : parsePositiveInt( opt.connectionThresholdPercentage ); + if ( opt.connectionThresholdPercentage !== undefined && percentage === null ) { + errors.push( + `Invalid value for --connection-threshold-percentage: ${ opt.connectionThresholdPercentage }. Expected a non-negative integer.` + ); + } + + return { enabled, challengeType, absolute, percentage, errors }; +} + +async function resolveRequiredViaPrompt( missing, enabled, challengeType ) { + const answers = await prompt( + missing.map( flag => + flag === '--enabled' + ? { + type: 'confirm', + name: 'enabled', + message: 'Enable defensive mode?', + } + : { + type: 'input', + name: 'challengeType', + message: 'Challenge type (integer):', + } + ) + ); + + let resolvedEnabled = enabled; + let resolvedChallengeType = challengeType; + + if ( resolvedEnabled === null && 'enabled' in answers ) { + resolvedEnabled = Boolean( answers.enabled ); + } + if ( resolvedChallengeType === null && 'challengeType' in answers ) { + resolvedChallengeType = parsePositiveInt( answers.challengeType ); + if ( resolvedChallengeType === null ) { + console.error( chalk.red( 'Challenge type must be a non-negative integer.' ) ); + process.exit( 1 ); + } + } + + return { enabled: resolvedEnabled, challengeType: resolvedChallengeType }; +} + +export async function defensiveModeConfigureCommand( _args, opt ) { + const interactive = isInteractive( opt ); + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + interactive, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_configure_command_execute', trackingParams ); + + const { + enabled: rawEnabled, + challengeType: rawChallengeType, + absolute, + percentage, + errors, + } = validateFlags( opt ); + + if ( errors.length > 0 ) { + errors.forEach( msg => console.error( chalk.red( msg ) ) ); + process.exit( 1 ); + } + + const missing = []; + if ( rawEnabled === null ) { + missing.push( '--enabled' ); + } + if ( rawChallengeType === null ) { + missing.push( '--challenge-type' ); + } + + let enabled = rawEnabled; + let challengeType = rawChallengeType; + + if ( missing.length > 0 ) { + if ( ! interactive ) { + console.error( + chalk.red( `Missing required flags in non-interactive mode: ${ missing.join( ', ' ) }` ) + ); + console.error( + 'Re-run with all required flags, or remove --non-interactive and run on a TTY.' + ); + await trackEvent( 'defensive_mode_configure_command_error', { + ...trackingParams, + error: 'missing-required-flags', + } ); + process.exit( 1 ); + } + + ( { enabled, challengeType } = await resolveRequiredViaPrompt( + missing, + enabled, + challengeType + ) ); + } + + const input = { + appId: opt.app.id, + envId: opt.env.id, + enabled, + challengeType, + }; + if ( absolute !== undefined ) { + input.connectionThresholdAbsolute = absolute; + } + if ( percentage !== undefined ) { + input.connectionThresholdPercentage = percentage; + } + + if ( interactive && ! opt.skipConfirmation && opt.env.type === 'production' ) { + const yes = await confirm( + `Apply this configuration to ${ formatEnvironment( opt.env.type ) } for ${ + opt.app.name + }?\n${ JSON.stringify( input, null, 2 ) }` + ); + if ( ! yes ) { + await trackEvent( 'defensive_mode_configure_command_cancelled', trackingParams ); + console.log( 'Command cancelled' ); + process.exit(); + } + } + + const result = await updateDefensiveModeConfig( input ); + + if ( ! result.success ) { + await trackEvent( 'defensive_mode_configure_command_error', { + ...trackingParams, + error: result.message, + } ); + console.log( chalk.red( `Failed to update defensive mode config: ${ result.message }` ) ); + process.exit( 1 ); + } + + await trackEvent( 'defensive_mode_configure_command_success', trackingParams ); + console.log( + chalk.green( '✓' ), + `Defensive mode configuration updated for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'enabled', 'Whether defensive mode should be enabled (true|false). Required.' ) + .option( 'challenge-type', 'Challenge type integer. Required.' ) + .option( + 'connection-threshold-absolute', + 'Absolute connection threshold that triggers defensive mode.' + ) + .option( + 'connection-threshold-percentage', + 'Connection threshold percentage that triggers defensive mode.' + ) + .option( + 'non-interactive', + 'Disable prompts and browser-open; fail fast if a required flag is missing.', + false + ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .examples( examples ) + .argv( process.argv, defensiveModeConfigureCommand ); From c700b4e15cd30b0370dde5fab04f865eeb7ca952 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:36:54 -0500 Subject: [PATCH 13/22] feat(cli): register vip defensive-mode top-level command --- package.json | 4 ++++ src/bin/vip.js | 1 + 2 files changed, 5 insertions(+) diff --git a/package.json b/package.json index 42158c9a2..ba535a2d0 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "vip-config-software-update": "dist/bin/vip-config-software-update.js", "vip-db": "dist/bin/vip-db.js", "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js", + "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", + "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js", + "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", + "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", "vip-dev-env": "dist/bin/vip-dev-env.js", "vip-dev-env-create": "dist/bin/vip-dev-env-create.js", "vip-dev-env-update": "dist/bin/vip-dev-env-update.js", diff --git a/src/bin/vip.js b/src/bin/vip.js index ee61a4c91..769d06fa3 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -73,6 +73,7 @@ const runCmd = async function () { ) .command( 'slowlogs', 'Retrieve MySQL slow query logs from an environment.' ) .command( 'db', "Access an environment's database." ) + .command( 'defensive-mode', 'Manage VIP defensive mode for an environment.' ) .command( 'sync', 'Sync the database from production to a non-production environment.' ) .command( 'whoami', 'Retrieve details about the current authenticated VIP-CLI user.' ) .command( 'wp', 'Execute a WP-CLI command against an environment.' ); From 80e0d1d4a26918310332bf14fa1167e1729f9bf5 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:37:53 -0500 Subject: [PATCH 14/22] feat(logout): clear elevated-token cache on logout --- __tests__/lib/logout.test.ts | 42 ++++++++++++++++++++++++++++++++++++ src/lib/logout.ts | 2 ++ 2 files changed, 44 insertions(+) create mode 100644 __tests__/lib/logout.test.ts diff --git a/__tests__/lib/logout.test.ts b/__tests__/lib/logout.test.ts new file mode 100644 index 000000000..c8bb47d57 --- /dev/null +++ b/__tests__/lib/logout.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import logout from '../../src/lib/logout'; +import tokenCache from '../../src/lib/rechallenge/token-cache'; +import Token from '../../src/lib/token'; +import { trackEvent } from '../../src/lib/tracker'; + +jest.mock( '../../src/lib/api/http', () => ( { + __esModule: true, + default: jest.fn( () => Promise.resolve( { ok: true } ) ), +} ) ); + +jest.mock( '../../src/lib/token', () => ( { + __esModule: true, + default: { + purge: jest.fn( () => Promise.resolve( true ) ), + }, +} ) ); + +jest.mock( '../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn(), + clearScope: jest.fn(), + clearAll: jest.fn( () => Promise.resolve() ), + }, +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +describe( 'logout', () => { + it( 'purges primary token, clears elevated-token cache, and emits telemetry', async () => { + await logout(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( Token.purge ).toHaveBeenCalledTimes( 1 ); + expect( tokenCache.clearAll ).toHaveBeenCalledTimes( 1 ); + expect( trackEvent ).toHaveBeenCalledWith( 'logout_command_execute' ); + } ); +} ); diff --git a/src/lib/logout.ts b/src/lib/logout.ts index 26dfd217e..9a84fdad6 100644 --- a/src/lib/logout.ts +++ b/src/lib/logout.ts @@ -1,4 +1,5 @@ import http from '../lib/api/http'; +import tokenCache from '../lib/rechallenge/token-cache'; import Token from '../lib/token'; import { trackEvent } from '../lib/tracker'; @@ -6,6 +7,7 @@ export default async (): Promise< void > => { await http( '/logout', { method: 'post' } ); await Token.purge(); + await tokenCache.clearAll(); await trackEvent( 'logout_command_execute' ); }; From 6763ec4ab4ae311f59abc8d7d8be004dc0c41b07 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:41:29 -0500 Subject: [PATCH 15/22] fix(api): lint cleanup for lazy rechallenge link import --- src/lib/api.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 5dff1f07a..e446091ea 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,8 +13,8 @@ import debugLib from 'debug'; import { Kind, OperationTypeNode } from 'graphql'; import { FetchError } from 'node-fetch'; -import http from './api/http'; import { API_URL } from './api/constants'; +import http from './api/http'; // Config — re-exported from ./api/constants so modules in the rechallenge tree // can import them without pulling in the full api.ts graph (which would create @@ -151,16 +151,14 @@ export default function API( { // api.ts → link.ts → client.ts), preventing jest.mock('../api/http') from // intercepting the http reference captured inside client.ts. A require() // call inside the function body is resolved after all mocks are registered. + + type RechallengeLinkModule = typeof import('./rechallenge/link'); // eslint-disable-next-line @typescript-eslint/no-require-imports - const createRechallengeLink = ( require( './rechallenge/link' ) as typeof import( './rechallenge/link' ) ).default; + const linkMod = require( './rechallenge/link' ) as RechallengeLinkModule; + const createRechallengeLink = linkMod.default; return new ApolloClient( { - link: ApolloLink.from( [ - errorLink, - createRechallengeLink(), - retryLink, - httpLink, - ] ), + link: ApolloLink.from( [ errorLink, createRechallengeLink(), retryLink, httpLink ] ), cache: new InMemoryCache( { typePolicies: { WPSite: { From a3539249b697df1bc59a6012beda4ec6814f1d76 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 15:53:05 -0500 Subject: [PATCH 16/22] fix: address final-review findings (diff in configure, non-interactive guards, telemetry order, teardown race) - configure: log current effective config and proposed input before mutating (Fix 1) - enable/disable: add --non-interactive option and guard; error on production mutation attempted non-interactively without --skip-confirmation (Fix 2) - flow: add clientType=cli to rechallenge_required event (Fix 3) - flow: fire rechallenge_verified before rechallenge_exchanged to match spec order (Fix 4) - link: split innerSub into firstSub/retrySub to eliminate teardown race during async gap (Fix 5) - enable/disable/configure: use console.error for error-path chalk.red messages (Fix 6) - token-cache: document single-blob keychain strategy (Fix 7) - tests: assert proposed config logged in configure; add non-interactive-production exit tests for enable/disable; assert rechallenge_verified fires before rechallenge_exchanged Co-Authored-By: Claude Sonnet 4.6 --- __tests__/bin/vip-defensive-mode-configure.js | 16 +++++++++++++ __tests__/bin/vip-defensive-mode-disable.js | 16 +++++++++++++ __tests__/bin/vip-defensive-mode-enable.js | 16 +++++++++++++ __tests__/lib/rechallenge/flow.test.ts | 9 ++++++++ src/bin/vip-defensive-mode-configure.js | 10 +++++++- src/bin/vip-defensive-mode-disable.js | 23 ++++++++++++++++++- src/bin/vip-defensive-mode-enable.js | 23 ++++++++++++++++++- src/lib/rechallenge/flow.ts | 15 +++++++----- src/lib/rechallenge/link.ts | 10 ++++---- src/lib/rechallenge/token-cache.ts | 5 ++++ 10 files changed, 130 insertions(+), 13 deletions(-) diff --git a/__tests__/bin/vip-defensive-mode-configure.js b/__tests__/bin/vip-defensive-mode-configure.js index a01148391..1d272ffa9 100644 --- a/__tests__/bin/vip-defensive-mode-configure.js +++ b/__tests__/bin/vip-defensive-mode-configure.js @@ -116,4 +116,20 @@ describe( 'defensiveModeConfigureCommand', () => { expect.any( Object ) ); } ); + + it( 'logs the proposed configuration before mutating', async () => { + const consoleSpy = jest.spyOn( console, 'log' ); + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '2', + } ); + const allArgs = consoleSpy.mock.calls.flat(); + const inputJson = JSON.stringify( + { appId: 7, envId: 9, enabled: true, challengeType: 2 }, + null, + 2 + ); + expect( allArgs.some( arg => typeof arg === 'string' && arg === inputJson ) ).toBe( true ); + } ); } ); diff --git a/__tests__/bin/vip-defensive-mode-disable.js b/__tests__/bin/vip-defensive-mode-disable.js index a7b28f9d7..263c5d36d 100644 --- a/__tests__/bin/vip-defensive-mode-disable.js +++ b/__tests__/bin/vip-defensive-mode-disable.js @@ -9,6 +9,7 @@ function mockExit() { throw 'EXIT'; } jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); jest.mock( '../../src/lib/cli/command', () => { @@ -59,4 +60,19 @@ describe( 'defensiveModeDisableCommand', () => { expect.any( Object ) ); } ); + + it( 'exits with error on production without skip-confirmation in non-interactive mode', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'production' }, + skipConfirmation: false, + nonInteractive: true, + }; + await expect( defensiveModeDisableCommand( [], opts ) ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeStatus ).not.toHaveBeenCalled(); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_disable_command_cancelled', + expect.any( Object ) + ); + } ); } ); diff --git a/__tests__/bin/vip-defensive-mode-enable.js b/__tests__/bin/vip-defensive-mode-enable.js index 10b31aaaf..31800b089 100644 --- a/__tests__/bin/vip-defensive-mode-enable.js +++ b/__tests__/bin/vip-defensive-mode-enable.js @@ -9,6 +9,7 @@ function mockExit() { throw 'EXIT'; } jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); jest.mock( '../../src/lib/cli/command', () => { @@ -66,4 +67,19 @@ describe( 'defensiveModeEnableCommand', () => { expect.any( Object ) ); } ); + + it( 'exits with error on production without skip-confirmation in non-interactive mode', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'production' }, + skipConfirmation: false, + nonInteractive: true, + }; + await expect( defensiveModeEnableCommand( [], opts ) ).rejects.toBe( 'EXIT' ); + expect( mockUpdate ).not.toHaveBeenCalled(); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_cancelled', + expect.any( Object ) + ); + } ); } ); diff --git a/__tests__/lib/rechallenge/flow.test.ts b/__tests__/lib/rechallenge/flow.test.ts index b94d33753..9fe29da49 100644 --- a/__tests__/lib/rechallenge/flow.test.ts +++ b/__tests__/lib/rechallenge/flow.test.ts @@ -109,10 +109,19 @@ describe( 'runRechallenge', () => { 'updateDefensiveModeStatus', expect.objectContaining( { token: 'jwt.payload.sig' } ) ); + expect( trackEvent ).toHaveBeenCalledWith( + 'rechallenge_verified', + expect.objectContaining( { scope: 'updateDefensiveModeStatus' } ) + ); expect( trackEvent ).toHaveBeenCalledWith( 'rechallenge_exchanged', expect.objectContaining( { scope: 'updateDefensiveModeStatus' } ) ); + // verified fires before exchanged + const calls = trackEvent.mock.calls.map( ( [ name ] ) => name ); + expect( calls.indexOf( 'rechallenge_verified' ) ).toBeLessThan( + calls.indexOf( 'rechallenge_exchanged' ) + ); } ); it( 'throws RechallengeTerminalError on non-verified terminal states', async () => { diff --git a/src/bin/vip-defensive-mode-configure.js b/src/bin/vip-defensive-mode-configure.js index 6c70f2a09..d2b9841d8 100644 --- a/src/bin/vip-defensive-mode-configure.js +++ b/src/bin/vip-defensive-mode-configure.js @@ -207,6 +207,14 @@ export async function defensiveModeConfigureCommand( _args, opt ) { input.connectionThresholdPercentage = percentage; } + const currentConfig = opt.env.defensiveMode?.config?.effective ?? null; + if ( currentConfig ) { + console.log( chalk.bold( 'Current defensive-mode configuration:' ) ); + console.log( JSON.stringify( currentConfig, null, 2 ) ); + } + console.log( chalk.bold( 'Proposed defensive-mode configuration:' ) ); + console.log( JSON.stringify( input, null, 2 ) ); + if ( interactive && ! opt.skipConfirmation && opt.env.type === 'production' ) { const yes = await confirm( `Apply this configuration to ${ formatEnvironment( opt.env.type ) } for ${ @@ -227,7 +235,7 @@ export async function defensiveModeConfigureCommand( _args, opt ) { ...trackingParams, error: result.message, } ); - console.log( chalk.red( `Failed to update defensive mode config: ${ result.message }` ) ); + console.error( chalk.red( `Failed to update defensive mode config: ${ result.message }` ) ); process.exit( 1 ); } diff --git a/src/bin/vip-defensive-mode-disable.js b/src/bin/vip-defensive-mode-disable.js index ede7c3d4c..cd04331b7 100644 --- a/src/bin/vip-defensive-mode-disable.js +++ b/src/bin/vip-defensive-mode-disable.js @@ -8,6 +8,12 @@ import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; import { confirm } from '../lib/envvar/input'; import { trackEvent } from '../lib/tracker'; +function isInteractive( opt ) { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) return false; + if ( opt.nonInteractive ) return false; + return Boolean( process.stdout.isTTY ); +} + const baseUsage = 'vip defensive-mode disable'; const exampleUsage = 'vip @example-app.production defensive-mode disable'; @@ -31,6 +37,16 @@ export async function defensiveModeDisableCommand( _args, opt ) { await trackEvent( 'defensive_mode_disable_command_execute', trackingParams ); if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { + if ( ! isInteractive( opt ) ) { + console.error( + chalk.red( + 'Refusing to disable defensive mode on production without confirmation. ' + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + await trackEvent( 'defensive_mode_disable_command_cancelled', trackingParams ); + process.exit( 1 ); + } const yes = await confirm( `Disable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` ); @@ -52,7 +68,7 @@ export async function defensiveModeDisableCommand( _args, opt ) { ...trackingParams, error: result.message, } ); - console.log( chalk.red( `Failed to disable defensive mode: ${ result.message }` ) ); + console.error( chalk.red( `Failed to disable defensive mode: ${ result.message }` ) ); process.exit( 1 ); } @@ -70,5 +86,10 @@ command( { usage: baseUsage, } ) .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .option( + 'non-interactive', + 'Disable prompts; error if a production mutation is attempted without --skip-confirmation.', + false + ) .examples( examples ) .argv( process.argv, defensiveModeDisableCommand ); diff --git a/src/bin/vip-defensive-mode-enable.js b/src/bin/vip-defensive-mode-enable.js index d0250a2d4..8819c86de 100644 --- a/src/bin/vip-defensive-mode-enable.js +++ b/src/bin/vip-defensive-mode-enable.js @@ -8,6 +8,12 @@ import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; import { confirm } from '../lib/envvar/input'; import { trackEvent } from '../lib/tracker'; +function isInteractive( opt ) { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) return false; + if ( opt.nonInteractive ) return false; + return Boolean( process.stdout.isTTY ); +} + const baseUsage = 'vip defensive-mode enable'; const exampleUsage = 'vip @example-app.production defensive-mode enable'; @@ -35,6 +41,16 @@ export async function defensiveModeEnableCommand( _args, opt ) { await trackEvent( 'defensive_mode_enable_command_execute', trackingParams ); if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { + if ( ! isInteractive( opt ) ) { + console.error( + chalk.red( + 'Refusing to enable defensive mode on production without confirmation. ' + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + await trackEvent( 'defensive_mode_enable_command_cancelled', trackingParams ); + process.exit( 1 ); + } const yes = await confirm( `Enable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` ); @@ -56,7 +72,7 @@ export async function defensiveModeEnableCommand( _args, opt ) { ...trackingParams, error: result.message, } ); - console.log( chalk.red( `Failed to enable defensive mode: ${ result.message }` ) ); + console.error( chalk.red( `Failed to enable defensive mode: ${ result.message }` ) ); process.exit( 1 ); } @@ -74,5 +90,10 @@ command( { usage: baseUsage, } ) .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .option( + 'non-interactive', + 'Disable prompts; error if a production mutation is attempted without --skip-confirmation.', + false + ) .examples( examples ) .argv( process.argv, defensiveModeEnableCommand ); diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts index 564568590..84c7d164b 100644 --- a/src/lib/rechallenge/flow.ts +++ b/src/lib/rechallenge/flow.ts @@ -10,7 +10,7 @@ import { } from './errors'; import { openBrowser } from './open-browser'; import tokenCache from './token-cache'; -import { RECHALLENGE_VERSION } from './types'; +import { CLIENT_TYPE, RECHALLENGE_VERSION } from './types'; import type { ElevatedToken, RechallengeExtension, RechallengeStatus } from './types'; @@ -55,7 +55,10 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El throw new RechallengeUnsupportedVersionError( rechallenge.version, requestedOperation ); } - await trackEvent( 'rechallenge_required', { scope: requestedOperation } ); + await trackEvent( 'rechallenge_required', { + scope: requestedOperation, + clientType: CLIENT_TYPE, + } ); const session = await client.createSession( { path: rechallenge.createSessionPath, @@ -123,6 +126,10 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El } if ( status.status === 'verified' ) { + await trackEvent( 'rechallenge_verified', { + scope: requestedOperation, + provider: status.provider ?? 'unknown', + } ); const { elevatedToken } = await client.exchange( { template: rechallenge.exchangePathTemplate, challengeId: session.challengeId, @@ -133,10 +140,6 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El ...elevatedToken, headerName: rechallenge.elevatedHeaderName, } ); - await trackEvent( 'rechallenge_verified', { - scope: requestedOperation, - provider: status.provider ?? 'unknown', - } ); return elevatedToken; } diff --git a/src/lib/rechallenge/link.ts b/src/lib/rechallenge/link.ts index 6a7800d03..6d1c36abe 100644 --- a/src/lib/rechallenge/link.ts +++ b/src/lib/rechallenge/link.ts @@ -74,7 +74,8 @@ export default function createRechallengeLink(): ApolloLink { return new Observable< ApolloLink.Result >( observer => { let retrying = false; let cancelled = false; - let innerSub: { unsubscribe(): void } | null = null; + let firstSub: { unsubscribe(): void } | null = null; + let retrySub: { unsubscribe(): void } | null = null; const preflight = async () => { if ( ! eligible || ! scope ) { @@ -92,7 +93,7 @@ export default function createRechallengeLink(): ApolloLink { if ( cancelled || observer.closed ) { return; } - innerSub = forward( operation ).subscribe( { + firstSub = forward( operation ).subscribe( { next: result => { if ( retrying || ! eligible || ! scope ) { observer.next( result ); @@ -117,7 +118,7 @@ export default function createRechallengeLink(): ApolloLink { return; } attachElevatedHeader( operation, headerName, token ); - innerSub = forward( operation ).subscribe( { + retrySub = forward( operation ).subscribe( { next: res => observer.next( res ), error: err => observer.error( err ), complete: () => observer.complete(), @@ -145,7 +146,8 @@ export default function createRechallengeLink(): ApolloLink { return () => { cancelled = true; - innerSub?.unsubscribe(); + firstSub?.unsubscribe(); + retrySub?.unsubscribe(); }; } ); } ); diff --git a/src/lib/rechallenge/token-cache.ts b/src/lib/rechallenge/token-cache.ts index 24061d0d0..57bd357eb 100644 --- a/src/lib/rechallenge/token-cache.ts +++ b/src/lib/rechallenge/token-cache.ts @@ -6,6 +6,11 @@ import keychain from '../keychain'; import type { ElevatedToken } from './types'; const debug = debugLib( '@automattic/vip:rechallenge:cache' ); +// Storage strategy: a single keychain entry holds a JSON map { [scope]: ElevatedToken }. +// The vip-cli Keychain interface (src/lib/keychain/keychain.ts) is service-only — there +// is no separate account argument — so per-scope entries under the keytar model would +// require a different keying scheme. The single-blob approach also keeps clearAll() cheap. +// This is marked subject-to-change in the spec pending security review. const BASE_SERVICE = 'vip-go-cli:elevated'; function serviceName(): string { From c9fc8dfca6255823ea42fa35b3f188ea19ffc000 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 16:24:37 -0500 Subject: [PATCH 17/22] fix: address PR review feedback (data guards, version constant, payload validation, dedupe commands) Co-Authored-By: Claude Opus 4.7 --- __tests__/lib/rechallenge/client.test.ts | 2 + src/bin/vip-defensive-mode-configure.js | 33 +++------ src/bin/vip-defensive-mode-disable.js | 59 +++++---------- src/bin/vip-defensive-mode-enable.js | 59 +++++---------- src/lib/defensive-mode/api.ts | 10 +++ src/lib/defensive-mode/cli-helpers.ts | 91 ++++++++++++++++++++++++ src/lib/rechallenge/errors.ts | 4 +- src/lib/rechallenge/link.ts | 8 ++- 8 files changed, 159 insertions(+), 107 deletions(-) create mode 100644 src/lib/defensive-mode/cli-helpers.ts diff --git a/__tests__/lib/rechallenge/client.test.ts b/__tests__/lib/rechallenge/client.test.ts index eb532a651..db4e75e0f 100644 --- a/__tests__/lib/rechallenge/client.test.ts +++ b/__tests__/lib/rechallenge/client.test.ts @@ -4,6 +4,8 @@ import http from '../../../src/lib/api/http'; import * as client from '../../../src/lib/rechallenge/client'; import { RechallengeHttpError } from '../../../src/lib/rechallenge/errors'; +import type { Response } from 'node-fetch'; + jest.mock( '../../../src/lib/api/http' ); const mockHttp = http as unknown as jest.Mock; diff --git a/src/bin/vip-defensive-mode-configure.js b/src/bin/vip-defensive-mode-configure.js index d2b9841d8..a23a3cfdd 100644 --- a/src/bin/vip-defensive-mode-configure.js +++ b/src/bin/vip-defensive-mode-configure.js @@ -6,6 +6,7 @@ import { prompt } from 'enquirer'; import command from '../lib/cli/command'; import { formatEnvironment } from '../lib/cli/format'; import { appQuery, updateDefensiveModeConfig } from '../lib/defensive-mode/api'; +import { isInteractive, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; import { confirm } from '../lib/envvar/input'; import { trackEvent } from '../lib/tracker'; @@ -23,16 +24,6 @@ const examples = [ }, ]; -function isInteractive( opt ) { - if ( process.env.VIP_NON_INTERACTIVE === '1' ) { - return false; - } - if ( opt.nonInteractive ) { - return false; - } - return Boolean( process.stdout.isTTY ); -} - function parseBoolean( raw ) { if ( raw === true || raw === false ) { return raw; @@ -230,19 +221,15 @@ export async function defensiveModeConfigureCommand( _args, opt ) { const result = await updateDefensiveModeConfig( input ); - if ( ! result.success ) { - await trackEvent( 'defensive_mode_configure_command_error', { - ...trackingParams, - error: result.message, - } ); - console.error( chalk.red( `Failed to update defensive mode config: ${ result.message }` ) ); - process.exit( 1 ); - } - - await trackEvent( 'defensive_mode_configure_command_success', trackingParams ); - console.log( - chalk.green( '✓' ), - `Defensive mode configuration updated for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + await reportMutationResult( + result, + trackingParams, + 'configure', + opt.app.name, + opt.env.type, + 'configuration updated', + 'update defensive mode config', + trackEvent ); } diff --git a/src/bin/vip-defensive-mode-disable.js b/src/bin/vip-defensive-mode-disable.js index cd04331b7..a396e4cec 100644 --- a/src/bin/vip-defensive-mode-disable.js +++ b/src/bin/vip-defensive-mode-disable.js @@ -1,19 +1,12 @@ #!/usr/bin/env node -import chalk from 'chalk'; - import command from '../lib/cli/command'; import { formatEnvironment } from '../lib/cli/format'; import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { guardProductionMutation, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; import { confirm } from '../lib/envvar/input'; import { trackEvent } from '../lib/tracker'; -function isInteractive( opt ) { - if ( process.env.VIP_NON_INTERACTIVE === '1' ) return false; - if ( opt.nonInteractive ) return false; - return Boolean( process.stdout.isTTY ); -} - const baseUsage = 'vip defensive-mode disable'; const exampleUsage = 'vip @example-app.production defensive-mode disable'; @@ -36,26 +29,14 @@ export async function defensiveModeDisableCommand( _args, opt ) { await trackEvent( 'defensive_mode_disable_command_execute', trackingParams ); - if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { - if ( ! isInteractive( opt ) ) { - console.error( - chalk.red( - 'Refusing to disable defensive mode on production without confirmation. ' + - 'Pass --skip-confirmation to proceed non-interactively.' - ) - ); - await trackEvent( 'defensive_mode_disable_command_cancelled', trackingParams ); - process.exit( 1 ); - } - const yes = await confirm( - `Disable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` - ); - if ( ! yes ) { - await trackEvent( 'defensive_mode_disable_command_cancelled', trackingParams ); - console.log( 'Command cancelled' ); - process.exit(); - } - } + await guardProductionMutation( + opt, + 'disable', + trackingParams, + confirm, + trackEvent, + formatEnvironment + ); const result = await updateDefensiveModeStatus( { appId: opt.app.id, @@ -63,19 +44,15 @@ export async function defensiveModeDisableCommand( _args, opt ) { enabled: false, } ); - if ( ! result.success ) { - await trackEvent( 'defensive_mode_disable_command_error', { - ...trackingParams, - error: result.message, - } ); - console.error( chalk.red( `Failed to disable defensive mode: ${ result.message }` ) ); - process.exit( 1 ); - } - - await trackEvent( 'defensive_mode_disable_command_success', trackingParams ); - console.log( - chalk.green( '✓' ), - `Defensive mode disabled for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + await reportMutationResult( + result, + trackingParams, + 'disable', + opt.app.name, + opt.env.type, + 'disabled', + 'disable defensive mode', + trackEvent ); } diff --git a/src/bin/vip-defensive-mode-enable.js b/src/bin/vip-defensive-mode-enable.js index 8819c86de..2a8eb5fd2 100644 --- a/src/bin/vip-defensive-mode-enable.js +++ b/src/bin/vip-defensive-mode-enable.js @@ -1,19 +1,12 @@ #!/usr/bin/env node -import chalk from 'chalk'; - import command from '../lib/cli/command'; import { formatEnvironment } from '../lib/cli/format'; import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { guardProductionMutation, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; import { confirm } from '../lib/envvar/input'; import { trackEvent } from '../lib/tracker'; -function isInteractive( opt ) { - if ( process.env.VIP_NON_INTERACTIVE === '1' ) return false; - if ( opt.nonInteractive ) return false; - return Boolean( process.stdout.isTTY ); -} - const baseUsage = 'vip defensive-mode enable'; const exampleUsage = 'vip @example-app.production defensive-mode enable'; @@ -40,26 +33,14 @@ export async function defensiveModeEnableCommand( _args, opt ) { await trackEvent( 'defensive_mode_enable_command_execute', trackingParams ); - if ( ! opt.skipConfirmation && opt.env.type === 'production' ) { - if ( ! isInteractive( opt ) ) { - console.error( - chalk.red( - 'Refusing to enable defensive mode on production without confirmation. ' + - 'Pass --skip-confirmation to proceed non-interactively.' - ) - ); - await trackEvent( 'defensive_mode_enable_command_cancelled', trackingParams ); - process.exit( 1 ); - } - const yes = await confirm( - `Enable defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name }?` - ); - if ( ! yes ) { - await trackEvent( 'defensive_mode_enable_command_cancelled', trackingParams ); - console.log( 'Command cancelled' ); - process.exit(); - } - } + await guardProductionMutation( + opt, + 'enable', + trackingParams, + confirm, + trackEvent, + formatEnvironment + ); const result = await updateDefensiveModeStatus( { appId: opt.app.id, @@ -67,19 +48,15 @@ export async function defensiveModeEnableCommand( _args, opt ) { enabled: true, } ); - if ( ! result.success ) { - await trackEvent( 'defensive_mode_enable_command_error', { - ...trackingParams, - error: result.message, - } ); - console.error( chalk.red( `Failed to enable defensive mode: ${ result.message }` ) ); - process.exit( 1 ); - } - - await trackEvent( 'defensive_mode_enable_command_success', trackingParams ); - console.log( - chalk.green( '✓' ), - `Defensive mode enabled for ${ opt.app.name }.${ opt.env.type } — ${ result.message }` + await reportMutationResult( + result, + trackingParams, + 'enable', + opt.app.name, + opt.env.type, + 'enabled', + 'enable defensive mode', + trackEvent ); } diff --git a/src/lib/defensive-mode/api.ts b/src/lib/defensive-mode/api.ts index e8ca07a46..6d4ee4d8d 100644 --- a/src/lib/defensive-mode/api.ts +++ b/src/lib/defensive-mode/api.ts @@ -84,6 +84,11 @@ export async function updateDefensiveModeStatus( input: { id: input.appId, environmentId: input.envId, enabled: input.enabled }, }, } ); + if ( ! result.data ) { + throw new Error( + 'updateDefensiveModeStatus returned no data; the API may have rejected the request.' + ); + } return ( result.data as { updateDefensiveModeStatus: { success: boolean; message: string }; @@ -111,6 +116,11 @@ export async function updateDefensiveModeConfig( mutation: CONFIG_MUTATION, variables: { input: mutationInput }, } ); + if ( ! result.data ) { + throw new Error( + 'updateDefensiveModeConfig returned no data; the API may have rejected the request.' + ); + } return ( result.data as { updateDefensiveModeConfig: { success: boolean; message: string }; diff --git a/src/lib/defensive-mode/cli-helpers.ts b/src/lib/defensive-mode/cli-helpers.ts new file mode 100644 index 000000000..2923affa8 --- /dev/null +++ b/src/lib/defensive-mode/cli-helpers.ts @@ -0,0 +1,91 @@ +import chalk from 'chalk'; + +export function isInteractive( opt: { nonInteractive?: boolean } ): boolean { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) { + return false; + } + if ( opt.nonInteractive ) { + return false; + } + return Boolean( process.stdout.isTTY ); +} + +export interface ProductionGuardOptions { + app: { name: string }; + env: { type: string }; + skipConfirmation?: boolean; + nonInteractive?: boolean; +} + +function capitalize( s: string ): string { + return s.charAt( 0 ).toUpperCase() + s.slice( 1 ); +} + +/** + * Guards production mutations that require confirmation. Returns true if the + * command should proceed. In non-interactive contexts without + * --skip-confirmation it emits an error and calls process.exit(1) directly. + * If the user declines the interactive prompt it calls process.exit() directly. + */ +export async function guardProductionMutation( + opt: ProductionGuardOptions, + action: 'enable' | 'disable' | 'configure', + trackingParams: Record< string, unknown >, + confirmFn: ( message: string ) => Promise< boolean >, + trackEventFn: ( event: string, props: Record< string, unknown > ) => Promise< void >, + formatEnvironment: ( type: string ) => string +): Promise< boolean > { + if ( opt.skipConfirmation || opt.env.type !== 'production' ) { + return true; + } + if ( ! isInteractive( opt ) ) { + console.error( + chalk.red( + `Refusing to ${ action } defensive mode on production without confirmation. ` + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + await trackEventFn( `defensive_mode_${ action }_command_cancelled`, trackingParams ); + process.exit( 1 ); + } + const yes = await confirmFn( + `${ capitalize( action ) } defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ + opt.app.name + }?` + ); + if ( ! yes ) { + await trackEventFn( `defensive_mode_${ action }_command_cancelled`, trackingParams ); + console.log( 'Command cancelled' ); + process.exit(); + } + return true; +} + +/** + * Handles success/failure reporting, telemetry, and log output after a + * defensive-mode mutation. Exits the process on failure. + */ +export async function reportMutationResult( + result: { success: boolean; message: string }, + trackingParams: Record< string, unknown >, + action: 'enable' | 'disable' | 'configure', + appName: string, + envType: string, + successVerb: string, + failureVerb: string, + trackEventFn: ( event: string, props: Record< string, unknown > ) => Promise< void > +): Promise< void > { + if ( ! result.success ) { + await trackEventFn( `defensive_mode_${ action }_command_error`, { + ...trackingParams, + error: result.message, + } ); + console.error( chalk.red( `Failed to ${ failureVerb }: ${ result.message }` ) ); + process.exit( 1 ); + } + await trackEventFn( `defensive_mode_${ action }_command_success`, trackingParams ); + console.log( + chalk.green( '✓' ), + `Defensive mode ${ successVerb } for ${ appName }.${ envType } — ${ result.message }` + ); +} diff --git a/src/lib/rechallenge/errors.ts b/src/lib/rechallenge/errors.ts index bfef60d4e..644dfe0c9 100644 --- a/src/lib/rechallenge/errors.ts +++ b/src/lib/rechallenge/errors.ts @@ -1,3 +1,5 @@ +import { RECHALLENGE_VERSION } from './types'; + import type { RechallengeStatus } from './types'; export class RechallengeError extends Error { @@ -12,7 +14,7 @@ export class RechallengeError extends Error { export class RechallengeUnsupportedVersionError extends RechallengeError { constructor( version: string, scope: string ) { super( - `Server requested rechallenge version "${ version }" but this CLI only supports v2. Update vip-cli.`, + `Server requested rechallenge version "${ version }" but this CLI only supports ${ RECHALLENGE_VERSION }. Update vip-cli.`, scope ); this.name = 'RechallengeUnsupportedVersionError'; diff --git a/src/lib/rechallenge/link.ts b/src/lib/rechallenge/link.ts index 6d1c36abe..d3a2b0c88 100644 --- a/src/lib/rechallenge/link.ts +++ b/src/lib/rechallenge/link.ts @@ -44,7 +44,13 @@ function extractElevatedPermission( result: ApolloLink.Result ): ElevatedPermiss continue; } const rechallenge = ext.rechallenge as RechallengeExtension | undefined; - if ( rechallenge && typeof rechallenge.createSessionPath === 'string' ) { + if ( + rechallenge && + typeof rechallenge.createSessionPath === 'string' && + typeof rechallenge.statusPathTemplate === 'string' && + typeof rechallenge.exchangePathTemplate === 'string' && + typeof rechallenge.elevatedHeaderName === 'string' + ) { return { rechallenge }; } } From 5a7ca8348192ce56cb8ff332447ce6d83aa25ab9 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 10 Jun 2026 12:33:48 -0500 Subject: [PATCH 18/22] fix: address PR review feedback (output formatting, login hardening, bin loader, sleep, flag parsing) - Render current/proposed defensive-mode config as a table instead of raw JSON, and drop the JSON blob from the production confirm prompt - Clear the elevated-token cache when `vip login` replaces the stored token, so cached elevation cannot carry across user identities - Register the four vip-defensive-mode* bins in internal-bin-loader.js - Replace the hand-rolled abortable sleep in rechallenge flow with setTimeout from node:timers/promises - Reject boolean/blank values in parsePositiveInt so bare flags like `--connection-threshold-absolute` error instead of coercing to 1 Co-Authored-By: Claude Fable 5 --- __tests__/bin/vip-defensive-mode-configure.js | 27 ++++++--- src/bin/vip-defensive-mode-configure.js | 60 +++++++++++++++---- src/bin/vip.js | 5 ++ src/lib/cli/internal-bin-loader.js | 4 ++ src/lib/rechallenge/flow.ts | 21 +------ 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/__tests__/bin/vip-defensive-mode-configure.js b/__tests__/bin/vip-defensive-mode-configure.js index 1d272ffa9..347d914ea 100644 --- a/__tests__/bin/vip-defensive-mode-configure.js +++ b/__tests__/bin/vip-defensive-mode-configure.js @@ -124,12 +124,25 @@ describe( 'defensiveModeConfigureCommand', () => { enabled: 'true', challengeType: '2', } ); - const allArgs = consoleSpy.mock.calls.flat(); - const inputJson = JSON.stringify( - { appId: 7, envId: 9, enabled: true, challengeType: 2 }, - null, - 2 - ); - expect( allArgs.some( arg => typeof arg === 'string' && arg === inputJson ) ).toBe( true ); + const allArgs = consoleSpy.mock.calls.flat().filter( arg => typeof arg === 'string' ); + const settingsTable = allArgs.find( arg => arg.includes( 'Challenge type' ) ); + expect( settingsTable ).toBeDefined(); + expect( settingsTable ).toContain( 'Enabled' ); + expect( settingsTable ).toContain( 'true' ); + expect( settingsTable ).toContain( '2' ); + expect( settingsTable ).toContain( '(not specified)' ); + } ); + + it( 'rejects bare threshold flags (boolean true)', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '1', + connectionThresholdAbsolute: true, + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeConfig ).not.toHaveBeenCalled(); } ); } ); diff --git a/src/bin/vip-defensive-mode-configure.js b/src/bin/vip-defensive-mode-configure.js index a23a3cfdd..2614f6546 100644 --- a/src/bin/vip-defensive-mode-configure.js +++ b/src/bin/vip-defensive-mode-configure.js @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { prompt } from 'enquirer'; import command from '../lib/cli/command'; -import { formatEnvironment } from '../lib/cli/format'; +import { formatEnvironment, table } from '../lib/cli/format'; import { appQuery, updateDefensiveModeConfig } from '../lib/defensive-mode/api'; import { isInteractive, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; import { confirm } from '../lib/envvar/input'; @@ -42,7 +42,12 @@ function parseBoolean( raw ) { } function parsePositiveInt( raw ) { - if ( raw === undefined || raw === null || raw === '' ) { + // A bare flag (e.g. `--challenge-type` with no value) arrives as boolean true, + // which Number() would silently coerce to 1. + if ( raw === undefined || raw === null || typeof raw === 'boolean' ) { + return null; + } + if ( typeof raw === 'string' && raw.trim() === '' ) { return null; } const num = Number( raw ); @@ -91,6 +96,35 @@ function validateFlags( opt ) { return { enabled, challengeType, absolute, percentage, errors }; } +function formatSettingValue( value ) { + return value === undefined || value === null ? '-' : String( value ); +} + +function buildSettingRows( currentConfig, { enabled, challengeType, absolute, percentage } ) { + return [ + { + setting: 'Enabled', + current: formatSettingValue( currentConfig?.enabled ), + proposed: formatSettingValue( enabled ), + }, + { + setting: 'Challenge type', + current: formatSettingValue( currentConfig?.challengeType ), + proposed: formatSettingValue( challengeType ), + }, + { + setting: 'Connection threshold (absolute)', + current: formatSettingValue( currentConfig?.connectionThresholdAbsolute ), + proposed: absolute === undefined ? '(not specified)' : formatSettingValue( absolute ), + }, + { + setting: 'Connection threshold (percentage)', + current: formatSettingValue( currentConfig?.connectionThresholdPercentage ), + proposed: percentage === undefined ? '(not specified)' : formatSettingValue( percentage ), + }, + ]; +} + async function resolveRequiredViaPrompt( missing, enabled, challengeType ) { const answers = await prompt( missing.map( flag => @@ -199,18 +233,24 @@ export async function defensiveModeConfigureCommand( _args, opt ) { } const currentConfig = opt.env.defensiveMode?.config?.effective ?? null; - if ( currentConfig ) { - console.log( chalk.bold( 'Current defensive-mode configuration:' ) ); - console.log( JSON.stringify( currentConfig, null, 2 ) ); - } - console.log( chalk.bold( 'Proposed defensive-mode configuration:' ) ); - console.log( JSON.stringify( input, null, 2 ) ); + const settingRows = buildSettingRows( currentConfig, { + enabled, + challengeType, + absolute, + percentage, + } ); + console.log( + `Defensive mode configuration for ${ chalk.bold( opt.app.name ) } (${ formatEnvironment( + opt.env.type + ) }):` + ); + console.log( table( settingRows ) ); if ( interactive && ! opt.skipConfirmation && opt.env.type === 'production' ) { const yes = await confirm( - `Apply this configuration to ${ formatEnvironment( opt.env.type ) } for ${ + `Apply the proposed configuration to ${ formatEnvironment( opt.env.type ) } for ${ opt.app.name - }?\n${ JSON.stringify( input, null, 2 ) }` + }?` ); if ( ! yes ) { await trackEvent( 'defensive_mode_configure_command_cancelled', trackingParams ); diff --git a/src/bin/vip.js b/src/bin/vip.js index 769d06fa3..713f7eaa8 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -14,6 +14,7 @@ import { resolveInternalBinFromArgv, isSeaRuntime, } from '../lib/cli/sea-dispatch'; +import tokenCache from '../lib/rechallenge/token-cache'; import Token from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; @@ -170,6 +171,10 @@ async function runLoginFlow() { throw err; } + // Elevated tokens are keyed by API host + scope, not by user identity. Drop any + // cached elevation from a previous login so it cannot carry across identities. + await tokenCache.clearAll(); + // De-anonymize user for tracking await aliasUser( token.id ); diff --git a/src/lib/cli/internal-bin-loader.js b/src/lib/cli/internal-bin-loader.js index 639414f4d..d40f035cc 100644 --- a/src/lib/cli/internal-bin-loader.js +++ b/src/lib/cli/internal-bin-loader.js @@ -20,6 +20,10 @@ const internalBinLoaders = { 'vip-config-software-update': () => import( '../../bin/vip-config-software-update' ), 'vip-db': () => import( '../../bin/vip-db' ), 'vip-db-phpmyadmin': () => import( '../../bin/vip-db-phpmyadmin' ), + 'vip-defensive-mode': () => import( '../../bin/vip-defensive-mode' ), + 'vip-defensive-mode-configure': () => import( '../../bin/vip-defensive-mode-configure' ), + 'vip-defensive-mode-disable': () => import( '../../bin/vip-defensive-mode-disable' ), + 'vip-defensive-mode-enable': () => import( '../../bin/vip-defensive-mode-enable' ), 'vip-dev-env': () => import( '../../bin/vip-dev-env' ), 'vip-dev-env-create': () => import( '../../bin/vip-dev-env-create' ), 'vip-dev-env-destroy': () => import( '../../bin/vip-dev-env-destroy' ), diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts index 84c7d164b..f15f4ca4a 100644 --- a/src/lib/rechallenge/flow.ts +++ b/src/lib/rechallenge/flow.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import debugLib from 'debug'; +import { setTimeout as sleep } from 'node:timers/promises'; import { trackEvent } from '../tracker'; import * as client from './client'; @@ -30,24 +31,6 @@ export interface RunRechallengeOptions { signal?: AbortSignal; } -function sleep( ms: number, signal?: AbortSignal ): Promise< void > { - return new Promise( ( resolve, reject ) => { - if ( signal?.aborted ) { - reject( new Error( 'aborted' ) ); - return; - } - const timer = setTimeout( () => { - signal?.removeEventListener( 'abort', onAbort ); - resolve(); - }, ms ); - function onAbort() { - clearTimeout( timer ); - reject( new Error( 'aborted' ) ); - } - signal?.addEventListener( 'abort', onAbort, { once: true } ); - } ); -} - export async function runRechallenge( opts: RunRechallengeOptions ): Promise< ElevatedToken > { const { requestedOperation, rechallenge, interactive, signal } = opts; @@ -101,7 +84,7 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El } try { - await sleep( interval, signal ); + await sleep( interval, undefined, { signal } ); } catch { throw new RechallengeAbortedError( requestedOperation ); } From 8736474dadbdc82e50101367025433a4ef735790 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 10 Jun 2026 12:46:39 -0500 Subject: [PATCH 19/22] chore(lint): scope no-await-in-loop disable to rechallenge polling loop The loop polls session status sequentially by design; each iteration must finish before the next starts. Co-Authored-By: Claude Fable 5 --- src/lib/rechallenge/flow.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts index f15f4ca4a..112856d40 100644 --- a/src/lib/rechallenge/flow.ts +++ b/src/lib/rechallenge/flow.ts @@ -78,6 +78,7 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El ); } + /* eslint-disable no-await-in-loop -- polling loop; each iteration must complete before the next */ while ( true ) { if ( signal?.aborted ) { throw new RechallengeAbortedError( requestedOperation ); @@ -135,6 +136,7 @@ export async function runRechallenge( opts: RunRechallengeOptions ): Promise< El status.statusReason?.message ); } + /* eslint-enable no-await-in-loop */ } export function isInteractiveContext( argvOrFlags: string[] = process.argv ): boolean { From 3a296460226bf465abd476cf6fb54cc8e96cff19 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 10 Jun 2026 12:50:03 -0500 Subject: [PATCH 20/22] fix(lint): stop importing Response type from node-fetch in rechallenge client trunk replaced node-fetch with undici (#2837), so in the PR merge ref the node-fetch types no longer resolve and type-aware lint flags every member access as unsafe. Derive the response type from http() instead, which tracks whichever fetch implementation the merged tree uses. Co-Authored-By: Claude Fable 5 --- __tests__/lib/rechallenge/client.test.ts | 2 +- src/lib/rechallenge/client.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/__tests__/lib/rechallenge/client.test.ts b/__tests__/lib/rechallenge/client.test.ts index db4e75e0f..b36e09c41 100644 --- a/__tests__/lib/rechallenge/client.test.ts +++ b/__tests__/lib/rechallenge/client.test.ts @@ -4,7 +4,7 @@ import http from '../../../src/lib/api/http'; import * as client from '../../../src/lib/rechallenge/client'; import { RechallengeHttpError } from '../../../src/lib/rechallenge/errors'; -import type { Response } from 'node-fetch'; +type Response = Awaited< ReturnType< typeof http > >; jest.mock( '../../../src/lib/api/http' ); const mockHttp = http as unknown as jest.Mock; diff --git a/src/lib/rechallenge/client.ts b/src/lib/rechallenge/client.ts index bc52ca8bb..90bd42551 100644 --- a/src/lib/rechallenge/client.ts +++ b/src/lib/rechallenge/client.ts @@ -10,7 +10,10 @@ import type { RechallengeSession, RechallengeSessionStatus, } from './types'; -import type { Response } from 'node-fetch'; + +// Derived from http() rather than imported from a fetch library, so this module +// keeps compiling across the node-fetch -> undici migration (trunk #2837). +type HttpResponse = Awaited< ReturnType< typeof http > >; const debug = debugLib( '@automattic/vip:rechallenge:client' ); @@ -18,7 +21,7 @@ function fillTemplate( template: string, challengeId: string ): string { return template.replaceAll( '{challengeId}', encodeURIComponent( challengeId ) ); } -async function parseOrThrow< T >( response: Response, scope: string ): Promise< T > { +async function parseOrThrow< T >( response: HttpResponse, scope: string ): Promise< T > { if ( ! response.ok ) { const text = await response.text(); throw new RechallengeHttpError( response.status, text, scope ); From 758fda5b20292cb7296ef76fe577abfe2e915e42 Mon Sep 17 00:00:00 2001 From: Alessandro Crismani Date: Wed, 10 Jun 2026 14:45:59 +0200 Subject: [PATCH 21/22] feat(edge-workers): add edge workers commands Co-Authored-By: Claude Fable 5 --- __tests__/bin/vip-edge-workers-deploy.js | 190 ++++++++++++++ __tests__/bin/vip-edge-workers-list.js | 78 ++++++ __tests__/bin/vip-edge-workers-validate.js | 114 ++++++++ __tests__/lib/edge-workers/location.js | 21 ++ __tests__/lib/edge-workers/project.js | 111 ++++++++ __tests__/lib/edge-workers/toolchains.js | 72 +++++ npm-shrinkwrap.json | 15 ++ package.json | 11 + src/bin/vip-edge-workers-build.js | 63 +++++ src/bin/vip-edge-workers-delete.js | 51 ++++ src/bin/vip-edge-workers-deploy.js | 140 ++++++++++ src/bin/vip-edge-workers-disable.js | 50 ++++ src/bin/vip-edge-workers-enable.js | 50 ++++ src/bin/vip-edge-workers-get.js | 85 ++++++ src/bin/vip-edge-workers-init.js | 69 +++++ src/bin/vip-edge-workers-list.js | 68 +++++ src/bin/vip-edge-workers-new.js | 82 ++++++ src/bin/vip-edge-workers-validate.js | 98 +++++++ src/bin/vip-edge-workers.js | 18 ++ src/bin/vip.js | 1 + src/lib/api.ts | 7 + src/lib/api/edge-workers.ts | 248 ++++++++++++++++++ src/lib/edge-workers/index.ts | 66 +++++ src/lib/edge-workers/location.ts | 26 ++ src/lib/edge-workers/project.ts | 185 +++++++++++++ .../toolchains/assemblyscript/constants.ts | 11 + .../toolchains/assemblyscript/index.ts | 146 +++++++++++ .../toolchains/assemblyscript/templates.ts | 100 +++++++ src/lib/edge-workers/toolchains/index.ts | 56 ++++ src/lib/edge-workers/types.ts | 92 +++++++ 30 files changed, 2324 insertions(+) create mode 100644 __tests__/bin/vip-edge-workers-deploy.js create mode 100644 __tests__/bin/vip-edge-workers-list.js create mode 100644 __tests__/bin/vip-edge-workers-validate.js create mode 100644 __tests__/lib/edge-workers/location.js create mode 100644 __tests__/lib/edge-workers/project.js create mode 100644 __tests__/lib/edge-workers/toolchains.js create mode 100644 src/bin/vip-edge-workers-build.js create mode 100644 src/bin/vip-edge-workers-delete.js create mode 100644 src/bin/vip-edge-workers-deploy.js create mode 100644 src/bin/vip-edge-workers-disable.js create mode 100644 src/bin/vip-edge-workers-enable.js create mode 100644 src/bin/vip-edge-workers-get.js create mode 100644 src/bin/vip-edge-workers-init.js create mode 100644 src/bin/vip-edge-workers-list.js create mode 100644 src/bin/vip-edge-workers-new.js create mode 100644 src/bin/vip-edge-workers-validate.js create mode 100644 src/bin/vip-edge-workers.js create mode 100644 src/lib/api/edge-workers.ts create mode 100644 src/lib/edge-workers/index.ts create mode 100644 src/lib/edge-workers/location.ts create mode 100644 src/lib/edge-workers/project.ts create mode 100644 src/lib/edge-workers/toolchains/assemblyscript/constants.ts create mode 100644 src/lib/edge-workers/toolchains/assemblyscript/index.ts create mode 100644 src/lib/edge-workers/toolchains/assemblyscript/templates.ts create mode 100644 src/lib/edge-workers/toolchains/index.ts create mode 100644 src/lib/edge-workers/types.ts diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js new file mode 100644 index 000000000..c33856f08 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -0,0 +1,190 @@ +import { edgeWorkersDeployCommand } from '../../src/bin/vip-edge-workers-deploy'; +import * as api from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as lib from '../../src/lib/edge-workers'; +import * as project from '../../src/lib/edge-workers/project'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: '', + findEdgeWorkerByName: jest.fn(), + createEdgeWorker: jest.fn(), + updateEdgeWorker: jest.fn(), + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), + readWorkerSource: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + resolveProjectDir: jest.fn(), + findWorker: jest.fn(), + discoverWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1 }, + env: { id: 3 }, + skipBuild: true, +}; + +const worker = { + dir: '/proj/workers/my-worker', + manifest: { name: 'my-worker', entry: 'assembly/index.ts', on_failure: 'continue' }, +}; + +describe( 'edgeWorkersDeployCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/proj' ); + project.findWorker.mockReturnValue( worker ); + lib.readPrebuiltWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + lib.readWorkerSource.mockReturnValue( 'source code' ); + api.validateEdgeWorker.mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'creates a worker when none exists with that name', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.createEdgeWorker ).toHaveBeenCalledWith( 3, { + name: 'my-worker', + wasmBinary: 'V0FTTQ==', + onFailure: 'continue', + source: 'source code', + } ); + expect( api.updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'updates the worker when one already exists with that name', async () => { + api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); + api.updateEdgeWorker.mockResolvedValue( { id: 42, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.updateEdgeWorker ).toHaveBeenCalledWith( 3, 42, { + name: 'my-worker', + wasmBinary: 'V0FTTQ==', + onFailure: 'continue', + source: 'source code', + location: null, + } ); + expect( api.createEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'sends the manifest location on update, clearing it when absent', async () => { + const location = { operator: 'starts_with', value: '/api/' }; + project.findWorker.mockReturnValue( { + ...worker, + manifest: { ...worker.manifest, location }, + } ); + api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); + api.updateEdgeWorker.mockResolvedValue( { id: 42, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.updateEdgeWorker ).toHaveBeenCalledWith( + 3, + 42, + expect.objectContaining( { location } ) + ); + } ); + + it( 'omits location on create when the manifest has none', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.createEdgeWorker ).toHaveBeenCalledWith( + 3, + expect.not.objectContaining( { location: expect.anything() } ) + ); + } ); + + it( 'omits source when --skip-source is set', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], { ...opts, skipSource: true } ); + + expect( lib.readWorkerSource ).not.toHaveBeenCalled(); + expect( api.createEdgeWorker ).toHaveBeenCalledWith( + 3, + expect.not.objectContaining( { source: expect.anything() } ) + ); + } ); + + it( 'validates against the env before uploading', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'V0FTTQ==' ); + } ); + + it( 'aborts the upload when validation fails', async () => { + api.validateEdgeWorker.mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + expect( api.createEdgeWorker ).not.toHaveBeenCalled(); + expect( api.updateEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'missing alloc export' ) + ); + } ); + + it( 'skips validation when --skip-validate is set', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], { ...opts, skipValidate: true } ); + + expect( api.validateEdgeWorker ).not.toHaveBeenCalled(); + expect( api.createEdgeWorker ).toHaveBeenCalled(); + } ); + + it( 'errors when no worker name and no --all is given', async () => { + await expect( edgeWorkersDeployCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'supply a worker name' ) + ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-list.js b/__tests__/bin/vip-edge-workers-list.js new file mode 100644 index 000000000..616493d2d --- /dev/null +++ b/__tests__/bin/vip-edge-workers-list.js @@ -0,0 +1,78 @@ +import { edgeWorkersListCommand } from '../../src/bin/vip-edge-workers-list'; +import * as api from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: '', + listEdgeWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { app: { id: 1 }, env: { id: 3 }, format: 'table' }; + +describe( 'edgeWorkersListCommand()', () => { + beforeEach( jest.clearAllMocks ); + + it( 'maps workers into flat, formattable rows', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers', + active: true, + phases: [ 'client_response' ], + location: { operator: 'starts_with', value: '/api/' }, + onFailure: 'continue', + updatedAt: '2026-06-04', + }, + ] ); + + const rows = await edgeWorkersListCommand( [], opts ); + + expect( rows ).toEqual( [ + { + id: 5, + name: 'headers', + active: 'yes', + phases: 'client_response', + location: 'starts_with "/api/"', + on_failure: 'continue', + modified: '2026-06-04', + }, + ] ); + } ); + + it( 'shows a friendly message and returns an empty array when there are none', async () => { + api.listEdgeWorkers.mockResolvedValue( [] ); + + const rows = await edgeWorkersListCommand( [], opts ); + + expect( rows ).toEqual( [] ); + expect( console.log ).toHaveBeenCalledWith( + 'No edge workers are deployed to this environment.' + ); + } ); + + it( 'reports a friendly error when the API call fails', async () => { + api.listEdgeWorkers.mockRejectedValue( new Error( 'boom' ) ); + + await expect( edgeWorkersListCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( 'Failed to list edge workers: boom' ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js new file mode 100644 index 000000000..f6949f102 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -0,0 +1,114 @@ +import { edgeWorkersValidateCommand } from '../../src/bin/vip-edge-workers-validate'; +import * as api from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as lib from '../../src/lib/edge-workers'; +import * as project from '../../src/lib/edge-workers/project'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: '', + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + resolveProjectDir: jest.fn(), + findWorker: jest.fn(), + discoverWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { app: { id: 1 }, env: { id: 3 } }; + +const worker = { + dir: '/proj/workers/my-worker', + manifest: { name: 'my-worker', entry: 'assembly/index.ts' }, +}; + +describe( 'edgeWorkersValidateCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/proj' ); + project.findWorker.mockReturnValue( worker ); + lib.buildWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'V0FTTQ==', + } ); + api.validateEdgeWorker.mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'builds and validates the worker against the env', async () => { + await edgeWorkersValidateCommand( [ 'my-worker' ], opts ); + + expect( lib.buildWorker ).toHaveBeenCalledWith( '/proj', worker ); + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'V0FTTQ==' ); + expect( exit.withError ).not.toHaveBeenCalled(); + } ); + + it( 'uses the prebuilt artifact with --skip-build', async () => { + lib.readPrebuiltWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'UFJF', + } ); + + await edgeWorkersValidateCommand( [ 'my-worker' ], { ...opts, skipBuild: true } ); + + expect( lib.buildWorker ).not.toHaveBeenCalled(); + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'UFJF' ); + } ); + + it( 'exits with an error when a worker is invalid', async () => { + api.validateEdgeWorker.mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( 'failed validation' ) ); + } ); + + it( 'validates every worker with --all', async () => { + project.discoverWorkers.mockReturnValue( [ + worker, + { dir: '/proj/workers/other', manifest: { name: 'other', entry: 'assembly/index.ts' } }, + ] ); + + await edgeWorkersValidateCommand( [], { ...opts, all: true } ); + + expect( api.validateEdgeWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'errors when no worker name and no --all is given', async () => { + await expect( edgeWorkersValidateCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'supply a worker name' ) + ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/location.js b/__tests__/lib/edge-workers/location.js new file mode 100644 index 000000000..3f3da23f9 --- /dev/null +++ b/__tests__/lib/edge-workers/location.js @@ -0,0 +1,21 @@ +import { parseLocationOption } from '../../../src/lib/edge-workers/location'; + +describe( 'parseLocationOption()', () => { + it.each( [ + [ 'starts_with:/api/', { operator: 'starts_with', value: '/api/' } ], + [ 'equals:/feed', { operator: 'equals', value: '/feed' } ], + [ 'ends_with:.json', { operator: 'ends_with', value: '.json' } ], + [ 'contains:preview', { operator: 'contains', value: 'preview' } ], + // Only the first colon separates the operator; the value keeps the rest. + [ 'equals:/api/v1:beta', { operator: 'equals', value: '/api/v1:beta' } ], + ] )( 'parses %s', ( raw, expected ) => { + expect( parseLocationOption( raw ) ).toEqual( expected ); + } ); + + it.each( [ 'starts_with', 'starts_with:', 'matches:/api/', ':/api/', '/api/', '' ] )( + 'rejects %s', + raw => { + expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); + } + ); +} ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js new file mode 100644 index 000000000..8e9852363 --- /dev/null +++ b/__tests__/lib/edge-workers/project.js @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + CONVENTIONAL_PROJECT_DIR, + discoverWorkers, + findWorker, + readProjectDescriptor, + resolveProjectDir, + writeProjectDescriptor, + writeWorkerManifest, +} from '../../../src/lib/edge-workers/project'; + +function makeProject( root ) { + fs.mkdirSync( root, { recursive: true } ); + writeProjectDescriptor( root, { type: 'assemblyscript' } ); + return root; +} + +function makeWorker( root, name, manifest = {} ) { + const dir = path.join( root, 'workers', name ); + fs.mkdirSync( dir, { recursive: true } ); + writeWorkerManifest( dir, { name, entry: 'assembly/index.ts', ...manifest } ); + return dir; +} + +describe( 'edge-workers project', () => { + let tmp; + + beforeEach( () => { + tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-test-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmp, { recursive: true, force: true } ); + } ); + + describe( 'resolveProjectDir', () => { + it( 'resolves an explicit --path containing a descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( resolveProjectDir( { path: 'proj' }, tmp ) ).toBe( project ); + } ); + + it( 'throws when --path has no descriptor', () => { + fs.mkdirSync( path.join( tmp, 'empty' ) ); + expect( () => resolveProjectDir( { path: 'empty' }, tmp ) ).toThrow( + /No edge-workers project/ + ); + } ); + + it( 'walks up from the cwd to find the descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + const deep = path.join( project, 'workers', 'a', 'assembly' ); + fs.mkdirSync( deep, { recursive: true } ); + expect( resolveProjectDir( {}, deep ) ).toBe( project ); + } ); + + it( 'falls back to the conventional subfolder', () => { + const project = makeProject( path.join( tmp, CONVENTIONAL_PROJECT_DIR ) ); + expect( resolveProjectDir( {}, tmp ) ).toBe( project ); + } ); + + it( 'throws with guidance when nothing is found', () => { + expect( () => resolveProjectDir( {}, tmp ) ).toThrow( /vip edge-workers init/ ); + } ); + } ); + + describe( 'descriptor', () => { + it( 'round-trips the descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( readProjectDescriptor( project ) ).toEqual( { type: 'assemblyscript' } ); + } ); + + it( 'throws when the descriptor lacks a type', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), '{}' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /missing a "type"/ ); + } ); + } ); + + describe( 'discoverWorkers / findWorker', () => { + it( 'discovers workers sorted by name and ignores dirs without a manifest', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'beta' ); + makeWorker( project, 'alpha' ); + fs.mkdirSync( path.join( project, 'workers', 'no-manifest' ), { recursive: true } ); + + const names = discoverWorkers( project ).map( worker => worker.manifest.name ); + expect( names ).toEqual( [ 'alpha', 'beta' ] ); + } ); + + it( 'returns an empty list when there is no workers dir', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( discoverWorkers( project ) ).toEqual( [] ); + } ); + + it( 'finds a worker by name', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'alpha' ); + expect( findWorker( project, 'alpha' ).manifest.name ).toBe( 'alpha' ); + } ); + + it( 'throws listing available workers when not found', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'alpha' ); + expect( () => findWorker( project, 'nope' ) ).toThrow( /Available workers: alpha/ ); + } ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js new file mode 100644 index 000000000..5d4e8b1cd --- /dev/null +++ b/__tests__/lib/edge-workers/toolchains.js @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { readProjectDescriptor, readWorkerManifest } from '../../../src/lib/edge-workers/project'; +import { getToolchain } from '../../../src/lib/edge-workers/toolchains'; + +describe( 'edge-workers toolchains', () => { + let tmp; + + beforeEach( () => { + tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-tc-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmp, { recursive: true, force: true } ); + } ); + + it( 'throws for an unknown type', () => { + expect( () => getToolchain( 'rust' ) ).toThrow( /Unknown edge worker type/ ); + } ); + + describe( 'assemblyscript', () => { + const tc = getToolchain( 'assemblyscript' ); + + it( 'scaffolds a project with the expected layout', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + + expect( readProjectDescriptor( project ).type ).toBe( 'assemblyscript' ); + expect( fs.existsSync( path.join( project, 'package.json' ) ) ).toBe( true ); + expect( fs.existsSync( path.join( project, 'tsconfig.json' ) ) ).toBe( true ); + expect( fs.existsSync( path.join( project, 'workers' ) ) ).toBe( true ); + + const pkg = JSON.parse( fs.readFileSync( path.join( project, 'package.json' ), 'utf8' ) ); + expect( pkg.dependencies ).toHaveProperty( '@automattic/vip-edge-workers-sdk' ); + expect( pkg.devDependencies ).toHaveProperty( 'assemblyscript' ); + } ); + + it( 'refuses to scaffold over an existing project', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.scaffoldProject( project ) ).toThrow( /already exists/ ); + } ); + + it( 'scaffolds a worker with a manifest and entry file', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + tc.scaffoldWorker( project, 'my-worker' ); + + const workerDir = path.join( project, 'workers', 'my-worker' ); + expect( readWorkerManifest( workerDir ) ).toEqual( { + name: 'my-worker', + entry: 'assembly/index.ts', + } ); + expect( fs.existsSync( path.join( workerDir, 'assembly', 'index.ts' ) ) ).toBe( true ); + } ); + + it( 'refuses to scaffold a worker that already exists', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + tc.scaffoldWorker( project, 'dup' ); + expect( () => tc.scaffoldWorker( project, 'dup' ) ).toThrow( /already exists/ ); + } ); + + it( 'ensureAvailable throws when the compiler is missing', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.ensureAvailable( project ) ).toThrow( /npm install/ ); + } ); + } ); +} ); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 064b1aaca..584224f48 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -69,6 +69,10 @@ "vip-config-software-update": "dist/bin/vip-config-software-update.js", "vip-db": "dist/bin/vip-db.js", "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js", + "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", + "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js", + "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", + "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", "vip-dev-env": "dist/bin/vip-dev-env.js", "vip-dev-env-create": "dist/bin/vip-dev-env-create.js", "vip-dev-env-destroy": "dist/bin/vip-dev-env-destroy.js", @@ -92,6 +96,17 @@ "vip-dev-env-sync": "dist/bin/vip-dev-env-sync.js", "vip-dev-env-sync-sql": "dist/bin/vip-dev-env-sync-sql.js", "vip-dev-env-update": "dist/bin/vip-dev-env-update.js", + "vip-edge-workers": "dist/bin/vip-edge-workers.js", + "vip-edge-workers-build": "dist/bin/vip-edge-workers-build.js", + "vip-edge-workers-delete": "dist/bin/vip-edge-workers-delete.js", + "vip-edge-workers-deploy": "dist/bin/vip-edge-workers-deploy.js", + "vip-edge-workers-disable": "dist/bin/vip-edge-workers-disable.js", + "vip-edge-workers-enable": "dist/bin/vip-edge-workers-enable.js", + "vip-edge-workers-get": "dist/bin/vip-edge-workers-get.js", + "vip-edge-workers-init": "dist/bin/vip-edge-workers-init.js", + "vip-edge-workers-list": "dist/bin/vip-edge-workers-list.js", + "vip-edge-workers-new": "dist/bin/vip-edge-workers-new.js", + "vip-edge-workers-validate": "dist/bin/vip-edge-workers-validate.js", "vip-export": "dist/bin/vip-export.js", "vip-export-sql": "dist/bin/vip-export-sql.js", "vip-import": "dist/bin/vip-import.js", diff --git a/package.json b/package.json index 934a9dfa0..e09289213 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,17 @@ "vip-dev-env-stop": "dist/bin/vip-dev-env-stop.js", "vip-dev-env-logs": "dist/bin/vip-dev-env-logs.js", "vip-dev-env-purge": "dist/bin/vip-dev-env-purge.js", + "vip-edge-workers": "dist/bin/vip-edge-workers.js", + "vip-edge-workers-init": "dist/bin/vip-edge-workers-init.js", + "vip-edge-workers-new": "dist/bin/vip-edge-workers-new.js", + "vip-edge-workers-build": "dist/bin/vip-edge-workers-build.js", + "vip-edge-workers-validate": "dist/bin/vip-edge-workers-validate.js", + "vip-edge-workers-list": "dist/bin/vip-edge-workers-list.js", + "vip-edge-workers-get": "dist/bin/vip-edge-workers-get.js", + "vip-edge-workers-deploy": "dist/bin/vip-edge-workers-deploy.js", + "vip-edge-workers-enable": "dist/bin/vip-edge-workers-enable.js", + "vip-edge-workers-disable": "dist/bin/vip-edge-workers-disable.js", + "vip-edge-workers-delete": "dist/bin/vip-edge-workers-delete.js", "vip-export": "dist/bin/vip-export.js", "vip-export-sql": "dist/bin/vip-export-sql.js", "vip-dev-env-sync": "dist/bin/vip-dev-env-sync.js", diff --git a/src/bin/vip-edge-workers-build.js b/src/bin/vip-edge-workers-build.js new file mode 100644 index 000000000..ea09b4a31 --- /dev/null +++ b/src/bin/vip-edge-workers-build.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker } from '../lib/edge-workers'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip edge-workers build'; + +const examples = [ + { + usage: 'vip edge-workers build', + description: 'Compile every worker in the project to WebAssembly.', + }, + { + usage: 'vip edge-workers build my-worker', + description: 'Compile a single worker.', + }, +]; + +export async function edgeWorkersBuildCommand( args = [], opt = {} ) { + const name = args[ 0 ]; + + await trackEvent( 'edge_workers_build_command_execute', { name, all: Boolean( opt.all ) } ); + + try { + const projectDir = resolveProjectDir( { path: opt.path } ); + + const workers = + name && ! opt.all ? [ findWorker( projectDir, name ) ] : discoverWorkers( projectDir ); + + if ( ! workers.length ) { + exit.withError( 'No workers found in this project. Create one with `vip edge-workers new`.' ); + } + + for ( const worker of workers ) { + const { wasmPath, sizeBytes } = buildWorker( projectDir, worker ); + console.log( + `✓ Built "${ worker.manifest.name }" → ${ path.relative( + projectDir, + wasmPath + ) } (${ sizeBytes } bytes)` + ); + } + + await trackEvent( 'edge_workers_build_command_success', { count: workers.length } ); + } catch ( err ) { + await trackEvent( 'edge_workers_build_command_error', { name, error: err.message } ); + exit.withError( err.message ); + } +} + +command( { + requiredArgs: 0, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Compile every worker in the project.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersBuildCommand ); diff --git a/src/bin/vip-edge-workers-delete.js b/src/bin/vip-edge-workers-delete.js new file mode 100644 index 000000000..41260bbdf --- /dev/null +++ b/src/bin/vip-edge-workers-delete.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import { appQuery, deleteEdgeWorker, findEdgeWorkerByName } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers delete'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers delete my-worker', + description: 'Permanently delete the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersDeleteCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await deleteEdgeWorker( env.id, worker.id ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_success', { name } ); + console.log( `✓ Deleted edge worker "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to delete edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + requireConfirm: 'Are you sure you want to permanently delete this edge worker?', + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersDeleteCommand ); diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js new file mode 100644 index 000000000..fe511dfd2 --- /dev/null +++ b/src/bin/vip-edge-workers-deploy.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import { + appQuery, + createEdgeWorker, + findEdgeWorkerByName, + updateEdgeWorker, + validateEdgeWorker, +} from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from '../lib/edge-workers'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers deploy'; + +const examples = [ + { + usage: 'vip @example-app.develop edge-workers deploy my-worker', + description: 'Compile and deploy a single worker to the develop environment.', + }, + { + usage: 'vip @example-app.develop edge-workers deploy --all', + description: 'Compile and deploy every worker in the project.', + }, + { + usage: 'vip @example-app.develop edge-workers deploy my-worker --skip-build', + description: 'Deploy a previously compiled artifact without recompiling.', + }, +]; + +async function deployWorker( app, env, projectDir, worker, opt ) { + const artifact = opt.skipBuild + ? readPrebuiltWorker( projectDir, worker ) + : buildWorker( projectDir, worker ); + + const { name, location, on_failure: onFailure } = worker.manifest; + + // Server-side dry-run validation before the real upload: persists nothing and + // fails fast with structured errors. The create/update below validates again, + // so `--skip-validate` just trades the early check for a slightly later one. + if ( ! opt.skipValidate ) { + const validation = await validateEdgeWorker( env.id, artifact.base64 ); + if ( validation && ! validation.valid ) { + const errors = ( validation.errors || [] ).join( '; ' ) || 'unknown error'; + throw new Error( `worker "${ name }" failed validation: ${ errors }` ); + } + } + + const source = opt.skipSource ? undefined : readWorkerSource( worker ); + + const existing = await findEdgeWorkerByName( app.id, env.id, name ); + + const input = { + wasmBinary: artifact.base64, + ...( onFailure ? { onFailure } : {} ), + ...( source ? { source } : {} ), + }; + + if ( existing ) { + // Location is always sent on update: null clears the rule, so removing + // `location` from the manifest reverts the worker to running everywhere. + const result = await updateEdgeWorker( env.id, existing.id, { + name, + ...input, + location: location ?? null, + } ); + return { action: 'updated', worker: result, sizeBytes: artifact.sizeBytes }; + } + + const result = await createEdgeWorker( env.id, { + name, + ...input, + ...( location ? { location } : {} ), + } ); + return { action: 'created', worker: result, sizeBytes: artifact.sizeBytes }; +} + +export async function edgeWorkersDeployCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_execute', { + name, + all: Boolean( opt.all ), + } ); + + try { + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + exit.withError( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + exit.withError( 'Please supply a worker name to deploy, or pass `--all`.' ); + } + + // Deploy sequentially for clear, ordered output and to avoid hammering the API. + for ( const worker of workers ) { + // eslint-disable-next-line no-await-in-loop + const result = await deployWorker( app, env, projectDir, worker, opt ); + const { action, worker: deployed, sizeBytes } = result; + const phases = deployed?.phases; + const phasesNote = phases ? `, phases: ${ phases.join( ', ' ) || 'none' }` : ''; + console.log( + `✓ ${ action } "${ worker.manifest.name }" (${ sizeBytes } bytes${ phasesNote })` + ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_success', { + count: workers.length, + } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to deploy edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Deploy every worker in the project.', false ) + .option( 'skip-build', 'Deploy a previously compiled artifact without recompiling.', false ) + .option( 'skip-validate', 'Skip server-side dry-run validation before uploading.', false ) + .option( 'skip-source', 'Do not store the worker source alongside the binary.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersDeployCommand ); diff --git a/src/bin/vip-edge-workers-disable.js b/src/bin/vip-edge-workers-disable.js new file mode 100644 index 000000000..99960ee54 --- /dev/null +++ b/src/bin/vip-edge-workers-disable.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers disable'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers disable my-worker', + description: 'Disable the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersDisableCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await setEdgeWorkerActive( env.id, worker.id, false ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_success', { name } ); + console.log( `✓ Disabled edge worker "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to disable edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersDisableCommand ); diff --git a/src/bin/vip-edge-workers-enable.js b/src/bin/vip-edge-workers-enable.js new file mode 100644 index 000000000..c36162866 --- /dev/null +++ b/src/bin/vip-edge-workers-enable.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers enable'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers enable my-worker', + description: 'Enable the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersEnableCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await setEdgeWorkerActive( env.id, worker.id, true ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_success', { name } ); + console.log( `✓ Enabled edge worker "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to enable edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersEnableCommand ); diff --git a/src/bin/vip-edge-workers-get.js b/src/bin/vip-edge-workers-get.js new file mode 100644 index 000000000..a97e02b1c --- /dev/null +++ b/src/bin/vip-edge-workers-get.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { appQuery, getEdgeWorker } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { keyValue } from '../lib/cli/format'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers get'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers get my-worker', + description: 'Show details for the deployed worker named "my-worker".', + }, + { + usage: 'vip @example-app.production edge-workers get my-worker --source', + description: 'Also print the stored source code for the worker.', + }, +]; + +export async function edgeWorkersGetCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_execute', { name } ); + + if ( ! name ) { + exit.withError( 'Please supply the name of an edge worker.' ); + } + + let worker; + try { + worker = await getEdgeWorker( app.id, env.id, name ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to get edge worker: ${ err.message }` ); + } + + if ( ! worker ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { + name, + error: 'Not found', + } ); + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_success', { name } ); + + const location = worker.location + ? `${ worker.location.operator } "${ worker.location.value }"` + : 'all requests'; + + console.log( + keyValue( [ + { key: 'ID', value: worker.id }, + { key: 'Name', value: worker.name }, + { key: 'Active', value: worker.active ? 'yes' : 'no' }, + { key: 'Phases', value: ( worker.phases || [] ).join( ', ' ) }, + { key: 'Location', value: location }, + { key: 'On failure', value: worker.onFailure }, + { key: 'Created', value: worker.createdAt }, + { key: 'Modified', value: worker.updatedAt }, + ] ) + ); + + if ( opt.source ) { + console.log( '\nSource:' ); + console.log( worker.source ?? '(no source stored)' ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .option( 'source', 'Print the stored source code for the worker.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersGetCommand ); diff --git a/src/bin/vip-edge-workers-init.js b/src/bin/vip-edge-workers-init.js new file mode 100644 index 000000000..7540d7c1c --- /dev/null +++ b/src/bin/vip-edge-workers-init.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { CONVENTIONAL_PROJECT_DIR } from '../lib/edge-workers/project'; +import { getToolchain } from '../lib/edge-workers/toolchains'; +import { DEFAULT_EDGE_WORKER_TYPE, SUPPORTED_EDGE_WORKER_TYPES } from '../lib/edge-workers/types'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip edge-workers init'; + +const examples = [ + { + usage: 'vip edge-workers init', + description: `Scaffold a new edge-workers project in ./${ CONVENTIONAL_PROJECT_DIR }.`, + }, + { + usage: 'vip edge-workers init ./infra/edge --type=assemblyscript', + description: 'Scaffold a project at a custom path with an explicit toolchain.', + }, +]; + +export async function edgeWorkersInitCommand( args = [], opt = {} ) { + const type = opt.type || DEFAULT_EDGE_WORKER_TYPE; + const targetArg = args[ 0 ] || CONVENTIONAL_PROJECT_DIR; + const projectDir = path.resolve( process.cwd(), targetArg ); + + await trackEvent( 'edge_workers_init_command_execute', { type } ); + + if ( ! SUPPORTED_EDGE_WORKER_TYPES.includes( type ) ) { + await trackEvent( 'edge_workers_init_command_error', { type, error: 'Unsupported type' } ); + exit.withError( + `Unsupported type "${ type }". Supported types: ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }.` + ); + } + + try { + getToolchain( type ).scaffoldProject( projectDir ); + } catch ( err ) { + await trackEvent( 'edge_workers_init_command_error', { type, error: err.message } ); + exit.withError( err.message ); + } + + await trackEvent( 'edge_workers_init_command_success', { type } ); + + console.log( `✓ Created a new ${ type } edge-workers project in ${ projectDir }` ); + console.log( '\nNext steps:' ); + console.log( ` cd ${ targetArg }` ); + console.log( ' npm install' ); + console.log( ' vip edge-workers new my-worker' ); +} + +command( { + requiredArgs: 0, + usage, +} ) + .option( + 'type', + `The worker toolchain to scaffold. Accepts ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }. Default is "${ DEFAULT_EDGE_WORKER_TYPE }".`, + DEFAULT_EDGE_WORKER_TYPE + ) + .examples( examples ) + .argv( process.argv, edgeWorkersInitCommand ); diff --git a/src/bin/vip-edge-workers-list.js b/src/bin/vip-edge-workers-list.js new file mode 100644 index 000000000..99ed9ef4a --- /dev/null +++ b/src/bin/vip-edge-workers-list.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import { appQuery, listEdgeWorkers } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers list'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers list', + description: 'List all edge workers deployed to the production environment.', + }, +]; + +function formatLocation( location ) { + if ( ! location ) { + return 'all requests'; + } + + return `${ location.operator } "${ location.value }"`; +} + +export async function edgeWorkersListCommand( _args = [], opt = {} ) { + const { app, env } = opt; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_execute' ); + + let workers; + try { + workers = await listEdgeWorkers( app.id, env.id ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_error', { + error: err.message, + } ); + exit.withError( `Failed to list edge workers: ${ err.message }` ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_success', { + count: workers.length, + } ); + + if ( ! workers.length && opt.format !== 'json' ) { + console.log( 'No edge workers are deployed to this environment.' ); + return []; + } + + return workers.map( worker => ( { + id: worker.id, + name: worker.name, + active: worker.active ? 'yes' : 'no', + phases: ( worker.phases || [] ).join( ', ' ), + location: formatLocation( worker.location ), + on_failure: worker.onFailure, + modified: worker.updatedAt, + } ) ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + format: true, + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersListCommand ); diff --git a/src/bin/vip-edge-workers-new.js b/src/bin/vip-edge-workers-new.js new file mode 100644 index 000000000..24740f26b --- /dev/null +++ b/src/bin/vip-edge-workers-new.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { parseLocationOption } from '../lib/edge-workers/location'; +import { + readProjectDescriptor, + readWorkerManifest, + resolveProjectDir, + WORKERS_DIR, + writeWorkerManifest, +} from '../lib/edge-workers/project'; +import { getToolchain } from '../lib/edge-workers/toolchains'; +import { EDGE_WORKER_LOCATION_OPERATORS } from '../lib/edge-workers/types'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip edge-workers new'; + +const examples = [ + { + usage: 'vip edge-workers new add-security-headers', + description: 'Add a new worker named "add-security-headers" to the current project.', + }, + { + usage: 'vip edge-workers new my-worker --path ./infra/edge', + description: 'Add a worker to a project at a specific path.', + }, + { + usage: 'vip edge-workers new api-auth --location starts_with:/api/', + description: 'Add a worker that only runs on request paths under /api/.', + }, +]; + +export async function edgeWorkersNewCommand( args = [], opt = {} ) { + const name = args[ 0 ]; + + await trackEvent( 'edge_workers_new_command_execute', { name } ); + + if ( ! name ) { + await trackEvent( 'edge_workers_new_command_error', { error: 'Missing name' } ); + exit.withError( 'Please supply a name for the new worker.' ); + } + + try { + // Parse up front so a bad --location doesn't leave a half-created worker behind. + const location = opt.location ? parseLocationOption( opt.location ) : undefined; + const projectDir = resolveProjectDir( { path: opt.path } ); + const descriptor = readProjectDescriptor( projectDir ); + getToolchain( descriptor.type ).scaffoldWorker( projectDir, name ); + + if ( location ) { + const workerDir = path.join( projectDir, WORKERS_DIR, name ); + writeWorkerManifest( workerDir, { ...readWorkerManifest( workerDir ), location } ); + } + + await trackEvent( 'edge_workers_new_command_success', { name, type: descriptor.type } ); + + const entryDir = path.join( WORKERS_DIR, name ); + console.log( `✓ Created worker "${ name }" in ${ path.join( projectDir, entryDir ) }` ); + console.log( '\nEdit the worker, then deploy it with:' ); + console.log( ` vip @my-site.develop edge-workers deploy ${ name }` ); + } catch ( err ) { + await trackEvent( 'edge_workers_new_command_error', { name, error: err.message } ); + exit.withError( err.message ); + } +} + +command( { + requiredArgs: 1, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( + 'location', + `Only run the worker on matching request paths, as ":". Operators: ${ EDGE_WORKER_LOCATION_OPERATORS.join( + ', ' + ) }.` + ) + .examples( examples ) + .argv( process.argv, edgeWorkersNewCommand ); diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js new file mode 100644 index 000000000..c0f57cc2c --- /dev/null +++ b/src/bin/vip-edge-workers-validate.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { appQuery, validateEdgeWorker } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker, readPrebuiltWorker } from '../lib/edge-workers'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers validate'; + +const examples = [ + { + usage: 'vip @example-app.develop edge-workers validate my-worker', + description: 'Compile a worker and validate it against the environment without deploying.', + }, + { + usage: 'vip @example-app.develop edge-workers validate --all', + description: 'Validate every worker in the project.', + }, + { + usage: 'vip @example-app.develop edge-workers validate my-worker --skip-build', + description: 'Validate a previously compiled artifact without recompiling.', + }, +]; + +export async function edgeWorkersValidateCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_execute', { + name, + all: Boolean( opt.all ), + } ); + + let invalidCount = 0; + try { + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + exit.withError( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + exit.withError( 'Please supply a worker name to validate, or pass `--all`.' ); + } + + // Validate sequentially for clear, ordered output. + for ( const worker of workers ) { + const artifact = opt.skipBuild + ? readPrebuiltWorker( projectDir, worker ) + : buildWorker( projectDir, worker ); + + // eslint-disable-next-line no-await-in-loop + const result = await validateEdgeWorker( env.id, artifact.base64 ); + + if ( result && ! result.valid ) { + invalidCount++; + const errors = ( result.errors || [] ).join( '; ' ) || 'unknown error'; + console.log( `✕ "${ worker.manifest.name }" is invalid: ${ errors }` ); + } else { + const phases = ( result?.phases || [] ).join( ', ' ) || 'none'; + console.log( `✓ "${ worker.manifest.name }" is valid (phases: ${ phases })` ); + } + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_success', { + count: workers.length, + invalid: invalidCount, + } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to validate edge worker: ${ err.message }` ); + } + + if ( invalidCount > 0 ) { + exit.withError( `${ invalidCount } worker(s) failed validation.` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Validate every worker in the project.', false ) + .option( 'skip-build', 'Validate a previously compiled artifact without recompiling.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersValidateCommand ); diff --git a/src/bin/vip-edge-workers.js b/src/bin/vip-edge-workers.js new file mode 100644 index 000000000..2967eba23 --- /dev/null +++ b/src/bin/vip-edge-workers.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; + +command( { + requiredArgs: 0, +} ) + .command( 'init', 'Scaffold a new edge-workers project.' ) + .command( 'new', 'Add a new worker to an edge-workers project.' ) + .command( 'build', 'Compile worker(s) to WebAssembly locally.' ) + .command( 'validate', 'Validate worker(s) against an environment without deploying.' ) + .command( 'list', 'List the edge workers deployed to an environment.' ) + .command( 'get', 'Retrieve details for a single deployed edge worker.' ) + .command( 'deploy', 'Compile and deploy a worker to an environment.' ) + .command( 'enable', 'Enable a deployed edge worker.' ) + .command( 'disable', 'Disable a deployed edge worker.' ) + .command( 'delete', 'Permanently delete a deployed edge worker.' ) + .argv( process.argv ); diff --git a/src/bin/vip.js b/src/bin/vip.js index 713f7eaa8..a54b70486 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -65,6 +65,7 @@ const runCmd = async function () { .command( 'cache', 'Manage page cache for an environment.' ) .command( 'config', 'Manage environment configurations.' ) .command( 'dev-env', 'Create and manage VIP Local Development Environments.' ) + .command( 'edge-workers', 'Scaffold, compile, and deploy WASM edge workers.' ) .command( 'export', 'Export a copy of data associated with an environment.' ) .command( 'import', 'Import media or SQL database files to an environment.' ) .command( 'logs', 'Retrieve Runtime Logs from an environment.' ) diff --git a/src/lib/api.ts b/src/lib/api.ts index aebb91621..284089ecb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -112,6 +112,13 @@ export default function API( { } if ( CombinedGraphQLErrors.is( error ) && globalGraphQLErrorHandlingEnabled ) { + // The full error objects carry `path`/`extensions` pinpointing the field + // that failed server-side, plus whatever partial data survived. + debug( 'GraphQL errors in response: %s', JSON.stringify( error.errors, null, 2 ) ); + if ( error.data ) { + debug( 'Partial response data: %s', JSON.stringify( error.data, null, 2 ) ); + } + for ( const err of error.errors ) { console.error( chalk.red( 'Error:' ), err.message ); } diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts new file mode 100644 index 000000000..17850ef4d --- /dev/null +++ b/src/lib/api/edge-workers.ts @@ -0,0 +1,248 @@ +/** + * GraphQL access for edge workers. + * + * The schema exposes workers under `app.environments[].edgeWorkers`, with + * `source`/`wasmBinary` as on-demand fields, plus create/update/setActive/delete + * mutations keyed by `environmentId`. Worker names are unique per environment, so + * the CLI reconciles create-vs-update by matching on `name`. + * + * NOTE: these types are hand-written rather than codegen'd because the edge + * worker schema is not part of the public schema bundle the codegen runs against. + */ + +import gql from 'graphql-tag'; + +import API from '../../lib/api'; + +import type { + EdgeWorker, + EdgeWorkerLocation, + EdgeWorkerOnFailure, + EdgeWorkerPhase, +} from '../edge-workers/types'; + +// Selector used by command.js for app/env context resolution. +export const appQuery = ` + id + name + environments { + id + appId + name + type + primaryDomain { + name + } + } +`; + +const EDGE_WORKER_FIELDS = ` + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +`; + +interface EnvironmentWithWorkers { + id: number; + edgeWorkers: EdgeWorker[]; +} + +interface EdgeWorkersQueryResult { + app: { + environments: EnvironmentWithWorkers[]; + } | null; +} + +function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: number ): EdgeWorker[] { + const env = result?.app?.environments?.find( candidate => candidate.id === envId ); + return env?.edgeWorkers ?? []; +} + +/** List the edge workers deployed to an environment (without source/wasm). */ +export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { + const api = API(); + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkers($appId: Int!) { + app(id: $appId) { + environments { + id + edgeWorkers { + ${ EDGE_WORKER_FIELDS } + } + } + } + } + `, + variables: { appId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data, envId ); +} + +/** + * Fetch a single worker by name, including the on-demand `source` and + * `wasmBinary` fields. The schema has no single-worker query, so this requests + * those fields across the environment's workers and filters client-side. + */ +export async function getEdgeWorker( + appId: number, + envId: number, + name: string +): Promise< EdgeWorker | null > { + const api = API(); + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkerDetail($appId: Int!) { + app(id: $appId) { + environments { + id + edgeWorkers { + ${ EDGE_WORKER_FIELDS } + source + wasmBinary + } + } + } + } + `, + variables: { appId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data, envId ).find( worker => worker.name === name ) ?? null; +} + +/** Find a deployed worker by name, or null. Used to reconcile create-vs-update. */ +export async function findEdgeWorkerByName( + appId: number, + envId: number, + name: string +): Promise< EdgeWorker | null > { + const workers = await listEdgeWorkers( appId, envId ); + return workers.find( worker => worker.name === name ) ?? null; +} + +export interface EdgeWorkerWriteInput { + name?: string; + wasmBinary?: string; + location?: EdgeWorkerLocation | null; + onFailure?: EdgeWorkerOnFailure; + source?: string; +} + +export async function createEdgeWorker( + envId: number, + input: EdgeWorkerWriteInput & { name: string; wasmBinary: string } +): Promise< EdgeWorker | null > { + const api = API(); + const response = await api.mutate< { createEdgeWorker: EdgeWorker | null } >( { + mutation: gql` + mutation CreateEdgeWorker($input: CreateEdgeWorkerInput!) { + createEdgeWorker(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, ...input } }, + } ); + + return response.data?.createEdgeWorker ?? null; +} + +export async function updateEdgeWorker( + envId: number, + edgeWorkerId: number, + input: EdgeWorkerWriteInput +): Promise< EdgeWorker | null > { + const api = API(); + const response = await api.mutate< { updateEdgeWorker: EdgeWorker | null } >( { + mutation: gql` + mutation UpdateEdgeWorker($input: UpdateEdgeWorkerInput!) { + updateEdgeWorker(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, edgeWorkerId, ...input } }, + } ); + + return response.data?.updateEdgeWorker ?? null; +} + +export interface EdgeWorkerValidationResult { + valid: boolean; + phases: EdgeWorkerPhase[]; + errors: string[]; +} + +/** + * Server-side dry-run validation of a compiled worker. Persists nothing — used + * to fail fast before the real create/update upload. Returns null when the + * mutation yields no result. + */ +export async function validateEdgeWorker( + envId: number, + wasmBinary: string +): Promise< EdgeWorkerValidationResult | null > { + const api = API(); + const response = await api.mutate< { + validateEdgeWorker: EdgeWorkerValidationResult | null; + } >( { + mutation: gql` + mutation ValidateEdgeWorker($input: ValidateEdgeWorkerInput!) { + validateEdgeWorker(input: $input) { + valid + phases + errors + } + } + `, + variables: { input: { environmentId: envId, wasmBinary } }, + } ); + + return response.data?.validateEdgeWorker ?? null; +} + +export async function setEdgeWorkerActive( + envId: number, + edgeWorkerId: number, + active: boolean +): Promise< EdgeWorker | null > { + const api = API(); + const response = await api.mutate< { setEdgeWorkerActive: EdgeWorker | null } >( { + mutation: gql` + mutation SetEdgeWorkerActive($input: SetEdgeWorkerActiveInput!) { + setEdgeWorkerActive(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, edgeWorkerId, active } }, + } ); + + return response.data?.setEdgeWorkerActive ?? null; +} + +export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< boolean > { + const api = API(); + const response = await api.mutate< { deleteEdgeWorker: boolean | null } >( { + mutation: gql` + mutation DeleteEdgeWorker($input: DeleteEdgeWorkerInput!) { + deleteEdgeWorker(input: $input) + } + `, + variables: { input: { environmentId: envId, edgeWorkerId } }, + } ); + + return response.data?.deleteEdgeWorker ?? false; +} diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts new file mode 100644 index 000000000..a6d111cfd --- /dev/null +++ b/src/lib/edge-workers/index.ts @@ -0,0 +1,66 @@ +/** + * Convenience entry point for the edge-workers lib: ties project resolution and + * the toolchain together to produce a deployable artifact. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import UserError from '../user-error'; +import { readProjectDescriptor } from './project'; +import { getToolchain } from './toolchains'; + +import type { DiscoveredWorker } from './types'; + +export * from './types'; +export * from './project'; +export * from './location'; +export { getToolchain } from './toolchains'; + +/** Conventional output directory for compiled artifacts, relative to the project root. */ +export const BUILD_DIR = 'build'; + +interface BuiltArtifact { + wasmPath: string; + base64: string; + sizeBytes: number; +} + +function encodeArtifact( wasmPath: string ): BuiltArtifact { + const buffer = fs.readFileSync( wasmPath ); + return { wasmPath, base64: buffer.toString( 'base64' ), sizeBytes: buffer.length }; +} + +/** Read a previously compiled artifact without recompiling (used by `deploy --skip-build`). */ +export function readPrebuiltWorker( projectDir: string, worker: DiscoveredWorker ): BuiltArtifact { + const wasmPath = path.join( projectDir, BUILD_DIR, `${ worker.manifest.name }.wasm` ); + if ( ! fs.existsSync( wasmPath ) ) { + throw new UserError( + `No compiled artifact found for "${ worker.manifest.name }" at "${ wasmPath }". ` + + 'Run `vip edge-workers build` first, or deploy without `--skip-build`.' + ); + } + + return encodeArtifact( wasmPath ); +} + +/** Compile a worker and return both the artifact path and its base64 encoding. */ +export function buildWorker( projectDir: string, worker: DiscoveredWorker ): BuiltArtifact { + const descriptor = readProjectDescriptor( projectDir ); + const toolchain = getToolchain( descriptor.type ); + + toolchain.ensureAvailable( projectDir ); + const wasmPath = toolchain.compile( projectDir, worker ); + + return encodeArtifact( wasmPath ); +} + +/** Read the entry source of a worker, for storing alongside the binary. */ +export function readWorkerSource( worker: DiscoveredWorker ): string | undefined { + const entry = path.resolve( worker.dir, worker.manifest.entry ); + try { + return fs.readFileSync( entry, 'utf8' ); + } catch { + return undefined; + } +} diff --git a/src/lib/edge-workers/location.ts b/src/lib/edge-workers/location.ts new file mode 100644 index 000000000..7c5de7e57 --- /dev/null +++ b/src/lib/edge-workers/location.ts @@ -0,0 +1,26 @@ +/** + * Parsing for location rules passed on the command line as + * `:` (e.g. `starts_with:/api/`). A location scopes which + * request paths a worker runs on; workers without one run on all requests. + */ + +import UserError from '../user-error'; +import { EDGE_WORKER_LOCATION_OPERATORS } from './types'; + +import type { EdgeWorkerLocation, EdgeWorkerLocationOperator } from './types'; + +export function parseLocationOption( raw: string ): EdgeWorkerLocation { + // Split on the first colon only: the value may itself contain colons. + const separator = raw.indexOf( ':' ); + const operator = separator > 0 ? raw.slice( 0, separator ) : ''; + const value = separator > 0 ? raw.slice( separator + 1 ) : ''; + + if ( ! ( EDGE_WORKER_LOCATION_OPERATORS as string[] ).includes( operator ) || ! value ) { + throw new UserError( + `Invalid location "${ raw }". Use ":", where is one of: ` + + `${ EDGE_WORKER_LOCATION_OPERATORS.join( ', ' ) } (e.g. "starts_with:/api/").` + ); + } + + return { operator: operator as EdgeWorkerLocationOperator, value }; +} diff --git a/src/lib/edge-workers/project.ts b/src/lib/edge-workers/project.ts new file mode 100644 index 000000000..cd07315d4 --- /dev/null +++ b/src/lib/edge-workers/project.ts @@ -0,0 +1,185 @@ +/** + * Edge-workers project resolution and on-disk layout helpers. + * + * Layout (created by `vip edge-workers init`): + * + * edge-workers/ + * edge-workers.json <- project descriptor (toolchain type) + * package.json + * lib/ <- shared modules + * workers/ + * / + * worker.json <- per-worker manifest + * assembly/index.ts <- entry (toolchain-specific) + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import UserError from '../user-error'; + +import type { DiscoveredWorker, ProjectDescriptor, WorkerManifest } from './types'; + +export const PROJECT_DESCRIPTOR_FILE = 'edge-workers.json'; +export const WORKER_MANIFEST_FILE = 'worker.json'; +export const WORKERS_DIR = 'workers'; +/** Conventional subfolder checked when resolving from a site-repo root. */ +export const CONVENTIONAL_PROJECT_DIR = 'edge-workers'; + +function isProjectRoot( dir: string ): boolean { + return fs.existsSync( path.join( dir, PROJECT_DESCRIPTOR_FILE ) ); +} + +/** + * Resolve the edge-workers project directory for a command. + * + * Resolution order: + * 1. `--path` if provided (must contain a project descriptor). + * 2. Walk up from the current working directory looking for the descriptor. + * 3. The conventional `./edge-workers` subfolder, if present. + * 4. Otherwise throw a UserError with guidance. + */ +export function resolveProjectDir( + opts: { path?: string } = {}, + cwd: string = process.cwd() +): string { + if ( opts.path ) { + const explicit = path.resolve( cwd, opts.path ); + if ( ! isProjectRoot( explicit ) ) { + throw new UserError( + `No edge-workers project found at "${ explicit }" (missing ${ PROJECT_DESCRIPTOR_FILE }).` + ); + } + + return explicit; + } + + // Walk up from cwd. + let current = path.resolve( cwd ); + + while ( true ) { + if ( isProjectRoot( current ) ) { + return current; + } + + const parent = path.dirname( current ); + if ( parent === current ) { + break; + } + current = parent; + } + + // Conventional subfolder fallback. + const conventional = path.resolve( cwd, CONVENTIONAL_PROJECT_DIR ); + if ( isProjectRoot( conventional ) ) { + return conventional; + } + + throw new UserError( + 'No edge-workers project found here. Run `vip edge-workers init` to create one, ' + + 'run the command from inside a project, or pass `--path` to point at one.' + ); +} + +export function readProjectDescriptor( projectDir: string ): ProjectDescriptor { + const file = path.join( projectDir, PROJECT_DESCRIPTOR_FILE ); + let raw: string; + try { + raw = fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read project descriptor at "${ file }".` ); + } + + let parsed: ProjectDescriptor; + try { + parsed = JSON.parse( raw ) as ProjectDescriptor; + } catch { + throw new UserError( `Project descriptor at "${ file }" is not valid JSON.` ); + } + + if ( ! parsed.type ) { + throw new UserError( `Project descriptor at "${ file }" is missing a "type" field.` ); + } + + return parsed; +} + +export function writeProjectDescriptor( projectDir: string, descriptor: ProjectDescriptor ): void { + const file = path.join( projectDir, PROJECT_DESCRIPTOR_FILE ); + fs.mkdirSync( projectDir, { recursive: true } ); + fs.writeFileSync( file, JSON.stringify( descriptor, null, '\t' ) + '\n' ); +} + +export function readWorkerManifest( workerDir: string ): WorkerManifest { + const file = path.join( workerDir, WORKER_MANIFEST_FILE ); + let raw: string; + try { + raw = fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read worker manifest at "${ file }".` ); + } + + let parsed: WorkerManifest; + try { + parsed = JSON.parse( raw ) as WorkerManifest; + } catch { + throw new UserError( `Worker manifest at "${ file }" is not valid JSON.` ); + } + + if ( ! parsed.name ) { + throw new UserError( `Worker manifest at "${ file }" is missing a "name" field.` ); + } + + return parsed; +} + +export function writeWorkerManifest( workerDir: string, manifest: WorkerManifest ): void { + const file = path.join( workerDir, WORKER_MANIFEST_FILE ); + fs.mkdirSync( workerDir, { recursive: true } ); + fs.writeFileSync( file, JSON.stringify( manifest, null, '\t' ) + '\n' ); +} + +/** Discover all workers in a project by scanning each `workers//worker.json`. */ +export function discoverWorkers( projectDir: string ): DiscoveredWorker[] { + const workersRoot = path.join( projectDir, WORKERS_DIR ); + if ( ! fs.existsSync( workersRoot ) ) { + return []; + } + + const entries = fs.readdirSync( workersRoot, { withFileTypes: true } ); + const workers: DiscoveredWorker[] = []; + for ( const entry of entries ) { + if ( ! entry.isDirectory() ) { + continue; + } + + const dir = path.join( workersRoot, entry.name ); + if ( ! fs.existsSync( path.join( dir, WORKER_MANIFEST_FILE ) ) ) { + continue; + } + + workers.push( { dir, manifest: readWorkerManifest( dir ) } ); + } + + return workers.sort( ( left, right ) => left.manifest.name.localeCompare( right.manifest.name ) ); +} + +/** + * Find a single worker by name (the manifest `name`, falling back to the + * directory name for convenience). + */ +export function findWorker( projectDir: string, name: string ): DiscoveredWorker { + const workers = discoverWorkers( projectDir ); + const match = workers.find( + worker => worker.manifest.name === name || path.basename( worker.dir ) === name + ); + + if ( ! match ) { + const available = workers.map( worker => worker.manifest.name ).join( ', ' ) || '(none)'; + throw new UserError( + `No worker named "${ name }" found in this project. Available workers: ${ available }.` + ); + } + + return match; +} diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts new file mode 100644 index 000000000..d67da3124 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts @@ -0,0 +1,11 @@ +/** + * Shared constants for the AssemblyScript toolchain. Kept in one place so a + * version bump or an SDK rename is a single-line change that flows into both the + * scaffolded templates and the scaffold/compile logic. + */ + +export const SDK_PACKAGE = '@automattic/vip-edge-workers-sdk'; +export const SDK_VERSION = '^0.2.0'; +export const ASSEMBLYSCRIPT_VERSION = '^0.27.0'; +export const DEFAULT_ENTRY = 'assembly/index.ts'; +export const BUILD_DIR = 'build'; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/index.ts b/src/lib/edge-workers/toolchains/assemblyscript/index.ts new file mode 100644 index 000000000..3f251b708 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -0,0 +1,146 @@ +/** + * AssemblyScript toolchain: scaffolds an AssemblyScript edge-workers project, + * adds workers, and compiles them to `.wasm` with the canonical `asc` flags. + * + * The compile flags are a contract with the platform's WASM validator, so the + * CLI owns them here rather than relying on user-authored build scripts — every + * customer then compiles identically and a CLI update can fix everyone at once. + * + * The scaffolded file contents live in `./templates`; shared constants (versions, + * SDK name, paths) live in `./constants`. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { BUILD_DIR, DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; +import { GITIGNORE, PACKAGE_JSON, README, starterWorker, TSCONFIG_JSON } from './templates'; +import UserError from '../../../user-error'; +import { + PROJECT_DESCRIPTOR_FILE, + WORKERS_DIR, + writeProjectDescriptor, + writeWorkerManifest, +} from '../../project'; + +import type { DiscoveredWorker } from '../../types'; +import type { Toolchain } from '../index'; + +/** Write `contents` to `filePath`, creating any missing parent directories. */ +function writeFileEnsuringDir( filePath: string, contents: string ): void { + fs.mkdirSync( path.dirname( filePath ), { recursive: true } ); + fs.writeFileSync( filePath, contents ); +} + +function ascBinaryPath( projectDir: string ): string { + const binName = process.platform === 'win32' ? 'asc.cmd' : 'asc'; + return path.join( projectDir, 'node_modules', '.bin', binName ); +} + +const toolchain: Toolchain = { + type: 'assemblyscript', + + scaffoldProject( projectDir: string ): void { + if ( fs.existsSync( path.join( projectDir, PROJECT_DESCRIPTOR_FILE ) ) ) { + throw new UserError( + `An edge-workers project already exists at "${ projectDir }" (found ${ PROJECT_DESCRIPTOR_FILE }).` + ); + } + + // Every write below ensures its own parent directory, so no standalone + // mkdir is needed up front. + writeProjectDescriptor( projectDir, { + type: 'assemblyscript', + sdk: `${ SDK_PACKAGE }@${ SDK_VERSION }`, + } ); + writeFileEnsuringDir( + path.join( projectDir, 'package.json' ), + JSON.stringify( PACKAGE_JSON, null, '\t' ) + '\n' + ); + writeFileEnsuringDir( + path.join( projectDir, 'tsconfig.json' ), + JSON.stringify( TSCONFIG_JSON, null, '\t' ) + '\n' + ); + writeFileEnsuringDir( path.join( projectDir, '.gitignore' ), GITIGNORE ); + writeFileEnsuringDir( path.join( projectDir, 'README.md' ), README ); + // Keep the workers directory present (and committed) even when empty. + writeFileEnsuringDir( path.join( projectDir, WORKERS_DIR, '.gitkeep' ), '' ); + }, + + scaffoldWorker( projectDir: string, name: string ): void { + const workerDir = path.join( projectDir, WORKERS_DIR, name ); + if ( fs.existsSync( workerDir ) ) { + throw new UserError( `A worker directory already exists at "${ workerDir }".` ); + } + + writeWorkerManifest( workerDir, { name, entry: DEFAULT_ENTRY } ); + writeFileEnsuringDir( path.join( workerDir, DEFAULT_ENTRY ), starterWorker() ); + }, + + ensureAvailable( projectDir: string ): void { + const asc = ascBinaryPath( projectDir ); + if ( ! fs.existsSync( asc ) ) { + throw new UserError( + `The AssemblyScript compiler was not found at "${ asc }". ` + + `Run \`npm install\` in "${ projectDir }" first.` + ); + } + }, + + compile( projectDir: string, worker: DiscoveredWorker ): string { + const asc = ascBinaryPath( projectDir ); + const entry = path.resolve( worker.dir, worker.manifest.entry || DEFAULT_ENTRY ); + if ( ! fs.existsSync( entry ) ) { + throw new UserError( `Worker entry file not found: "${ entry }".` ); + } + + const nodeModules = path.join( projectDir, 'node_modules' ); + const outFile = path.join( projectDir, BUILD_DIR, `${ worker.manifest.name }.wasm` ); + fs.mkdirSync( path.dirname( outFile ), { recursive: true } ); + + const args = [ + entry, + '--runtime', + 'stub', + '--path', + nodeModules, + '--outFile', + outFile, + '--optimizeLevel', + '3', + '--shrinkLevel', + '2', + ]; + + // Enable the json-as transform when it's installed so workers can parse JSON. + // The transform lives at the `transform` subpath; the package root has no + // requirable entry, so `--transform json-as` fails to resolve. + if ( fs.existsSync( path.join( nodeModules, 'json-as' ) ) ) { + args.push( '--transform', 'json-as/transform' ); + } + + // asc misbehaves if NODE_OPTIONS is inherited; drop it like the SDK build does. + const env = { ...process.env }; + delete env.NODE_OPTIONS; + + const result = spawnSync( asc, args, { cwd: projectDir, env, encoding: 'utf8' } ); + + if ( result.error ) { + throw new UserError( `Failed to run the AssemblyScript compiler: ${ result.error.message }` ); + } + + if ( result.status !== 0 ) { + const details = ( result.stderr || result.stdout || '' ).trim(); + throw new UserError( + `Compilation failed for worker "${ worker.manifest.name }"${ + details ? `:\n${ details }` : '.' + }` + ); + } + + return outFile; + }, +}; + +export default toolchain; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts new file mode 100644 index 000000000..51ac29d3e --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts @@ -0,0 +1,100 @@ +/** + * The files written into a scaffolded AssemblyScript project. Kept out of the + * toolchain logic so the scaffold steps read cleanly; the dynamic bits (SDK + * package name, workers dir, default entry) interpolate from shared constants so + * there's a single source of truth. + */ + +import { + ASSEMBLYSCRIPT_VERSION, + BUILD_DIR, + DEFAULT_ENTRY, + SDK_PACKAGE, + SDK_VERSION, +} from './constants'; +import { WORKERS_DIR } from '../../project'; + +export const PACKAGE_JSON = { + name: 'edge-workers', + version: '0.0.0', + private: true, + description: 'VIP edge workers', + type: 'module', + scripts: { + build: 'vip edge-workers build --all', + }, + dependencies: { + [ SDK_PACKAGE ]: SDK_VERSION, + }, + devDependencies: { + assemblyscript: ASSEMBLYSCRIPT_VERSION, + }, +}; + +export const TSCONFIG_JSON = { + extends: 'assemblyscript/std/assembly.json', + include: [ './**/*.ts' ], +}; + +export const GITIGNORE = `node_modules/ +${ BUILD_DIR }/ +`; + +export const README = `# Edge workers + +AssemblyScript edge workers for your VIP environment. Each worker lives in its +own folder under \`${ WORKERS_DIR }/\` and is compiled to a \`.wasm\` binary that +runs at the edge. + +## Getting started + +\`\`\`sh +npm install # install the SDK + compiler +vip edge-workers new my-worker # scaffold a new worker +# edit ${ WORKERS_DIR }/my-worker/${ DEFAULT_ENTRY } +vip @my-site.develop edge-workers deploy my-worker +\`\`\` + +Shared AssemblyScript modules go in \`lib/\` and can be imported from any worker. + +## Parsing JSON + +To work with JSON in a worker, install [json-as](https://www.npmjs.com/package/json-as) +(\`npm install --save-dev json-as@^1.3.4\`); the build enables its compiler +transform automatically when the package is present. +`; + +export function starterWorker(): string { + return `import { + Request, + Response, + onClientRequest, + onOriginRequest, + onClientResponse, + onOriginResponse, +} from '${ SDK_PACKAGE }'; + +// A worker re-exports \`alloc\` plus the host entrypoints for each phase it +// handles. Drop the ones you don't use (and their hooks below). +export { + alloc, + on_client_request, + on_origin_request, + on_client_response, + on_origin_response, +} from '${ SDK_PACKAGE }/assembly/index'; + +// Client request: runs before the cache lookup, on every request. +onClientRequest( ( req: Request ): void => {} ); + +// Origin request: runs on a cache miss, before forwarding to origin. +onOriginRequest( ( req: Request ): void => {} ); + +// Client response: runs before the response reaches the client. +onClientResponse( ( res: Response ): void => {} ); + +// Origin response: runs after origin responds (cache miss); what you set here +// governs what the host caches. +onOriginResponse( ( res: Response ): void => {} ); +`; +} diff --git a/src/lib/edge-workers/toolchains/index.ts b/src/lib/edge-workers/toolchains/index.ts new file mode 100644 index 000000000..3dd28b5ee --- /dev/null +++ b/src/lib/edge-workers/toolchains/index.ts @@ -0,0 +1,56 @@ +/** + * Toolchain registry. + * + * A Toolchain encapsulates everything language-specific about an edge-workers + * project: how to scaffold it, how to add a worker, how to verify the local + * compiler is available, and how to compile a worker to a `.wasm` artifact. + * + * Everything downstream of `compile()` (base64, upload, list, toggle, delete) + * is language-neutral, so adding a new language (e.g. Rust) means implementing + * one Toolchain and registering it here — nothing in the command layer changes. + */ + +import UserError from '../../user-error'; +import { SUPPORTED_EDGE_WORKER_TYPES } from '../types'; +import assemblyscript from './assemblyscript'; + +import type { DiscoveredWorker, EdgeWorkerType } from '../types'; + +export interface Toolchain { + type: EdgeWorkerType; + + /** Scaffold a fresh project at `projectDir`. */ + scaffoldProject( projectDir: string ): void; + + /** Add a new worker named `name` to an existing project. */ + scaffoldWorker( projectDir: string, name: string ): void; + + /** + * Verify the local compiler toolchain is available for this project, + * throwing a UserError with remediation steps if not. + */ + ensureAvailable( projectDir: string ): void; + + /** + * Compile a worker to a `.wasm` binary. Returns the absolute path to the + * produced artifact. + */ + compile( projectDir: string, worker: DiscoveredWorker ): string; +} + +const TOOLCHAINS: Record< EdgeWorkerType, Toolchain > = { + assemblyscript, +}; + +export function getToolchain( type: EdgeWorkerType ): Toolchain { + const toolchain = TOOLCHAINS[ type ]; + if ( ! toolchain ) { + throw new UserError( + `Unknown edge worker type "${ type }". Supported types: ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }.` + ); + } + + return toolchain; +} diff --git a/src/lib/edge-workers/types.ts b/src/lib/edge-workers/types.ts new file mode 100644 index 000000000..c839ffd0f --- /dev/null +++ b/src/lib/edge-workers/types.ts @@ -0,0 +1,92 @@ +/** + * Shared types for the edge-workers commands. + * + * The local half of edge workers (scaffold + compile) is language-specific and + * lives behind the Toolchain abstraction; the remote half (upload, list, toggle) + * is language-neutral because the deployable artifact is always a `.wasm` binary. + */ + +/** + * The languages/SDKs an edge-workers project can be scaffolded with. Only + * AssemblyScript is implemented today; new toolchains slot in via the registry + * in `./toolchains` without touching the command layer. + */ +export type EdgeWorkerType = 'assemblyscript'; + +export const SUPPORTED_EDGE_WORKER_TYPES: EdgeWorkerType[] = [ 'assemblyscript' ]; + +export const DEFAULT_EDGE_WORKER_TYPE: EdgeWorkerType = 'assemblyscript'; + +/** The behavior to apply when a worker errors at runtime (mirrors the API enum). */ +export type EdgeWorkerOnFailure = 'continue' | 'error'; + +/** The request/response phases a worker hooks into, derived from its wasm exports (mirrors the API enum). */ +export type EdgeWorkerPhase = + | 'client_request' + | 'client_response' + | 'origin_request' + | 'origin_response'; + +/** The operators available for matching an edge worker's location (mirrors the API enum). */ +export type EdgeWorkerLocationOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with'; + +export const EDGE_WORKER_LOCATION_OPERATORS: EdgeWorkerLocationOperator[] = [ + 'contains', + 'equals', + 'starts_with', + 'ends_with', +]; + +/** A rule scoping which requests a worker runs on. Runs on all requests when absent. */ +export interface EdgeWorkerLocation { + operator: EdgeWorkerLocationOperator; + value: string; +} + +/** + * The project descriptor written once at `init` to the project root + * (`edge-workers.json`). It records which toolchain the project uses so that + * `new`/`build`/`deploy` can dispatch without re-asking. It is intentionally NOT + * a registry of workers — workers are discovered by scanning for `worker.json`. + */ +export interface ProjectDescriptor { + type: EdgeWorkerType; + /** The pinned SDK dependency spec, for reference (e.g. `@automattic/vip-edge-workers-sdk@^0.1.0`). */ + sdk?: string; +} + +/** + * The per-worker manifest (`worker.json`) co-located with each worker's code. + * Holds exactly the metadata the create/update API needs, keyed by `name`. + */ +export interface WorkerManifest { + /** The human-readable name; the per-site unique key used to reconcile create-vs-update. */ + name: string; + /** Entry source file, relative to the worker directory. Defaults per toolchain. */ + entry: string; + location?: EdgeWorkerLocation; + on_failure?: EdgeWorkerOnFailure; +} + +/** A worker discovered on disk: its directory plus parsed manifest. */ +export interface DiscoveredWorker { + /** Absolute path to the worker directory. */ + dir: string; + manifest: WorkerManifest; +} + +/** A deployed edge worker as returned by the API. */ +export interface EdgeWorker { + id: number; + name: string; + location: EdgeWorkerLocation | null; + phases: EdgeWorkerPhase[]; + onFailure: EdgeWorkerOnFailure; + active: boolean; + createdAt: string; + updatedAt: string; + /** Only present when explicitly requested (on-demand field). */ + source?: string | null; + /** Only present when explicitly requested (on-demand field). */ + wasmBinary?: string | null; +} From c59fde216547fc13fe12f1db528873104884c6aa Mon Sep 17 00:00:00 2001 From: Alessandro Crismani Date: Wed, 15 Jul 2026 14:41:10 +0200 Subject: [PATCH 22/22] Bump the SDK version to get the new resp.request functionality --- src/lib/edge-workers/toolchains/assemblyscript/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts index d67da3124..c8de96077 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts @@ -5,7 +5,7 @@ */ export const SDK_PACKAGE = '@automattic/vip-edge-workers-sdk'; -export const SDK_VERSION = '^0.2.0'; +export const SDK_VERSION = '^0.3.0'; export const ASSEMBLYSCRIPT_VERSION = '^0.27.0'; export const DEFAULT_ENTRY = 'assembly/index.ts'; export const BUILD_DIR = 'build';