From 8934b7116ea537446f95e5896a271d2716dacc09 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 4 Jun 2026 13:55:41 -0500 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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'; From a3e087cfc289bce32bcd377f88c24c95fb7d9661 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 11:50:59 -0500 Subject: [PATCH 23/41] fix(edge-workers): contain project filesystem paths --- __tests__/lib/edge-workers/project.js | 62 +++++++ __tests__/lib/edge-workers/toolchains.js | 31 ++++ __tests__/lib/edge-workers/validation.test.ts | 170 ++++++++++++++++++ src/lib/edge-workers/index.ts | 15 +- src/lib/edge-workers/project.ts | 32 ++-- .../toolchains/assemblyscript/index.ts | 11 +- src/lib/edge-workers/types.ts | 2 +- src/lib/edge-workers/validation.ts | 126 +++++++++++++ 8 files changed, 427 insertions(+), 22 deletions(-) create mode 100644 __tests__/lib/edge-workers/validation.test.ts create mode 100644 src/lib/edge-workers/validation.ts diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js index 8e9852363..23b773fdd 100644 --- a/__tests__/lib/edge-workers/project.js +++ b/__tests__/lib/edge-workers/project.js @@ -2,11 +2,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { readPrebuiltWorker, readWorkerSource } from '../../../src/lib/edge-workers'; import { CONVENTIONAL_PROJECT_DIR, discoverWorkers, findWorker, readProjectDescriptor, + readWorkerManifest, resolveProjectDir, writeProjectDescriptor, writeWorkerManifest, @@ -78,6 +80,39 @@ describe( 'edge-workers project', () => { fs.writeFileSync( path.join( project, 'edge-workers.json' ), '{}' ); expect( () => readProjectDescriptor( project ) ).toThrow( /missing a "type"/ ); } ); + + it( 'rejects an unsupported descriptor type', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), '{"type":"rust"}' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /invalid "type"/ ); + } ); + + it( 'rejects a null descriptor', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), 'null' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /invalid "type"/ ); + } ); + } ); + + describe( 'worker manifests', () => { + it( 'rejects a null manifest', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + fs.writeFileSync( path.join( worker, 'worker.json' ), 'null' ); + expect( () => readWorkerManifest( worker ) ).toThrow( /must be an object/ ); + } ); + + it( 'rejects an entry outside the worker directory', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + fs.writeFileSync( + path.join( worker, 'worker.json' ), + '{"name":"demo","entry":"../outside.ts"}' + ); + expect( () => readWorkerManifest( worker ) ).toThrow( /Worker entry must stay within/ ); + } ); } ); describe( 'discoverWorkers / findWorker', () => { @@ -96,6 +131,13 @@ describe( 'edge-workers project', () => { expect( discoverWorkers( project ) ).toEqual( [] ); } ); + it( 'rejects case-insensitive duplicate manifest names', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'first', { name: 'Headers' } ); + makeWorker( project, 'second', { name: 'headers' } ); + expect( () => discoverWorkers( project ) ).toThrow( /Duplicate worker name/ ); + } ); + it( 'finds a worker by name', () => { const project = makeProject( path.join( tmp, 'proj' ) ); makeWorker( project, 'alpha' ); @@ -108,4 +150,24 @@ describe( 'edge-workers project', () => { expect( () => findWorker( project, 'nope' ) ).toThrow( /Available workers: alpha/ ); } ); } ); + + describe( 'worker source and artifacts', () => { + it( 'throws when worker source cannot be read', () => { + const worker = { + dir: path.join( tmp, 'worker' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + expect( () => readWorkerSource( worker ) ).toThrow( /Could not read worker source/ ); + } ); + + it( 'rejects traversal in prebuilt artifact names', () => { + const worker = { + dir: path.join( tmp, 'worker' ), + manifest: { name: '../outside', entry: 'assembly/index.ts' }, + }; + expect( () => readPrebuiltWorker( path.join( tmp, 'project' ), worker ) ).toThrow( + /Invalid worker name/ + ); + } ); + } ); } ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 5d4e8b1cd..5a6ac31be 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -63,6 +63,37 @@ describe( 'edge-workers toolchains', () => { expect( () => tc.scaffoldWorker( project, 'dup' ) ).toThrow( /already exists/ ); } ); + it( 'rejects a worker name that escapes the workers directory', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.scaffoldWorker( project, '../outside' ) ).toThrow( /Invalid worker name/ ); + expect( fs.existsSync( path.join( tmp, 'outside' ) ) ).toBe( false ); + } ); + + it( 'rejects an entry that escapes the worker directory before compiling', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: '../outside.ts' }, + }; + expect( () => tc.compile( project, worker ) ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects a worker name that escapes the build directory', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + fs.mkdirSync( path.join( workerDir, 'assembly' ), { recursive: true } ); + fs.writeFileSync( path.join( workerDir, 'assembly', 'index.ts' ), 'export {};' ); + const worker = { + dir: workerDir, + manifest: { name: '../outside', entry: 'assembly/index.ts' }, + }; + expect( () => tc.compile( project, worker ) ).toThrow( /Invalid worker name/ ); + expect( fs.existsSync( path.join( tmp, 'outside.wasm' ) ) ).toBe( false ); + } ); + it( 'ensureAvailable throws when the compiler is missing', () => { const project = path.join( tmp, 'proj' ); tc.scaffoldProject( project ); diff --git a/__tests__/lib/edge-workers/validation.test.ts b/__tests__/lib/edge-workers/validation.test.ts new file mode 100644 index 000000000..a699ae252 --- /dev/null +++ b/__tests__/lib/edge-workers/validation.test.ts @@ -0,0 +1,170 @@ +import path from 'node:path'; + +import { + parseProjectDescriptor, + parseWorkerManifest, + resolvePathWithin, + validateWorkerName, +} from '../../../src/lib/edge-workers/validation'; + +describe( 'validateWorkerName', () => { + it.each( [ 'headers', 'security headers', 'worker.v2', 'A_worker-2' ] )( 'accepts %s', name => { + expect( validateWorkerName( name ) ).toBe( name ); + } ); + + it.each( [ + '', + '.', + '..', + '../outside', + 'folder/worker', + 'folder\\worker', + 'bad:name', + 'trailing.', + 'trailing ', + 'CON', + 'com1', + 'a'.repeat( 65 ), + ] )( 'rejects %s', name => { + expect( () => validateWorkerName( name ) ).toThrow( /Invalid worker name/ ); + } ); +} ); + +describe( 'resolvePathWithin', () => { + it( 'resolves below the root', () => { + expect( resolvePathWithin( '/project/workers/demo', 'assembly/index.ts', 'entry' ) ).toBe( + path.resolve( '/project/workers/demo/assembly/index.ts' ) + ); + } ); + + it( 'rejects traversal outside the root', () => { + expect( () => resolvePathWithin( '/project/workers/demo', '../secret.ts', 'entry' ) ).toThrow( + /must stay within/ + ); + } ); + + it( 'rejects absolute paths', () => { + expect( () => resolvePathWithin( '/project/workers/demo', '/tmp/secret.ts', 'entry' ) ).toThrow( + /relative path/ + ); + } ); +} ); + +describe( 'parseProjectDescriptor', () => { + it.each( [ null, [], 'assemblyscript' ] )( 'rejects non-object descriptors', value => { + expect( () => parseProjectDescriptor( value, '/project/edge-workers.json' ) ).toThrow( + /invalid "type" field/ + ); + } ); + + it( 'rejects unknown project types', () => { + expect( () => + parseProjectDescriptor( { type: 'rust' }, '/project/edge-workers.json' ) + ).toThrow( /invalid "type" field/ ); + } ); + + it( 'parses a supported descriptor', () => { + expect( + parseProjectDescriptor( + { type: 'assemblyscript', sdk: 'example@1.0.0' }, + '/project/edge-workers.json' + ) + ).toEqual( { type: 'assemblyscript', sdk: 'example@1.0.0' } ); + } ); +} ); + +describe( 'parseWorkerManifest', () => { + const file = '/project/workers/demo/worker.json'; + + it( 'rejects null manifests', () => { + expect( () => parseWorkerManifest( null, file ) ).toThrow( /must be an object/ ); + } ); + + it.each( [ { name: 'demo' }, { name: 'demo', entry: '' } ] )( + 'rejects manifests missing an entry', + value => { + expect( () => parseWorkerManifest( value, file ) ).toThrow( /missing an "entry" field/ ); + } + ); + + it( 'rejects entries escaping the worker directory', () => { + expect( () => + parseWorkerManifest( + { + name: 'demo', + entry: '../outside.ts', + }, + file + ) + ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects invalid worker names', () => { + expect( () => + parseWorkerManifest( + { + name: 'folder/worker', + entry: 'assembly/index.ts', + }, + file + ) + ).toThrow( /Invalid worker name/ ); + } ); + + it.each( [ + { operator: 'matches', value: '/news' }, + { operator: 'contains', value: '' }, + ] )( 'rejects invalid locations', location => { + expect( () => + parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts', location }, file ) + ).toThrow( /invalid location/ ); + } ); + + it( 'rejects invalid failure behavior', () => { + expect( () => + parseWorkerManifest( + { name: 'demo', entry: 'assembly/index.ts', on_failure: 'ignore' }, + file + ) + ).toThrow( /invalid "on_failure" field/ ); + } ); + + it( 'parses an absent location', () => { + expect( parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts' }, file ) ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + } ); + } ); + + it( 'parses a null location', () => { + expect( + parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts', location: null }, file ) + ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + location: null, + } ); + } ); + + it.each( [ 'continue', 'error' ] )( + 'parses valid location objects and %s failure behavior', + onFailure => { + expect( + parseWorkerManifest( + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/news' }, + on_failure: onFailure, + }, + file + ) + ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/news' }, + on_failure: onFailure, + } ); + } + ); +} ); diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts index a6d111cfd..02d29e7ff 100644 --- a/src/lib/edge-workers/index.ts +++ b/src/lib/edge-workers/index.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import UserError from '../user-error'; import { readProjectDescriptor } from './project'; import { getToolchain } from './toolchains'; +import { resolvePathWithin, validateWorkerName } from './validation'; import type { DiscoveredWorker } from './types'; @@ -16,6 +17,7 @@ export * from './types'; export * from './project'; export * from './location'; export { getToolchain } from './toolchains'; +export * from './validation'; /** Conventional output directory for compiled artifacts, relative to the project root. */ export const BUILD_DIR = 'build'; @@ -33,7 +35,12 @@ function encodeArtifact( wasmPath: string ): BuiltArtifact { /** 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` ); + const name = validateWorkerName( worker.manifest.name ); + const wasmPath = resolvePathWithin( + path.join( projectDir, BUILD_DIR ), + `${ name }.wasm`, + 'Worker build artifact' + ); if ( ! fs.existsSync( wasmPath ) ) { throw new UserError( `No compiled artifact found for "${ worker.manifest.name }" at "${ wasmPath }". ` + @@ -56,11 +63,11 @@ export function buildWorker( projectDir: string, worker: DiscoveredWorker ): Bui } /** 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 ); +export function readWorkerSource( worker: DiscoveredWorker ): string { + const entry = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); try { return fs.readFileSync( entry, 'utf8' ); } catch { - return undefined; + throw new UserError( `Could not read worker source at "${ entry }".` ); } } diff --git a/src/lib/edge-workers/project.ts b/src/lib/edge-workers/project.ts index cd07315d4..f666abf6f 100644 --- a/src/lib/edge-workers/project.ts +++ b/src/lib/edge-workers/project.ts @@ -17,6 +17,7 @@ import fs from 'node:fs'; import path from 'node:path'; import UserError from '../user-error'; +import { parseProjectDescriptor, parseWorkerManifest } from './validation'; import type { DiscoveredWorker, ProjectDescriptor, WorkerManifest } from './types'; @@ -90,18 +91,14 @@ export function readProjectDescriptor( projectDir: string ): ProjectDescriptor { throw new UserError( `Could not read project descriptor at "${ file }".` ); } - let parsed: ProjectDescriptor; + let parsed: unknown; try { - parsed = JSON.parse( raw ) as ProjectDescriptor; + parsed = JSON.parse( raw ) as unknown; } 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; + return parseProjectDescriptor( parsed, file ); } export function writeProjectDescriptor( projectDir: string, descriptor: ProjectDescriptor ): void { @@ -119,18 +116,14 @@ export function readWorkerManifest( workerDir: string ): WorkerManifest { throw new UserError( `Could not read worker manifest at "${ file }".` ); } - let parsed: WorkerManifest; + let parsed: unknown; try { - parsed = JSON.parse( raw ) as WorkerManifest; + parsed = JSON.parse( raw ) as unknown; } 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; + return parseWorkerManifest( parsed, file ); } export function writeWorkerManifest( workerDir: string, manifest: WorkerManifest ): void { @@ -148,6 +141,7 @@ export function discoverWorkers( projectDir: string ): DiscoveredWorker[] { const entries = fs.readdirSync( workersRoot, { withFileTypes: true } ); const workers: DiscoveredWorker[] = []; + const workersByName = new Map< string, DiscoveredWorker >(); for ( const entry of entries ) { if ( ! entry.isDirectory() ) { continue; @@ -158,7 +152,15 @@ export function discoverWorkers( projectDir: string ): DiscoveredWorker[] { continue; } - workers.push( { dir, manifest: readWorkerManifest( dir ) } ); + const worker = { dir, manifest: readWorkerManifest( dir ) }; + const normalizedName = worker.manifest.name.toLocaleLowerCase( 'en-US' ); + if ( workersByName.has( normalizedName ) ) { + throw new UserError( + `Duplicate worker name "${ worker.manifest.name }" found in this project.` + ); + } + workersByName.set( normalizedName, worker ); + workers.push( worker ); } return workers.sort( ( left, right ) => left.manifest.name.localeCompare( right.manifest.name ) ); diff --git a/src/lib/edge-workers/toolchains/assemblyscript/index.ts b/src/lib/edge-workers/toolchains/assemblyscript/index.ts index 3f251b708..8a94f7d3f 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/index.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -23,6 +23,7 @@ import { writeProjectDescriptor, writeWorkerManifest, } from '../../project'; +import { resolvePathWithin, validateWorkerName } from '../../validation'; import type { DiscoveredWorker } from '../../types'; import type { Toolchain } from '../index'; @@ -69,6 +70,7 @@ const toolchain: Toolchain = { }, scaffoldWorker( projectDir: string, name: string ): void { + validateWorkerName( name ); const workerDir = path.join( projectDir, WORKERS_DIR, name ); if ( fs.existsSync( workerDir ) ) { throw new UserError( `A worker directory already exists at "${ workerDir }".` ); @@ -90,13 +92,18 @@ const toolchain: Toolchain = { compile( projectDir: string, worker: DiscoveredWorker ): string { const asc = ascBinaryPath( projectDir ); - const entry = path.resolve( worker.dir, worker.manifest.entry || DEFAULT_ENTRY ); + const entry = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker 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` ); + const workerName = validateWorkerName( worker.manifest.name ); + const outFile = resolvePathWithin( + path.join( projectDir, BUILD_DIR ), + `${ workerName }.wasm`, + 'Worker build artifact' + ); fs.mkdirSync( path.dirname( outFile ), { recursive: true } ); const args = [ diff --git a/src/lib/edge-workers/types.ts b/src/lib/edge-workers/types.ts index c839ffd0f..c0a019eef 100644 --- a/src/lib/edge-workers/types.ts +++ b/src/lib/edge-workers/types.ts @@ -64,7 +64,7 @@ export interface WorkerManifest { name: string; /** Entry source file, relative to the worker directory. Defaults per toolchain. */ entry: string; - location?: EdgeWorkerLocation; + location?: EdgeWorkerLocation | null; on_failure?: EdgeWorkerOnFailure; } diff --git a/src/lib/edge-workers/validation.ts b/src/lib/edge-workers/validation.ts new file mode 100644 index 000000000..6c3406cfe --- /dev/null +++ b/src/lib/edge-workers/validation.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; + +import UserError from '../user-error'; +import { EDGE_WORKER_LOCATION_OPERATORS, SUPPORTED_EDGE_WORKER_TYPES } from './types'; + +import type { + EdgeWorkerLocation, + EdgeWorkerLocationOperator, + EdgeWorkerOnFailure, + EdgeWorkerType, + ProjectDescriptor, + WorkerManifest, +} from './types'; + +// eslint-disable-next-line security/detect-unsafe-regex +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; +// This range deliberately rejects every Windows-disallowed control character. +// eslint-disable-next-line no-control-regex +const INVALID_PATH_CHARACTER = /[<>:"/\\|?*\u0000-\u001f]/; + +export function validateWorkerName( name: unknown, label = 'worker name' ): string { + if ( + typeof name !== 'string' || + name.length === 0 || + name.length > 64 || + name === '.' || + name === '..' || + INVALID_PATH_CHARACTER.test( name ) || + /[. ]$/.test( name ) || + WINDOWS_RESERVED_NAME.test( name ) + ) { + throw new UserError( `Invalid ${ label } "${ String( name ) }".` ); + } + return name; +} + +export function resolvePathWithin( root: string, relativePath: string, label: string ): string { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + path.isAbsolute( relativePath ) + ) { + throw new UserError( `${ label } must be a non-empty relative path.` ); + } + const resolvedRoot = path.resolve( root ); + const resolvedPath = path.resolve( resolvedRoot, relativePath ); + const relative = path.relative( resolvedRoot, resolvedPath ); + if ( + relative === '..' || + relative.startsWith( `..${ path.sep }` ) || + path.isAbsolute( relative ) + ) { + throw new UserError( `${ label } must stay within "${ resolvedRoot }".` ); + } + return resolvedPath; +} + +function isPlainObject( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +export function parseProjectDescriptor( value: unknown, file: string ): ProjectDescriptor { + if ( ! isPlainObject( value ) ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "type" field.` ); + } + if ( value.type === undefined ) { + throw new UserError( `Project descriptor at "${ file }" is missing a "type" field.` ); + } + if ( ! SUPPORTED_EDGE_WORKER_TYPES.includes( value.type as EdgeWorkerType ) ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "type" field.` ); + } + if ( value.sdk !== undefined && typeof value.sdk !== 'string' ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "sdk" field.` ); + } + + const descriptor: ProjectDescriptor = { type: value.type as EdgeWorkerType }; + if ( value.sdk !== undefined ) { + descriptor.sdk = value.sdk; + } + return descriptor; +} + +function parseLocation( value: unknown, file: string ): EdgeWorkerLocation | null | undefined { + if ( value === undefined || value === null ) { + return value; + } + if ( ! isPlainObject( value ) ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location.` ); + } + if ( ! EDGE_WORKER_LOCATION_OPERATORS.includes( value.operator as EdgeWorkerLocationOperator ) ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location operator.` ); + } + if ( typeof value.value !== 'string' || value.value.length === 0 ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location value.` ); + } + return { operator: value.operator as EdgeWorkerLocationOperator, value: value.value }; +} + +export function parseWorkerManifest( value: unknown, file: string ): WorkerManifest { + if ( ! isPlainObject( value ) ) { + throw new UserError( `Worker manifest at "${ file }" must be an object.` ); + } + + const name = validateWorkerName( value.name, 'worker name' ); + if ( typeof value.entry !== 'string' || value.entry.length === 0 ) { + throw new UserError( `Worker manifest at "${ file }" is missing an "entry" field.` ); + } + resolvePathWithin( path.dirname( file ), value.entry, 'Worker entry' ); + if ( + value.on_failure !== undefined && + value.on_failure !== 'continue' && + value.on_failure !== 'error' + ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid "on_failure" field.` ); + } + + const manifest: WorkerManifest = { name, entry: value.entry }; + const location = parseLocation( value.location, file ); + if ( location !== undefined ) { + manifest.location = location; + } + if ( value.on_failure !== undefined ) { + manifest.on_failure = value.on_failure as EdgeWorkerOnFailure; + } + return manifest; +} From 2bc1165bdd5f2b802267ee339c6e4530d58242da Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 11:56:55 -0500 Subject: [PATCH 24/41] fix(edge-workers): refuse destructive project initialization --- __tests__/bin/vip-edge-workers-init.js | 87 +++++++++++++++++++ __tests__/lib/edge-workers/toolchains.js | 29 ++++++- .../toolchains/assemblyscript/index.ts | 27 +++--- 3 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 __tests__/bin/vip-edge-workers-init.js diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js new file mode 100644 index 000000000..7e6c08ef5 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-init.js @@ -0,0 +1,87 @@ +import { edgeWorkersInitCommand } from '../../src/bin/vip-edge-workers-init'; +import * as exit from '../../src/lib/cli/exit'; +import * as toolchains from '../../src/lib/edge-workers/toolchains'; +import * as tracker from '../../src/lib/tracker'; + +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/edge-workers/toolchains', () => ( { + getToolchain: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'edgeWorkersInitCommand()', () => { + const scaffoldProject = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + toolchains.getToolchain.mockReturnValue( { scaffoldProject } ); + } ); + + it( 'scaffolds the requested project and prints next steps', async () => { + await edgeWorkersInitCommand( [ './infra/edge' ], { type: 'assemblyscript' } ); + + expect( scaffoldProject ).toHaveBeenCalledWith( expect.stringMatching( /infra\/edge$/ ) ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( 1, 'edge_workers_init_command_execute', { + type: 'assemblyscript', + } ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( 2, 'edge_workers_init_command_success', { + type: 'assemblyscript', + } ); + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( 'Created a new assemblyscript edge-workers project' ) + ); + } ); + + it( 'reports an unsupported type without scaffolding or success telemetry', async () => { + await expect( edgeWorkersInitCommand( [], { type: 'rust' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( scaffoldProject ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_init_command_error', { + type: 'rust', + error: 'Unsupported type', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_init_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + } ); + + it( 'reports a scaffold collision without success telemetry or output', async () => { + scaffoldProject.mockImplementation( () => { + throw new Error( 'target is not empty' ); + } ); + + await expect( edgeWorkersInitCommand( [], { type: 'assemblyscript' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_init_command_error', { + type: 'assemblyscript', + error: 'target is not empty', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_init_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 5a6ac31be..85881640e 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -37,10 +37,33 @@ describe( 'edge-workers toolchains', () => { expect( pkg.devDependencies ).toHaveProperty( 'assemblyscript' ); } ); - it( 'refuses to scaffold over an existing project', () => { - const project = path.join( tmp, 'proj' ); + it( 'scaffolds a project in an existing empty directory', () => { + const project = path.join( tmp, 'empty-proj' ); + fs.mkdirSync( project ); + tc.scaffoldProject( project ); - expect( () => tc.scaffoldProject( project ) ).toThrow( /already exists/ ); + + expect( fs.existsSync( path.join( project, 'edge-workers.json' ) ) ).toBe( true ); + } ); + + it( 'refuses a non-empty target before replacing files', () => { + const project = path.join( tmp, 'existing-app' ); + fs.mkdirSync( project ); + fs.writeFileSync( path.join( project, 'package.json' ), '{"name":"customer-app"}\n' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not empty/ ); + expect( fs.readFileSync( path.join( project, 'package.json' ), 'utf8' ) ).toBe( + '{"name":"customer-app"}\n' + ); + expect( fs.existsSync( path.join( project, 'edge-workers.json' ) ) ).toBe( false ); + } ); + + it( 'refuses a file target', () => { + const project = path.join( tmp, 'not-a-directory' ); + fs.writeFileSync( project, 'customer content\n' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not a directory/ ); + expect( fs.readFileSync( project, 'utf8' ) ).toBe( 'customer content\n' ); } ); it( 'scaffolds a worker with a manifest and entry file', () => { diff --git a/src/lib/edge-workers/toolchains/assemblyscript/index.ts b/src/lib/edge-workers/toolchains/assemblyscript/index.ts index 8a94f7d3f..2a6d3554a 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/index.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -17,12 +17,7 @@ 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 { WORKERS_DIR, writeProjectDescriptor, writeWorkerManifest } from '../../project'; import { resolvePathWithin, validateWorkerName } from '../../validation'; import type { DiscoveredWorker } from '../../types'; @@ -34,6 +29,20 @@ function writeFileEnsuringDir( filePath: string, contents: string ): void { fs.writeFileSync( filePath, contents ); } +function assertScaffoldTargetAvailable( projectDir: string ): void { + if ( ! fs.existsSync( projectDir ) ) return; + if ( ! fs.statSync( projectDir ).isDirectory() ) { + throw new UserError( + `Cannot create an edge-workers project at "${ projectDir }": target is not a directory.` + ); + } + if ( fs.readdirSync( projectDir ).length > 0 ) { + throw new UserError( + `Cannot create an edge-workers project at "${ projectDir }": target is not empty.` + ); + } +} + function ascBinaryPath( projectDir: string ): string { const binName = process.platform === 'win32' ? 'asc.cmd' : 'asc'; return path.join( projectDir, 'node_modules', '.bin', binName ); @@ -43,11 +52,7 @@ 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 }).` - ); - } + assertScaffoldTargetAvailable( projectDir ); // Every write below ensures its own parent directory, so no standalone // mkdir is needed up front. From 035a32fe78bf8e3faf0a06fe30c5118736f44397 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 12:04:39 -0500 Subject: [PATCH 25/41] fix(edge-workers): fail closed on API results --- __tests__/bin/vip-edge-workers-deploy.js | 35 +++++++++++++++++++ __tests__/bin/vip-edge-workers-validate.js | 19 ++++++++++ __tests__/lib/api-edge-workers.test.ts | 40 ++++++++++++++++++++++ src/lib/api/edge-workers.ts | 33 +++++++++++------- 4 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 __tests__/lib/api-edge-workers.test.ts diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index c33856f08..41fa68d17 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -3,6 +3,7 @@ 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'; +import * as tracker from '../../src/lib/tracker'; jest.spyOn( console, 'log' ).mockImplementation( () => {} ); jest.spyOn( exit, 'withError' ).mockImplementation( () => { @@ -102,6 +103,40 @@ describe( 'edgeWorkersDeployCommand()', () => { expect( api.createEdgeWorker ).not.toHaveBeenCalled(); } ); + it( 'does not report deployment success when create rejects', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockRejectedValue( new Error( 'createEdgeWorker returned no result.' ) ); + + await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( '✓ created' ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + } ); + + it( 'does not report deployment success when update rejects', async () => { + api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); + api.updateEdgeWorker.mockRejectedValue( new Error( 'updateEdgeWorker returned no result.' ) ); + + await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( '✓ updated' ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + } ); + it( 'sends the manifest location on update, clearing it when absent', async () => { const location = { operator: 'starts_with', value: '/api/' }; project.findWorker.mockReturnValue( { diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js index f6949f102..2c3c3c611 100644 --- a/__tests__/bin/vip-edge-workers-validate.js +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -3,6 +3,7 @@ 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'; +import * as tracker from '../../src/lib/tracker'; jest.spyOn( console, 'log' ).mockImplementation( () => {} ); jest.spyOn( exit, 'withError' ).mockImplementation( () => { @@ -94,6 +95,24 @@ describe( 'edgeWorkersValidateCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( 'failed validation' ) ); } ); + it( 'does not report validation success when the API rejects', async () => { + api.validateEdgeWorker.mockRejectedValue( + new Error( 'validateEdgeWorker returned no result.' ) + ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( 'is valid' ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_success', + expect.anything() + ); + } ); + it( 'validates every worker with --all', async () => { project.discoverWorkers.mockReturnValue( [ worker, diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts new file mode 100644 index 000000000..04a2474cc --- /dev/null +++ b/__tests__/lib/api-edge-workers.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import * as apiModule from '../../src/lib/api'; +import { + createEdgeWorker, + deleteEdgeWorker, + setEdgeWorkerActive, + updateEdgeWorker, + validateEdgeWorker, +} from '../../src/lib/api/edge-workers'; + +jest.mock( '../../src/lib/api' ); + +const mockMutate = + jest.fn< ( options: unknown ) => Promise< { data?: Record< string, unknown > } > >(); +const mockedAPI = apiModule as unknown as { default: jest.Mock }; + +beforeEach( () => { + mockMutate.mockReset(); + mockedAPI.default = jest.fn().mockReturnValue( { mutate: mockMutate } ); +} ); + +describe( 'edge worker mutation result contracts', () => { + it.each( [ + [ 'validateEdgeWorker', () => validateEdgeWorker( 3, 'V0FTTQ==' ) ], + [ 'createEdgeWorker', () => createEdgeWorker( 3, { name: 'demo', wasmBinary: 'V0FTTQ==' } ) ], + [ 'updateEdgeWorker', () => updateEdgeWorker( 3, 7, { wasmBinary: 'V0FTTQ==' } ) ], + [ 'setEdgeWorkerActive', () => setEdgeWorkerActive( 3, 7, true ) ], + ] )( 'rejects a missing %s payload', async ( operation, call ) => { + mockMutate.mockResolvedValueOnce( { data: { [ operation ]: null } } ); + + await expect( call() ).rejects.toThrow( `${ operation } returned no result` ); + } ); + + it( 'rejects a false delete result', async () => { + mockMutate.mockResolvedValueOnce( { data: { deleteEdgeWorker: false } } ); + + await expect( deleteEdgeWorker( 3, 7 ) ).rejects.toThrow( /did not confirm deletion/ ); + } ); +} ); diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts index 17850ef4d..f4d8e63be 100644 --- a/src/lib/api/edge-workers.ts +++ b/src/lib/api/edge-workers.ts @@ -13,6 +13,7 @@ import gql from 'graphql-tag'; import API from '../../lib/api'; +import UserError from '../user-error'; import type { EdgeWorker, @@ -66,6 +67,13 @@ function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: numb return env?.edgeWorkers ?? []; } +function requireMutationPayload< T >( operation: string, value: T | null | undefined ): T { + if ( value === null || value === undefined ) { + throw new UserError( `${ operation } returned no result.` ); + } + return value; +} + /** List the edge workers deployed to an environment (without source/wasm). */ export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { const api = API(); @@ -143,7 +151,7 @@ export interface EdgeWorkerWriteInput { export async function createEdgeWorker( envId: number, input: EdgeWorkerWriteInput & { name: string; wasmBinary: string } -): Promise< EdgeWorker | null > { +): Promise< EdgeWorker > { const api = API(); const response = await api.mutate< { createEdgeWorker: EdgeWorker | null } >( { mutation: gql` @@ -156,14 +164,14 @@ export async function createEdgeWorker( variables: { input: { environmentId: envId, ...input } }, } ); - return response.data?.createEdgeWorker ?? null; + return requireMutationPayload( 'createEdgeWorker', response.data?.createEdgeWorker ); } export async function updateEdgeWorker( envId: number, edgeWorkerId: number, input: EdgeWorkerWriteInput -): Promise< EdgeWorker | null > { +): Promise< EdgeWorker > { const api = API(); const response = await api.mutate< { updateEdgeWorker: EdgeWorker | null } >( { mutation: gql` @@ -176,7 +184,7 @@ export async function updateEdgeWorker( variables: { input: { environmentId: envId, edgeWorkerId, ...input } }, } ); - return response.data?.updateEdgeWorker ?? null; + return requireMutationPayload( 'updateEdgeWorker', response.data?.updateEdgeWorker ); } export interface EdgeWorkerValidationResult { @@ -187,13 +195,12 @@ export interface EdgeWorkerValidationResult { /** * 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. + * to fail fast before the real create/update upload. */ export async function validateEdgeWorker( envId: number, wasmBinary: string -): Promise< EdgeWorkerValidationResult | null > { +): Promise< EdgeWorkerValidationResult > { const api = API(); const response = await api.mutate< { validateEdgeWorker: EdgeWorkerValidationResult | null; @@ -210,14 +217,14 @@ export async function validateEdgeWorker( variables: { input: { environmentId: envId, wasmBinary } }, } ); - return response.data?.validateEdgeWorker ?? null; + return requireMutationPayload( 'validateEdgeWorker', response.data?.validateEdgeWorker ); } export async function setEdgeWorkerActive( envId: number, edgeWorkerId: number, active: boolean -): Promise< EdgeWorker | null > { +): Promise< EdgeWorker > { const api = API(); const response = await api.mutate< { setEdgeWorkerActive: EdgeWorker | null } >( { mutation: gql` @@ -230,10 +237,10 @@ export async function setEdgeWorkerActive( variables: { input: { environmentId: envId, edgeWorkerId, active } }, } ); - return response.data?.setEdgeWorkerActive ?? null; + return requireMutationPayload( 'setEdgeWorkerActive', response.data?.setEdgeWorkerActive ); } -export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< boolean > { +export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< void > { const api = API(); const response = await api.mutate< { deleteEdgeWorker: boolean | null } >( { mutation: gql` @@ -244,5 +251,7 @@ export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): P variables: { input: { environmentId: envId, edgeWorkerId } }, } ); - return response.data?.deleteEdgeWorker ?? false; + if ( response.data?.deleteEdgeWorker !== true ) { + throw new UserError( 'deleteEdgeWorker did not confirm deletion.' ); + } } From 4493a8ffa2d22f5f2dbe6297e13e54ca8c3b8e2a Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 12:20:45 -0500 Subject: [PATCH 26/41] feat(edge-workers): preview deployments before apply --- __tests__/bin/vip-edge-workers-deploy.js | 300 +++++++------- __tests__/lib/edge-workers/deployment.test.ts | 377 ++++++++++++++++++ src/bin/vip-edge-workers-deploy.js | 115 +++--- src/lib/edge-workers/deployment.ts | 235 +++++++++++ 4 files changed, 817 insertions(+), 210 deletions(-) create mode 100644 __tests__/lib/edge-workers/deployment.test.ts create mode 100644 src/lib/edge-workers/deployment.ts diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index 41fa68d17..3b9a28e8f 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -1,7 +1,7 @@ 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 format from '../../src/lib/cli/format'; +import * as deployment from '../../src/lib/edge-workers/deployment'; import * as project from '../../src/lib/edge-workers/project'; import * as tracker from '../../src/lib/tracker'; @@ -19,19 +19,19 @@ jest.mock( '../../src/lib/cli/command', () => { 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/cli/format', () => ( { + formatData: jest.fn(), } ) ); -jest.mock( '../../src/lib/edge-workers', () => ( { - buildWorker: jest.fn(), - readPrebuiltWorker: jest.fn(), - readWorkerSource: jest.fn(), -} ) ); +jest.mock( '../../src/lib/edge-workers/deployment', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/deployment' ); + return { + ...actual, + prepareEdgeWorkerDeploymentPlan: jest.fn(), + deploymentPlanRows: jest.fn(), + applyEdgeWorkerDeploymentPlan: jest.fn(), + }; +} ); jest.mock( '../../src/lib/edge-workers/project', () => ( { resolveProjectDir: jest.fn(), @@ -47,179 +47,191 @@ const opts = { app: { id: 1 }, env: { id: 3 }, skipBuild: true, + skipValidate: false, + skipSource: false, }; -const worker = { - dir: '/proj/workers/my-worker', - manifest: { name: 'my-worker', entry: 'assembly/index.ts', on_failure: 'continue' }, -}; +const worker = name => ( { + dir: `/project/workers/${ name }`, + manifest: { name, entry: 'assembly/index.ts' }, +} ); + +const planItem = ( name, action = 'create' ) => ( { + action, + worker: worker( name ), + existing: action === 'update' ? { id: 42, name, active: true } : null, + artifact: { + wasmPath: `/project/build/${ name }.wasm`, + base64: `binary-${ name }`, + sizeBytes: name.length, + }, + validation: 'passed', + phases: [ 'client_response' ], + input: { name, wasmBinary: `binary-${ name }` }, + currentLocation: null, + proposedLocation: null, + sourceMode: 'store', +} ); 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(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.findWorker.mockImplementation( ( _projectDir, name ) => worker( name ) ); + project.discoverWorkers.mockReturnValue( [ worker( 'alpha' ), worker( 'beta' ) ] ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ planItem( 'headers' ) ] ); + deployment.deploymentPlanRows.mockReturnValue( [ { worker: 'headers' } ] ); + format.formatData.mockReturnValue( 'PLAN TABLE' ); + deployment.applyEdgeWorkerDeploymentPlan.mockResolvedValue(); } ); - 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, + it( 'prepares the selected worker with the command options', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], opts ); + + expect( project.findWorker ).toHaveBeenCalledWith( '/project', 'headers' ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( { + appId: 1, + envId: 3, + projectDir: '/project', + workers: [ + expect.objectContaining( { manifest: expect.objectContaining( { name: 'headers' } ) } ), + ], + skipBuild: true, + skipValidate: false, + skipSource: false, } ); - expect( api.createEdgeWorker ).not.toHaveBeenCalled(); } ); - it( 'does not report deployment success when create rejects', async () => { - api.findEdgeWorkerByName.mockResolvedValue( null ); - api.createEdgeWorker.mockRejectedValue( new Error( 'createEdgeWorker returned no result.' ) ); - - await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( - 'EXIT_WITH_ERROR' - ); - - expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( '✓ created' ) ); - expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( - 1, - 3, - 'edge_workers_deploy_command_success', - expect.anything() + it( 'prepares every discovered worker for --all', async () => { + await edgeWorkersDeployCommand( [], { ...opts, all: true } ); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( project.findWorker ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( + expect.objectContaining( { + workers: [ + expect.objectContaining( { manifest: expect.objectContaining( { name: 'alpha' } ) } ), + expect.objectContaining( { manifest: expect.objectContaining( { name: 'beta' } ) } ), + ], + } ) ); } ); - it( 'does not report deployment success when update rejects', async () => { - api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); - api.updateEdgeWorker.mockRejectedValue( new Error( 'updateEdgeWorker returned no result.' ) ); - - await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + it( 'rejects a worker name combined with --all before project resolution', async () => { + await expect( edgeWorkersDeployCommand( [ 'headers' ], { ...opts, all: true } ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); - expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( '✓ updated' ) ); - expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( - 1, - 3, - 'edge_workers_deploy_command_success', - expect.anything() + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to deploy edge worker: Supply either a worker name or --all, not both.' ); + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).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 }, + it( 'prints the complete plan before applying the same items', async () => { + const plan = [ planItem( 'alpha' ), planItem( 'beta', 'update' ) ]; + const rows = [ { worker: 'alpha' }, { worker: 'beta' } ]; + const order = []; + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( plan ); + deployment.deploymentPlanRows.mockImplementation( received => { + expect( received ).toBe( plan ); + order.push( 'rows' ); + return rows; + } ); + format.formatData.mockImplementation( ( received, outputFormat ) => { + expect( received ).toBe( rows ); + expect( outputFormat ).toBe( 'table' ); + order.push( 'format' ); + return 'PLAN TABLE'; + } ); + console.log.mockImplementationOnce( value => { + expect( value ).toBe( 'PLAN TABLE' ); + order.push( 'preview' ); + } ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( async ( envId, received ) => { + expect( envId ).toBe( 3 ); + expect( received ).toBe( plan ); + order.push( 'apply' ); } ); - api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); - api.updateEdgeWorker.mockResolvedValue( { id: 42, phases: [ 'response' ] } ); - await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + await edgeWorkersDeployCommand( [], { ...opts, all: true } ); - expect( api.updateEdgeWorker ).toHaveBeenCalledWith( - 3, - 42, - expect.objectContaining( { location } ) - ); + expect( order ).toEqual( [ 'rows', 'format', 'preview', 'apply' ] ); } ); - 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( 'prints success output only from the applied callback and then tracks success', async () => { + const item = planItem( 'headers', 'update' ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + } ); + } ); - } ); - - 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 } ); + await edgeWorkersDeployCommand( [ 'headers' ], opts ); - expect( lib.readWorkerSource ).not.toHaveBeenCalled(); - expect( api.createEdgeWorker ).toHaveBeenCalledWith( + expect( console.log ).toHaveBeenCalledWith( + '✓ updated "headers" (7 bytes, phases: client_response)' + ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, 3, - expect.not.objectContaining( { source: expect.anything() } ) + 'edge_workers_deploy_command_success', + { count: 1 } ); + const outputOrder = console.log.mock.invocationCallOrder.at( -1 ); + const telemetryOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( outputOrder ).toBeLessThan( telemetryOrder ); } ); - 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' ], - } ); + it( 'does not preview or apply when preparation fails', async () => { + deployment.prepareEdgeWorkerDeploymentPlan.mockRejectedValue( + new Error( 'worker "beta" failed validation' ) + ); - await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + await expect( edgeWorkersDeployCommand( [], { ...opts, all: true } ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); - expect( api.createEdgeWorker ).not.toHaveBeenCalled(); - expect( api.updateEdgeWorker ).not.toHaveBeenCalled(); + + expect( deployment.deploymentPlanRows ).not.toHaveBeenCalled(); + expect( format.formatData ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); expect( exit.withError ).toHaveBeenCalledWith( - expect.stringContaining( 'missing alloc export' ) + 'Failed to deploy edge worker: worker "beta" failed validation' ); } ); - 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 } ); + it( 'reports exact progress and the original cause after a partial failure', async () => { + const cause = new Error( 'request timed out' ); + deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( + new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause ) + ); - expect( api.validateEdgeWorker ).not.toHaveBeenCalled(); - expect( api.createEdgeWorker ).toHaveBeenCalled(); - } ); + await expect( edgeWorkersDeployCommand( [], { ...opts, all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); - 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' ) + 'Deployment stopped at "beta". Applied: alpha. Not applied: gamma. Cause: request timed out' + ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() ); } ); } ); diff --git a/__tests__/lib/edge-workers/deployment.test.ts b/__tests__/lib/edge-workers/deployment.test.ts new file mode 100644 index 000000000..b7b85271e --- /dev/null +++ b/__tests__/lib/edge-workers/deployment.test.ts @@ -0,0 +1,377 @@ +import { + createEdgeWorker, + listEdgeWorkers, + updateEdgeWorker, + validateEdgeWorker, +} from '../../../src/lib/api/edge-workers'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from '../../../src/lib/edge-workers'; +import { + applyEdgeWorkerDeploymentPlan, + DeploymentApplyError, + deploymentPlanRows, + prepareEdgeWorkerDeploymentPlan, +} from '../../../src/lib/edge-workers/deployment'; + +import type { EdgeWorker } from '../../../src/lib/edge-workers/types'; + +jest.mock( '../../../src/lib/api/edge-workers', () => ( { + createEdgeWorker: jest.fn(), + listEdgeWorkers: jest.fn(), + updateEdgeWorker: jest.fn(), + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), + readWorkerSource: jest.fn(), +} ) ); + +const location = { operator: 'starts_with' as const, value: '/api/' }; +const replacementLocation = { operator: 'equals' as const, value: '/checkout' }; + +const remoteWorker = ( overrides: Partial< EdgeWorker > = {} ): EdgeWorker => ( { + id: 42, + name: 'headers', + location, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-18T00:00:00Z', + updatedAt: '2026-08-18T00:00:00Z', + ...overrides, +} ); + +const localWorker = ( manifest: Record< string, unknown > = {} ) => ( { + dir: '/project/workers/headers', + manifest: { + name: 'headers', + entry: 'assembly/index.ts', + on_failure: 'continue' as const, + ...manifest, + }, +} ); + +const options = ( workers = [ localWorker() ] ) => ( { + appId: 1, + envId: 3, + projectDir: '/project', + workers, + skipBuild: false, + skipValidate: false, + skipSource: false, +} ); + +describe( 'prepareEdgeWorkerDeploymentPlan()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + jest.mocked( buildWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + jest.mocked( readWorkerSource ).mockReturnValue( 'source code' ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'preserves an existing location when the update manifest omits location', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( options() ); + + expect( plan[ 0 ].action ).toBe( 'update' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'location' ); + expect( plan[ 0 ].currentLocation ).toEqual( location ); + expect( plan[ 0 ].proposedLocation ).toEqual( location ); + } ); + + it( 'clears an existing location when the update manifest sets location to null', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: null } ) ] ) + ); + + expect( plan[ 0 ].input ).toEqual( + expect.objectContaining( { + location: null, + } ) + ); + expect( plan[ 0 ].currentLocation ).toEqual( location ); + expect( plan[ 0 ].proposedLocation ).toBeNull(); + } ); + + it( 'replaces an existing location when the update manifest supplies an object', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: replacementLocation } ) ] ) + ); + + expect( plan[ 0 ].input ).toEqual( + expect.objectContaining( { + location: replacementLocation, + } ) + ); + expect( plan[ 0 ].proposedLocation ).toEqual( replacementLocation ); + } ); + + it.each( [ + [ 'absent', {} ], + [ 'null', { location: null } ], + ] )( 'treats a %s location as all requests on create', async ( _label, manifest ) => { + const plan = await prepareEdgeWorkerDeploymentPlan( options( [ localWorker( manifest ) ] ) ); + + expect( plan[ 0 ].action ).toBe( 'create' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'location' ); + expect( plan[ 0 ].currentLocation ).toBeNull(); + expect( plan[ 0 ].proposedLocation ).toBeNull(); + } ); + + it( 'omits source on create when source storage is skipped', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( plan[ 0 ].sourceMode ).toBe( 'omit' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'source' ); + } ); + + it( 'preserves stored source on update when source storage is skipped', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( plan[ 0 ].sourceMode ).toBe( 'preserve' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'source' ); + } ); + + it( 'aborts preparation when source cannot be read', async () => { + jest.mocked( readWorkerSource ).mockImplementation( () => { + throw new Error( 'Could not read worker source.' ); + } ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + 'Could not read worker source.' + ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'requires explicit successful validation and never applies during preparation', async () => { + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + 'worker "headers" failed validation: missing alloc export' + ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'loads remote workers once and prepares every selected worker', async () => { + const secondWorker = localWorker( { name: 'redirects' } ); + jest + .mocked( buildWorker ) + .mockReturnValueOnce( { + wasmPath: '/project/build/headers.wasm', + base64: 'SEVBREVSUw==', + sizeBytes: 7, + } ) + .mockReturnValueOnce( { + wasmPath: '/project/build/redirects.wasm', + base64: 'UkVESVJFQ1RT', + sizeBytes: 9, + } ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker(), secondWorker ] ) + ); + + expect( listEdgeWorkers ).toHaveBeenCalledTimes( 1 ); + expect( listEdgeWorkers ).toHaveBeenCalledWith( 1, 3 ); + expect( plan.map( item => item.worker.manifest.name ) ).toEqual( [ 'headers', 'redirects' ] ); + expect( buildWorker ).toHaveBeenCalledTimes( 2 ); + expect( validateEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( readWorkerSource ).toHaveBeenCalledTimes( 2 ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'uses prebuilt artifacts and records skipped validation when requested', async () => { + jest.mocked( readPrebuiltWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipBuild: true, + skipValidate: true, + } ); + + expect( buildWorker ).not.toHaveBeenCalled(); + expect( readPrebuiltWorker ).toHaveBeenCalled(); + expect( validateEdgeWorker ).not.toHaveBeenCalled(); + expect( plan[ 0 ].validation ).toBe( 'skipped' ); + expect( plan[ 0 ].phases ).toEqual( [] ); + } ); +} ); + +describe( 'deploymentPlanRows()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + jest.mocked( buildWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 128, + } ); + jest.mocked( readWorkerSource ).mockReturnValue( 'source code' ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'renders a stable row from the exact prepared update item', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: null } ) ] ) + ); + + expect( deploymentPlanRows( plan ) ).toEqual( [ + { + worker: 'headers', + action: 'update', + active: 'yes', + current_scope: 'starts_with "/api/"', + proposed_scope: 'all requests', + validation: 'passed', + phases: 'client_response', + bytes: '128', + source: 'store', + }, + ] ); + } ); + + it( 'renders create defaults and all validated phases without mutating the plan', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_request', 'origin_request' ], + errors: [], + } ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + const itemBeforePreview = { ...plan[ 0 ] }; + + expect( deploymentPlanRows( plan ) ).toEqual( [ + { + worker: 'headers', + action: 'create', + active: 'no', + current_scope: 'all requests', + proposed_scope: 'all requests', + validation: 'passed', + phases: 'client_request, origin_request', + bytes: '128', + source: 'omit', + }, + ] ); + expect( plan[ 0 ] ).toEqual( itemBeforePreview ); + } ); +} ); + +describe( 'applyEdgeWorkerDeploymentPlan()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( buildWorker ).mockImplementation( ( _projectDir, worker ) => ( { + wasmPath: `/project/build/${ worker.manifest.name }.wasm`, + base64: `binary-${ worker.manifest.name }`, + sizeBytes: worker.manifest.name.length, + } ) ); + jest + .mocked( readWorkerSource ) + .mockImplementation( worker => `source-${ worker.manifest.name }` ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'applies create and update items sequentially and reports each resolved result', async () => { + const redirects = localWorker( { name: 'redirects', location: null } ); + const existingRedirects = remoteWorker( { id: 84, name: 'redirects', active: false } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existingRedirects ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( options( [ localWorker(), redirects ] ) ); + const created = remoteWorker( { id: 7, location: null, active: false } ); + const updated = remoteWorker( { id: 84, name: 'redirects', location: null, active: false } ); + const order: string[] = []; + jest.mocked( createEdgeWorker ).mockImplementation( () => { + order.push( 'create' ); + return Promise.resolve( created ); + } ); + jest.mocked( updateEdgeWorker ).mockImplementation( () => { + order.push( 'update' ); + return Promise.resolve( updated ); + } ); + const planBeforeApply = plan.map( item => ( { ...item, input: { ...item.input } } ) ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, ( item, result ) => { + order.push( `applied:${ item.worker.manifest.name }:${ result.id }` ); + } ); + + expect( createEdgeWorker ).toHaveBeenCalledWith( 3, plan[ 0 ].input ); + expect( updateEdgeWorker ).toHaveBeenCalledWith( 3, 84, plan[ 1 ].input ); + expect( order ).toEqual( [ 'create', 'applied:headers:7', 'update', 'applied:redirects:84' ] ); + expect( plan ).toEqual( planBeforeApply ); + } ); + + it( 'reports applied, failed, and unapplied names without retry or rollback', async () => { + const workers = [ 'alpha', 'beta', 'gamma' ].map( name => localWorker( { name } ) ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( options( workers ) ); + const cause = new Error( 'request timed out' ); + jest + .mocked( createEdgeWorker ) + .mockResolvedValueOnce( remoteWorker( { id: 1, name: 'alpha', active: false } ) ) + .mockRejectedValueOnce( cause ); + const onApplied = jest.fn(); + + const application = applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + await expect( application ).rejects.toBeInstanceOf( DeploymentApplyError ); + await expect( application ).rejects.toMatchObject( { + appliedNames: [ 'alpha' ], + failedName: 'beta', + unappliedNames: [ 'gamma' ], + cause, + } ); + expect( createEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + expect( onApplied ).toHaveBeenCalledTimes( 1 ); + expect( onApplied ).toHaveBeenCalledWith( + plan[ 0 ], + expect.objectContaining( { name: 'alpha' } ) + ); + } ); +} ); diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js index fe511dfd2..34522f61c 100644 --- a/src/bin/vip-edge-workers-deploy.js +++ b/src/bin/vip-edge-workers-deploy.js @@ -1,15 +1,15 @@ #!/usr/bin/env node -import { - appQuery, - createEdgeWorker, - findEdgeWorkerByName, - updateEdgeWorker, - validateEdgeWorker, -} from '../lib/api/edge-workers'; +import { appQuery } 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 { formatData } from '../lib/cli/format'; +import { + applyEdgeWorkerDeploymentPlan, + DeploymentApplyError, + deploymentPlanRows, + prepareEdgeWorkerDeploymentPlan, +} from '../lib/edge-workers/deployment'; import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; import { trackEventWithEnv } from '../lib/tracker'; @@ -30,51 +30,17 @@ const examples = [ }, ]; -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 }; - } +function errorMessage( error ) { + return error instanceof Error ? error.message : String( error ); +} - const result = await createEdgeWorker( env.id, { - name, - ...input, - ...( location ? { location } : {} ), - } ); - return { action: 'created', worker: result, sizeBytes: artifact.sizeBytes }; +function partialFailureMessage( error ) { + return ( + `Deployment stopped at "${ error.failedName }". ` + + `Applied: ${ error.appliedNames.join( ', ' ) || 'none' }. ` + + `Not applied: ${ error.unappliedNames.join( ', ' ) || 'none' }. ` + + `Cause: ${ errorMessage( error.cause ) }` + ); } export async function edgeWorkersDeployCommand( args = [], opt = {} ) { @@ -87,41 +53,58 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { } ); try { + if ( name && opt.all ) { + throw new Error( 'Supply either a worker name or --all, not both.' ); + } + 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.' ); + throw new Error( '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`.' ); + throw new Error( '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' }` : ''; + const plan = await prepareEdgeWorkerDeploymentPlan( { + appId: app.id, + envId: env.id, + projectDir, + workers, + skipBuild: Boolean( opt.skipBuild ), + skipValidate: Boolean( opt.skipValidate ), + skipSource: Boolean( opt.skipSource ), + } ); + + console.log( formatData( deploymentPlanRows( plan ), 'table' ) ); + + await applyEdgeWorkerDeploymentPlan( env.id, plan, ( item, deployed ) => { + const action = item.action === 'create' ? 'created' : 'updated'; + const phasesNote = `, phases: ${ deployed.phases.join( ', ' ) || 'none' }`; console.log( - `✓ ${ action } "${ worker.manifest.name }" (${ sizeBytes } bytes${ phasesNote })` + `✓ ${ action } "${ item.worker.manifest.name }" ` + + `(${ item.artifact.sizeBytes } bytes${ phasesNote })` ); - } + } ); await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_success', { - count: workers.length, + count: plan.length, } ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { name, - error: err.message, + error: errorMessage( err ), } ); - exit.withError( `Failed to deploy edge worker: ${ err.message }` ); + exit.withError( + err instanceof DeploymentApplyError + ? partialFailureMessage( err ) + : `Failed to deploy edge worker: ${ errorMessage( err ) }` + ); } } diff --git a/src/lib/edge-workers/deployment.ts b/src/lib/edge-workers/deployment.ts new file mode 100644 index 000000000..8bf3a25a9 --- /dev/null +++ b/src/lib/edge-workers/deployment.ts @@ -0,0 +1,235 @@ +import { + createEdgeWorker, + listEdgeWorkers, + updateEdgeWorker, + validateEdgeWorker, +} from '../api/edge-workers'; +import UserError from '../user-error'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from './index'; + +import type { DiscoveredWorker, EdgeWorker, EdgeWorkerLocation, EdgeWorkerPhase } from './types'; +import type { EdgeWorkerWriteInput } from '../api/edge-workers'; + +export interface EdgeWorkerDeploymentPlanItem { + action: 'create' | 'update'; + worker: DiscoveredWorker; + existing: EdgeWorker | null; + artifact: { wasmPath: string; base64: string; sizeBytes: number }; + validation: 'passed' | 'skipped'; + phases: EdgeWorkerPhase[]; + input: EdgeWorkerWriteInput & { name: string; wasmBinary: string }; + currentLocation: EdgeWorkerLocation | null; + proposedLocation: EdgeWorkerLocation | null; + sourceMode: 'store' | 'omit' | 'preserve'; +} + +export interface EdgeWorkerDeploymentPlanOptions { + appId: number; + envId: number; + projectDir: string; + workers: readonly DiscoveredWorker[]; + skipBuild: boolean; + skipValidate: boolean; + skipSource: boolean; +} + +export type EdgeWorkerAppliedCallback = ( + item: EdgeWorkerDeploymentPlanItem, + result: EdgeWorker +) => void | Promise< void >; + +export class DeploymentApplyError extends Error { + public readonly appliedNames: string[]; + public readonly failedName: string; + public readonly unappliedNames: string[]; + + constructor( + appliedNames: string[], + failedName: string, + unappliedNames: string[], + cause: unknown + ) { + super( `Failed to apply edge worker "${ failedName }".`, { cause } ); + this.name = 'DeploymentApplyError'; + this.appliedNames = appliedNames; + this.failedName = failedName; + this.unappliedNames = unappliedNames; + } +} + +function prepareArtifact( options: EdgeWorkerDeploymentPlanOptions, worker: DiscoveredWorker ) { + if ( options.skipBuild ) { + return readPrebuiltWorker( options.projectDir, worker ); + } + return buildWorker( options.projectDir, worker ); +} + +async function prepareValidation( + options: EdgeWorkerDeploymentPlanOptions, + worker: DiscoveredWorker, + artifact: EdgeWorkerDeploymentPlanItem[ 'artifact' ] +): Promise< Pick< EdgeWorkerDeploymentPlanItem, 'validation' | 'phases' > > { + if ( options.skipValidate ) { + return { validation: 'skipped', phases: [] }; + } + + const result = await validateEdgeWorker( options.envId, artifact.base64 ); + if ( result.valid !== true ) { + const errors = result.errors.join( '; ' ) || 'unknown error'; + throw new UserError( `worker "${ worker.manifest.name }" failed validation: ${ errors }` ); + } + + return { validation: 'passed', phases: result.phases }; +} + +function proposedLocationFor( + worker: DiscoveredWorker, + existing: EdgeWorker | null, + hasLocation: boolean, + currentLocation: EdgeWorkerLocation | null +): EdgeWorkerLocation | null { + if ( ! existing ) { + return worker.manifest.location ?? null; + } + if ( ! hasLocation ) { + return currentLocation; + } + return worker.manifest.location ?? null; +} + +function prepareInput( + worker: DiscoveredWorker, + existing: EdgeWorker | null, + artifact: EdgeWorkerDeploymentPlanItem[ 'artifact' ], + source: string | undefined, + hasLocation: boolean +): EdgeWorkerDeploymentPlanItem[ 'input' ] { + const input: EdgeWorkerDeploymentPlanItem[ 'input' ] = { + name: worker.manifest.name, + wasmBinary: artifact.base64, + }; + if ( worker.manifest.on_failure ) { + input.onFailure = worker.manifest.on_failure; + } + if ( source ) { + input.source = source; + } + if ( existing && hasLocation ) { + input.location = worker.manifest.location ?? null; + } else if ( ! existing && worker.manifest.location ) { + input.location = worker.manifest.location; + } + return input; +} + +function sourceModeFor( + skipSource: boolean, + existing: EdgeWorker | null +): EdgeWorkerDeploymentPlanItem[ 'sourceMode' ] { + if ( ! skipSource ) { + return 'store'; + } + return existing ? 'preserve' : 'omit'; +} + +async function preparePlanItem( + options: EdgeWorkerDeploymentPlanOptions, + worker: DiscoveredWorker, + existing: EdgeWorker | null +): Promise< EdgeWorkerDeploymentPlanItem > { + const artifact = prepareArtifact( options, worker ); + const { validation, phases } = await prepareValidation( options, worker, artifact ); + const source = options.skipSource ? undefined : readWorkerSource( worker ); + const hasLocation = Object.hasOwn( worker.manifest, 'location' ); + const currentLocation = existing?.location ?? null; + + return { + action: existing ? 'update' : 'create', + worker, + existing, + artifact, + validation, + phases, + input: prepareInput( worker, existing, artifact, source, hasLocation ), + currentLocation, + proposedLocation: proposedLocationFor( worker, existing, hasLocation, currentLocation ), + sourceMode: sourceModeFor( options.skipSource, existing ), + }; +} + +export async function prepareEdgeWorkerDeploymentPlan( + options: EdgeWorkerDeploymentPlanOptions +): Promise< EdgeWorkerDeploymentPlanItem[] > { + const remoteWorkers = await listEdgeWorkers( options.appId, options.envId ); + const remoteWorkersByName = new Map( remoteWorkers.map( worker => [ worker.name, worker ] ) ); + const items: EdgeWorkerDeploymentPlanItem[] = []; + + for ( const worker of options.workers ) { + const existing = remoteWorkersByName.get( worker.manifest.name ) ?? null; + // eslint-disable-next-line no-await-in-loop + items.push( await preparePlanItem( options, worker, existing ) ); + } + + return items; +} + +function formatLocation( location: EdgeWorkerLocation | null ): string { + return location ? `${ location.operator } "${ location.value }"` : 'all requests'; +} + +export function deploymentPlanRows( + items: readonly EdgeWorkerDeploymentPlanItem[] +): Record< string, string >[] { + return items.map( item => ( { + worker: item.worker.manifest.name, + action: item.action, + active: item.existing?.active ? 'yes' : 'no', + current_scope: formatLocation( item.currentLocation ), + proposed_scope: formatLocation( item.proposedLocation ), + validation: item.validation, + phases: item.phases.join( ', ' ) || 'none', + bytes: String( item.artifact.sizeBytes ), + source: item.sourceMode, + } ) ); +} + +async function applyPlanItem( + envId: number, + item: EdgeWorkerDeploymentPlanItem +): Promise< EdgeWorker > { + if ( item.action === 'create' ) { + return createEdgeWorker( envId, item.input ); + } + if ( ! item.existing ) { + throw new Error( `Update plan for "${ item.worker.manifest.name }" has no existing worker.` ); + } + return updateEdgeWorker( envId, item.existing.id, item.input ); +} + +export async function applyEdgeWorkerDeploymentPlan( + envId: number, + items: readonly EdgeWorkerDeploymentPlanItem[], + onApplied: EdgeWorkerAppliedCallback +): Promise< void > { + const appliedNames: string[] = []; + + for ( const [ index, item ] of items.entries() ) { + const name = item.worker.manifest.name; + let result: EdgeWorker; + try { + // eslint-disable-next-line no-await-in-loop + result = await applyPlanItem( envId, item ); + } catch ( cause ) { + throw new DeploymentApplyError( + [ ...appliedNames ], + name, + items.slice( index + 1 ).map( remaining => remaining.worker.manifest.name ), + cause + ); + } + + appliedNames.push( name ); + // eslint-disable-next-line no-await-in-loop + await onApplied( item, result ); + } +} From edc8022a58f246244d642e6f9d8ad8c7b27bc080 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 12:25:57 -0500 Subject: [PATCH 27/41] fix(edge-workers): store empty deployment source --- __tests__/lib/edge-workers/deployment.test.ts | 10 ++++++++++ src/lib/edge-workers/deployment.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/__tests__/lib/edge-workers/deployment.test.ts b/__tests__/lib/edge-workers/deployment.test.ts index b7b85271e..9b9889921 100644 --- a/__tests__/lib/edge-workers/deployment.test.ts +++ b/__tests__/lib/edge-workers/deployment.test.ts @@ -157,6 +157,16 @@ describe( 'prepareEdgeWorkerDeploymentPlan()', () => { expect( plan[ 0 ].input ).not.toHaveProperty( 'source' ); } ); + it( 'stores an empty source file on update when source storage is enabled', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + jest.mocked( readWorkerSource ).mockReturnValue( '' ); + + const plan = await prepareEdgeWorkerDeploymentPlan( options() ); + + expect( plan[ 0 ].sourceMode ).toBe( 'store' ); + expect( plan[ 0 ].input ).toHaveProperty( 'source', '' ); + } ); + it( 'aborts preparation when source cannot be read', async () => { jest.mocked( readWorkerSource ).mockImplementation( () => { throw new Error( 'Could not read worker source.' ); diff --git a/src/lib/edge-workers/deployment.ts b/src/lib/edge-workers/deployment.ts index 8bf3a25a9..c88177f5e 100644 --- a/src/lib/edge-workers/deployment.ts +++ b/src/lib/edge-workers/deployment.ts @@ -111,7 +111,7 @@ function prepareInput( if ( worker.manifest.on_failure ) { input.onFailure = worker.manifest.on_failure; } - if ( source ) { + if ( source !== undefined ) { input.source = source; } if ( existing && hasLocation ) { From 6d19c8117f97904f3ca09362c839e4c1e5601131 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 12:42:17 -0500 Subject: [PATCH 28/41] fix(edge-workers): guard production mutations --- __tests__/bin/vip-edge-workers-delete.js | 151 +++++++++++++ __tests__/bin/vip-edge-workers-deploy.js | 69 +++++- __tests__/bin/vip-edge-workers-disable.js | 109 +++++++++ __tests__/bin/vip-edge-workers-enable.js | 176 +++++++++++++++ .../lib/edge-workers/confirmation.test.ts | 213 ++++++++++++++++++ src/bin/vip-edge-workers-delete.js | 14 +- src/bin/vip-edge-workers-deploy.js | 18 ++ src/bin/vip-edge-workers-enable.js | 18 ++ src/lib/edge-workers/confirmation.ts | 70 ++++++ 9 files changed, 833 insertions(+), 5 deletions(-) create mode 100644 __tests__/bin/vip-edge-workers-delete.js create mode 100644 __tests__/bin/vip-edge-workers-disable.js create mode 100644 __tests__/bin/vip-edge-workers-enable.js create mode 100644 __tests__/lib/edge-workers/confirmation.test.ts create mode 100644 src/lib/edge-workers/confirmation.ts diff --git a/__tests__/bin/vip-edge-workers-delete.js b/__tests__/bin/vip-edge-workers-delete.js new file mode 100644 index 000000000..2b833f1cd --- /dev/null +++ b/__tests__/bin/vip-edge-workers-delete.js @@ -0,0 +1,151 @@ +import { edgeWorkersDeleteCommand } from '../../src/bin/vip-edge-workers-delete'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +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: 'mock-app-query', + deleteEdgeWorker: jest.fn(), + findEdgeWorkerByName: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + force: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersDeleteCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.deleteEdgeWorker.mockResolvedValue(); + confirm.mockResolvedValue( true ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'resolves the target and does not prompt or delete when it is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersDeleteCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'missing' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'confirms the exact resolved target before deleting', async () => { + const order = []; + edgeWorkersApi.findEdgeWorkerByName.mockImplementation( async () => { + order.push( 'resolve' ); + return worker; + } ); + confirm.mockImplementation( async message => { + expect( message ).toBe( + 'Permanently delete edge worker "headers" from example-app.production?' + ); + order.push( 'confirm' ); + return true; + } ); + edgeWorkersApi.deleteEdgeWorker.mockImplementation( async () => { + order.push( 'delete' ); + } ); + + await edgeWorkersDeleteCommand( [ 'headers' ], opts ); + + expect( order ).toEqual( [ 'resolve', 'confirm', 'delete' ] ); + } ); + + it( 'does not delete when confirmation is declined', async () => { + confirm.mockResolvedValue( false ); + + await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to delete edge worker: Command cancelled by user.' + ); + } ); + + it( 'uses force as the explicit prompt bypass', async () => { + await edgeWorkersDeleteCommand( [ 'headers' ], { ...opts, force: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 ); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.deleteEdgeWorker.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to delete edge worker: API unavailable' + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + expect.anything() + ); + } ); + + it( 'prints and tracks success only after deletion succeeds', async () => { + await edgeWorkersDeleteCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 ); + expect( console.log ).toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + { name: 'headers' } + ); + const deletionOrder = edgeWorkersApi.deleteEdgeWorker.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( deletionOrder ).toBeLessThan( outputOrder ); + expect( deletionOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index 3b9a28e8f..30b00b6aa 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -1,8 +1,10 @@ import { edgeWorkersDeployCommand } from '../../src/bin/vip-edge-workers-deploy'; import * as exit from '../../src/lib/cli/exit'; import * as format from '../../src/lib/cli/format'; +import * as confirmation from '../../src/lib/edge-workers/confirmation'; import * as deployment from '../../src/lib/edge-workers/deployment'; import * as project from '../../src/lib/edge-workers/project'; +import { confirm } from '../../src/lib/envvar/input'; import * as tracker from '../../src/lib/tracker'; jest.spyOn( console, 'log' ).mockImplementation( () => {} ); @@ -23,6 +25,18 @@ jest.mock( '../../src/lib/cli/format', () => ( { formatData: jest.fn(), } ) ); +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/confirmation', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/confirmation' ); + return { + ...actual, + isInteractiveEdgeWorkers: jest.fn(), + }; +} ); + jest.mock( '../../src/lib/edge-workers/deployment', () => { const actual = jest.requireActual( '../../src/lib/edge-workers/deployment' ); return { @@ -44,11 +58,12 @@ jest.mock( '../../src/lib/tracker', () => ( { } ) ); const opts = { - app: { id: 1 }, - env: { id: 3 }, + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, skipBuild: true, skipValidate: false, skipSource: false, + skipConfirmation: false, }; const worker = name => ( { @@ -83,6 +98,8 @@ describe( 'edgeWorkersDeployCommand()', () => { deployment.deploymentPlanRows.mockReturnValue( [ { worker: 'headers' } ] ); format.formatData.mockReturnValue( 'PLAN TABLE' ); deployment.applyEdgeWorkerDeploymentPlan.mockResolvedValue(); + confirmation.isInteractiveEdgeWorkers.mockReturnValue( true ); + confirm.mockResolvedValue( true ); } ); it( 'prepares the selected worker with the command options', async () => { @@ -131,7 +148,7 @@ describe( 'edgeWorkersDeployCommand()', () => { expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); } ); - it( 'prints the complete plan before applying the same items', async () => { + it( 'prepares, previews, confirms exact production targets, then applies the same items', async () => { const plan = [ planItem( 'alpha' ), planItem( 'beta', 'update' ) ]; const rows = [ { worker: 'alpha' }, { worker: 'beta' } ]; const order = []; @@ -151,6 +168,11 @@ describe( 'edgeWorkersDeployCommand()', () => { expect( value ).toBe( 'PLAN TABLE' ); order.push( 'preview' ); } ); + confirm.mockImplementation( async message => { + expect( message ).toBe( 'Deploy 2 edge workers (alpha, beta) to example-app.production?' ); + order.push( 'confirm' ); + return true; + } ); deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( async ( envId, received ) => { expect( envId ).toBe( 3 ); expect( received ).toBe( plan ); @@ -159,7 +181,46 @@ describe( 'edgeWorkersDeployCommand()', () => { await edgeWorkersDeployCommand( [], { ...opts, all: true } ); - expect( order ).toEqual( [ 'rows', 'format', 'preview', 'apply' ] ); + expect( order ).toEqual( [ 'rows', 'format', 'preview', 'confirm', 'apply' ] ); + expect( confirmation.isInteractiveEdgeWorkers ).toHaveBeenCalledWith( { + ...opts, + all: true, + } ); + } ); + + it( 'previews and applies non-production deployments without prompting', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], { + ...opts, + env: { id: 3, type: 'develop' }, + } ); + + expect( console.log ).toHaveBeenCalledWith( 'PLAN TABLE' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).toHaveBeenCalled(); + } ); + + it( 'previews but refuses non-interactive production before applying', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( edgeWorkersDeployCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).toHaveBeenCalledWith( 'PLAN TABLE' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringMatching( /Refusing to deploy.*production/ ) + ); + } ); + + it( 'allows explicit production confirmation bypass without prompting', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, skipConfirmation: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).toHaveBeenCalled(); } ); it( 'prints success output only from the applied callback and then tracks success', async () => { diff --git a/__tests__/bin/vip-edge-workers-disable.js b/__tests__/bin/vip-edge-workers-disable.js new file mode 100644 index 000000000..5176536d5 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-disable.js @@ -0,0 +1,109 @@ +import { edgeWorkersDisableCommand } from '../../src/bin/vip-edge-workers-disable'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +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: 'mock-app-query', + findEdgeWorkerByName: jest.fn(), + setEdgeWorkerActive: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersDisableCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.setEdgeWorkerActive.mockResolvedValue( { ...worker, active: false } ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'does not prompt or mutate when the worker is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersDisableCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.setEdgeWorkerActive.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersDisableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to disable edge worker: API unavailable' + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Disabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + expect.anything() + ); + } ); + + it( 'disables production immediately without prompting and reports success after the API', async () => { + await edgeWorkersDisableCommand( [ 'headers' ], opts ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'headers' ); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, false ); + expect( console.log ).toHaveBeenCalledWith( '✓ Disabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + { name: 'headers' } + ); + const mutationOrder = edgeWorkersApi.setEdgeWorkerActive.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( mutationOrder ).toBeLessThan( outputOrder ); + expect( mutationOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-enable.js b/__tests__/bin/vip-edge-workers-enable.js new file mode 100644 index 000000000..2fc4cc0bd --- /dev/null +++ b/__tests__/bin/vip-edge-workers-enable.js @@ -0,0 +1,176 @@ +import { edgeWorkersEnableCommand } from '../../src/bin/vip-edge-workers-enable'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as confirmation from '../../src/lib/edge-workers/confirmation'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +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: 'mock-app-query', + findEdgeWorkerByName: jest.fn(), + setEdgeWorkerActive: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/confirmation', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/confirmation' ); + return { + ...actual, + isInteractiveEdgeWorkers: jest.fn(), + }; +} ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + skipConfirmation: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: false, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersEnableCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.setEdgeWorkerActive.mockResolvedValue( { ...worker, active: true } ); + confirmation.isInteractiveEdgeWorkers.mockReturnValue( true ); + confirm.mockResolvedValue( true ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'does not prompt or mutate when the worker is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersEnableCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + expect.anything() + ); + } ); + + it( 'enables a non-production worker without prompting', async () => { + await edgeWorkersEnableCommand( [ 'headers' ], { + ...opts, + env: { id: 3, type: 'develop' }, + } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + } ); + + it( 'confirms the exact production worker before enabling it', async () => { + const order = []; + confirm.mockImplementation( async message => { + expect( message ).toBe( 'Enable edge worker "headers" on example-app.production?' ); + order.push( 'confirm' ); + return true; + } ); + edgeWorkersApi.setEdgeWorkerActive.mockImplementation( async () => { + order.push( 'mutation' ); + return { ...worker, active: true }; + } ); + + await edgeWorkersEnableCommand( [ 'headers' ], opts ); + + expect( order ).toEqual( [ 'confirm', 'mutation' ] ); + expect( confirmation.isInteractiveEdgeWorkers ).toHaveBeenCalledWith( opts ); + } ); + + it( 'refuses non-interactive production before enabling', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( edgeWorkersEnableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringMatching( /Refusing to enable.*production/ ) + ); + } ); + + it( 'allows explicit production confirmation bypass', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await edgeWorkersEnableCommand( [ 'headers' ], { ...opts, skipConfirmation: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.setEdgeWorkerActive.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersEnableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to enable edge worker: API unavailable' + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Enabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + expect.anything() + ); + } ); + + it( 'prints and tracks success only after the confirmed API mutation succeeds', async () => { + await edgeWorkersEnableCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'headers' ); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + expect( console.log ).toHaveBeenCalledWith( '✓ Enabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + { name: 'headers' } + ); + const mutationOrder = edgeWorkersApi.setEdgeWorkerActive.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( mutationOrder ).toBeLessThan( outputOrder ); + expect( mutationOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/confirmation.test.ts b/__tests__/lib/edge-workers/confirmation.test.ts new file mode 100644 index 000000000..bddd2785e --- /dev/null +++ b/__tests__/lib/edge-workers/confirmation.test.ts @@ -0,0 +1,213 @@ +import { + confirmEdgeWorkerDeletion, + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../../../src/lib/edge-workers/confirmation'; +import UserError from '../../../src/lib/user-error'; + +const productionRequest = { + action: 'deploy' as const, + appName: 'example-app', + envType: 'production', + workerNames: [ 'headers', 'redirects' ], + skipConfirmation: false, + nonInteractive: false, +}; + +function withStdoutIsTTY< T >( isTTY: boolean, callback: () => T ): T { + const originalDescriptor = Object.getOwnPropertyDescriptor( process, 'stdout' ); + const stdout = Object.create( process.stdout ) as NodeJS.WriteStream; + Object.defineProperty( stdout, 'isTTY', { + configurable: true, + value: isTTY, + writable: true, + } ); + + try { + Object.defineProperty( process, 'stdout', { + configurable: true, + enumerable: originalDescriptor?.enumerable ?? true, + value: stdout, + writable: true, + } ); + return callback(); + } finally { + if ( originalDescriptor ) { + Object.defineProperty( process, 'stdout', originalDescriptor ); + } else { + delete ( process as { stdout?: NodeJS.WriteStream } ).stdout; + } + } +} + +describe( 'isInteractiveEdgeWorkers()', () => { + it( 'uses a TTY when no non-interactive override is set', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( true ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats VIP_NON_INTERACTIVE=1 as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + process.env.VIP_NON_INTERACTIVE = '1'; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats an explicit nonInteractive option as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( { nonInteractive: true } ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats non-TTY stdout as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( false, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); +} ); + +describe( 'confirmProductionEdgeWorkerMutation()', () => { + it( 'prompts with every exact worker name for interactive production deploys', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( productionRequest, confirmFn ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Deploy 2 edge workers (headers, redirects) to example-app.production?' + ); + } ); + + it( 'prompts with the exact worker identity for interactive production enables', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, action: 'enable', workerNames: [ 'headers' ] }, + confirmFn + ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Enable edge worker "headers" on example-app.production?' + ); + } ); + + it( 'rejects non-interactive production without bypass', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await expect( + confirmProductionEdgeWorkerMutation( + { ...productionRequest, nonInteractive: true }, + confirmFn + ) + ).rejects.toThrow( /Refusing to deploy.*production/ ); + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + + it( 'throws UserError when the user declines', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); + + await expect( + confirmProductionEdgeWorkerMutation( productionRequest, confirmFn ) + ).rejects.toEqual( new UserError( 'Command cancelled by user.' ) ); + } ); + + it( 'skips prompting when bypassed', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, skipConfirmation: true, nonInteractive: true }, + confirmFn + ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + + it( 'does not prompt outside production', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, envType: 'develop', nonInteractive: true }, + confirmFn + ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'confirmEdgeWorkerDeletion()', () => { + const request = { + appName: 'example-app', + envType: 'production', + workerName: 'headers', + force: false, + }; + + it( 'prompts with the exact destructive target', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmEdgeWorkerDeletion( request, confirmFn ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Permanently delete edge worker "headers" from example-app.production?' + ); + } ); + + it( 'throws UserError when the user declines deletion', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); + + await expect( confirmEdgeWorkerDeletion( request, confirmFn ) ).rejects.toEqual( + new UserError( 'Command cancelled by user.' ) + ); + } ); + + it( 'does not prompt when force is set', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmEdgeWorkerDeletion( { ...request, force: true }, confirmFn ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/bin/vip-edge-workers-delete.js b/src/bin/vip-edge-workers-delete.js index 41260bbdf..9b380fe7d 100644 --- a/src/bin/vip-edge-workers-delete.js +++ b/src/bin/vip-edge-workers-delete.js @@ -3,6 +3,8 @@ import { appQuery, deleteEdgeWorker, findEdgeWorkerByName } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; +import { confirmEdgeWorkerDeletion } from '../lib/edge-workers/confirmation'; +import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers delete'; @@ -26,6 +28,16 @@ export async function edgeWorkersDeleteCommand( args = [], opt = {} ) { exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); } + await confirmEdgeWorkerDeletion( + { + appName: app.name, + envType: env.type, + workerName: worker.name, + force: Boolean( opt.force ), + }, + confirm + ); + await deleteEdgeWorker( env.id, worker.id ); await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_success', { name } ); @@ -44,8 +56,8 @@ command( { appQuery, envContext: true, requiredArgs: 1, - requireConfirm: 'Are you sure you want to permanently delete this edge worker?', usage, } ) + .option( 'force', 'Skip confirmation.', false ) .examples( examples ) .argv( process.argv, edgeWorkersDeleteCommand ); diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js index 34522f61c..3ccfc09b0 100644 --- a/src/bin/vip-edge-workers-deploy.js +++ b/src/bin/vip-edge-workers-deploy.js @@ -4,6 +4,10 @@ import { appQuery } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; import { formatData } from '../lib/cli/format'; +import { + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../lib/edge-workers/confirmation'; import { applyEdgeWorkerDeploymentPlan, DeploymentApplyError, @@ -11,6 +15,7 @@ import { prepareEdgeWorkerDeploymentPlan, } from '../lib/edge-workers/deployment'; import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers deploy'; @@ -83,6 +88,18 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { console.log( formatData( deploymentPlanRows( plan ), 'table' ) ); + await confirmProductionEdgeWorkerMutation( + { + action: 'deploy', + appName: app.name, + envType: env.type, + workerNames: plan.map( item => item.worker.manifest.name ), + skipConfirmation: Boolean( opt.skipConfirmation ), + nonInteractive: ! isInteractiveEdgeWorkers( opt ), + }, + confirm + ); + await applyEdgeWorkerDeploymentPlan( env.id, plan, ( item, deployed ) => { const action = item.action === 'create' ? 'created' : 'updated'; const phasesNote = `, phases: ${ deployed.phases.join( ', ' ) || 'none' }`; @@ -119,5 +136,6 @@ command( { .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 ) + .option( 'skip-confirmation', 'Skip the production deployment confirmation.', false ) .examples( examples ) .argv( process.argv, edgeWorkersDeployCommand ); diff --git a/src/bin/vip-edge-workers-enable.js b/src/bin/vip-edge-workers-enable.js index c36162866..0ffd91e76 100644 --- a/src/bin/vip-edge-workers-enable.js +++ b/src/bin/vip-edge-workers-enable.js @@ -3,6 +3,11 @@ import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; +import { + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../lib/edge-workers/confirmation'; +import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers enable'; @@ -26,6 +31,18 @@ export async function edgeWorkersEnableCommand( args = [], opt = {} ) { exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); } + await confirmProductionEdgeWorkerMutation( + { + action: 'enable', + appName: app.name, + envType: env.type, + workerNames: [ worker.name ], + skipConfirmation: Boolean( opt.skipConfirmation ), + nonInteractive: ! isInteractiveEdgeWorkers( opt ), + }, + confirm + ); + await setEdgeWorkerActive( env.id, worker.id, true ); await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_success', { name } ); @@ -46,5 +63,6 @@ command( { requiredArgs: 1, usage, } ) + .option( 'skip-confirmation', 'Skip the production enable confirmation.', false ) .examples( examples ) .argv( process.argv, edgeWorkersEnableCommand ); diff --git a/src/lib/edge-workers/confirmation.ts b/src/lib/edge-workers/confirmation.ts new file mode 100644 index 000000000..19e8bcdb1 --- /dev/null +++ b/src/lib/edge-workers/confirmation.ts @@ -0,0 +1,70 @@ +import UserError from '../user-error'; + +export interface ProductionMutationConfirmationRequest { + action: 'deploy' | 'enable'; + appName: string; + envType: string; + workerNames: readonly string[]; + skipConfirmation: boolean; + nonInteractive: boolean; +} + +export interface EdgeWorkerDeletionConfirmationRequest { + appName: string; + envType: string; + workerName: string; + force: boolean; +} + +export type EdgeWorkerConfirmFunction = ( message: string ) => Promise< boolean >; + +export function isInteractiveEdgeWorkers( options: { nonInteractive?: boolean } ): boolean { + return ( + process.env.VIP_NON_INTERACTIVE !== '1' && + ! options.nonInteractive && + Boolean( process.stdout.isTTY ) + ); +} + +export async function confirmProductionEdgeWorkerMutation( + request: ProductionMutationConfirmationRequest, + confirmFn: EdgeWorkerConfirmFunction +): Promise< void > { + if ( request.envType !== 'production' || request.skipConfirmation ) { + return; + } + + if ( request.nonInteractive ) { + throw new UserError( + `Refusing to ${ request.action } edge workers in production without confirmation. ` + + 'Pass --skip-confirmation to proceed non-interactively.' + ); + } + + const message = + request.action === 'deploy' + ? `Deploy ${ request.workerNames.length } edge worker${ + request.workerNames.length === 1 ? '' : 's' + } (${ request.workerNames.join( ', ' ) }) to ${ request.appName }.${ request.envType }?` + : `Enable edge worker "${ request.workerNames[ 0 ] }" on ${ request.appName }.${ request.envType }?`; + + if ( ! ( await confirmFn( message ) ) ) { + throw new UserError( 'Command cancelled by user.' ); + } +} + +export async function confirmEdgeWorkerDeletion( + request: EdgeWorkerDeletionConfirmationRequest, + confirmFn: EdgeWorkerConfirmFunction +): Promise< void > { + if ( request.force ) { + return; + } + + const confirmed = await confirmFn( + `Permanently delete edge worker "${ request.workerName }" from ${ request.appName }.${ request.envType }?` + ); + if ( ! confirmed ) { + throw new UserError( 'Command cancelled by user.' ); + } +} From 42c557c5dd276cd60508a06b165363275ff395d3 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 12:55:47 -0500 Subject: [PATCH 29/41] fix(api): minimize edge-worker and debug data --- __tests__/bin/vip-edge-workers-get.js | 125 +++++++++++++++++++++++++ __tests__/lib/api-edge-workers.test.ts | 43 ++++++++- __tests__/lib/api-error-debug.test.ts | 37 ++++++++ src/bin/vip-edge-workers-get.js | 5 +- src/lib/api.ts | 17 ++-- src/lib/api/edge-workers.ts | 23 ++--- src/lib/api/error-debug.ts | 17 ++++ src/lib/edge-workers/types.ts | 2 - 8 files changed, 246 insertions(+), 23 deletions(-) create mode 100644 __tests__/bin/vip-edge-workers-get.js create mode 100644 __tests__/lib/api-error-debug.test.ts create mode 100644 src/lib/api/error-debug.ts diff --git a/__tests__/bin/vip-edge-workers-get.js b/__tests__/bin/vip-edge-workers-get.js new file mode 100644 index 000000000..a0654a469 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-get.js @@ -0,0 +1,125 @@ +import { edgeWorkersGetCommand } from '../../src/bin/vip-edge-workers-get'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as tracker from '../../src/lib/tracker'; + +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: 'mock-app-query', + getEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + source: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: { operator: 'starts_with', value: '/api/' }, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersGetCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.getEdgeWorker.mockResolvedValue( worker ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'requests default details without source and preserves key-value output', async () => { + await edgeWorkersGetCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.getEdgeWorker ).toHaveBeenCalledWith( 1, 3, 'headers', { + includeSource: false, + } ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '+ Name: headers' ) ); + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( '+ Location: starts_with "/api/"' ) + ); + expect( console.log ).not.toHaveBeenCalledWith( '\nSource:' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + { name: 'headers' } + ); + } ); + + it( 'requests and prints stored source only for --source', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { + ...worker, + source: 'export default {};', + } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + expect( edgeWorkersApi.getEdgeWorker ).toHaveBeenCalledWith( 1, 3, 'headers', { + includeSource: true, + } ); + expect( console.log ).toHaveBeenCalledWith( '\nSource:' ); + expect( console.log ).toHaveBeenCalledWith( 'export default {};' ); + } ); + + it( 'reports when explicitly requested source was not stored', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { ...worker, source: null } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + expect( console.log ).toHaveBeenCalledWith( '(no source stored)' ); + } ); + + it( 'reports a missing worker without false success', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( null ); + + await expect( edgeWorkersGetCommand( [ 'missing' ], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'No edge worker named "missing" is deployed to this environment.' + ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + expect.anything() + ); + } ); + + it( 'reports API rejection without details or false success', async () => { + edgeWorkersApi.getEdgeWorker.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersGetCommand( [ 'headers' ], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( 'Failed to get edge worker: API unavailable' ); + expect( console.log ).not.toHaveBeenCalled(); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + expect.anything() + ); + } ); +} ); diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts index 04a2474cc..db97be2fa 100644 --- a/__tests__/lib/api-edge-workers.test.ts +++ b/__tests__/lib/api-edge-workers.test.ts @@ -1,23 +1,64 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { print } from 'graphql'; import * as apiModule from '../../src/lib/api'; import { createEdgeWorker, deleteEdgeWorker, + getEdgeWorker, setEdgeWorkerActive, updateEdgeWorker, validateEdgeWorker, } from '../../src/lib/api/edge-workers'; +import type { DocumentNode } from 'graphql'; + jest.mock( '../../src/lib/api' ); const mockMutate = jest.fn< ( options: unknown ) => Promise< { data?: Record< string, unknown > } > >(); +const mockQuery = + jest.fn< + ( options: { query: DocumentNode } ) => Promise< { data?: Record< string, unknown > } > + >(); const mockedAPI = apiModule as unknown as { default: jest.Mock }; beforeEach( () => { mockMutate.mockReset(); - mockedAPI.default = jest.fn().mockReturnValue( { mutate: mockMutate } ); + mockQuery.mockReset(); + mockedAPI.default = jest.fn().mockReturnValue( { mutate: mockMutate, query: mockQuery } ); +} ); + +describe( 'edge worker read query contracts', () => { + beforeEach( () => { + mockQuery.mockResolvedValue( { + data: { + app: { + environments: [ { id: 3, edgeWorkers: [ { id: 5, name: 'headers' } ] } ], + }, + }, + } ); + } ); + + it( 'omits source and wasmBinary from the default detail query', async () => { + await getEdgeWorker( 1, 3, 'headers' ); + + const queryDocument = mockQuery.mock.calls[ 0 ][ 0 ].query; + const query = print( queryDocument ); + + expect( query ).not.toContain( 'source' ); + expect( query ).not.toContain( 'wasmBinary' ); + } ); + + it( 'requests source but never wasmBinary when source is explicitly included', async () => { + await getEdgeWorker( 1, 3, 'headers', { includeSource: true } ); + + const queryDocument = mockQuery.mock.calls[ 0 ][ 0 ].query; + const query = print( queryDocument ); + + expect( query ).toContain( 'source' ); + expect( query ).not.toContain( 'wasmBinary' ); + } ); } ); describe( 'edge worker mutation result contracts', () => { diff --git a/__tests__/lib/api-error-debug.test.ts b/__tests__/lib/api-error-debug.test.ts new file mode 100644 index 000000000..a9263d689 --- /dev/null +++ b/__tests__/lib/api-error-debug.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from '@jest/globals'; + +import { safeGraphQLErrorDebugInfo } from '../../src/lib/api/error-debug'; + +describe( 'safeGraphQLErrorDebugInfo()', () => { + it( 'returns only allowlisted operation, path, and string code metadata', () => { + const error = { + message: 'field failed', + path: [ 'app', 'environments', 0, 'envVars' ], + extensions: { code: 'FORBIDDEN', secret: 'must-not-appear' }, + }; + + const info = safeGraphQLErrorDebugInfo( 'EnvVars', [ error ] ); + + expect( info ).toEqual( [ + { + operation: 'EnvVars', + path: [ 'app', 'environments', 0, 'envVars' ], + code: 'FORBIDDEN', + }, + ] ); + expect( JSON.stringify( info ) ).not.toContain( 'field failed' ); + expect( JSON.stringify( info ) ).not.toContain( 'must-not-appear' ); + } ); + + it( 'omits non-string codes and defaults a missing path to an empty array', () => { + const error = { + message: 'binary rejected', + extensions: { code: 403, source: 'sensitive source' }, + }; + const info = safeGraphQLErrorDebugInfo( 'EdgeWorkerDetail', [ error ] ); + + expect( info ).toEqual( [ { operation: 'EdgeWorkerDetail', path: [] } ] ); + expect( JSON.stringify( info ) ).not.toContain( 'binary rejected' ); + expect( JSON.stringify( info ) ).not.toContain( 'sensitive source' ); + } ); +} ); diff --git a/src/bin/vip-edge-workers-get.js b/src/bin/vip-edge-workers-get.js index a97e02b1c..42e0d879b 100644 --- a/src/bin/vip-edge-workers-get.js +++ b/src/bin/vip-edge-workers-get.js @@ -22,6 +22,7 @@ const examples = [ export async function edgeWorkersGetCommand( args = [], opt = {} ) { const { app, env } = opt; const name = args[ 0 ]; + const includeSource = opt.source === true; await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_execute', { name } ); @@ -31,7 +32,7 @@ export async function edgeWorkersGetCommand( args = [], opt = {} ) { let worker; try { - worker = await getEdgeWorker( app.id, env.id, name ); + worker = await getEdgeWorker( app.id, env.id, name, { includeSource } ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { name, @@ -67,7 +68,7 @@ export async function edgeWorkersGetCommand( args = [], opt = {} ) { ] ) ); - if ( opt.source ) { + if ( includeSource ) { console.log( '\nSource:' ); console.log( worker.source ?? '(no source stored)' ); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 284089ecb..cc8106915 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,7 @@ import debugLib from 'debug'; import { Kind, OperationTypeNode } from 'graphql'; import { API_URL } from './api/constants'; +import { safeGraphQLErrorDebugInfo } from './api/error-debug'; import http from './api/http'; // Config — re-exported from ./api/constants so modules in the rechallenge tree @@ -88,7 +89,7 @@ export default function API( { silenceAuthErrors?: boolean; customRetryLink?: RetryLink; } = {} ): ApolloClient { - const errorLink = new ErrorLink( ( { error } ) => { + const errorLink = new ErrorLink( ( { error, operation } ) => { if ( ! silenceAuthErrors && error instanceof ServerError && error.statusCode === 401 ) { let message; try { @@ -112,12 +113,14 @@ 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 ) ); - } + debug( + 'GraphQL errors in response: %s', + JSON.stringify( + safeGraphQLErrorDebugInfo( operation.operationName ?? '', error.errors ), + 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 index f4d8e63be..a6ad0f425 100644 --- a/src/lib/api/edge-workers.ts +++ b/src/lib/api/edge-workers.ts @@ -2,9 +2,9 @@ * 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`. + * `source` as an on-demand read field, 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. @@ -74,7 +74,7 @@ function requireMutationPayload< T >( operation: string, value: T | null | undef return value; } -/** List the edge workers deployed to an environment (without source/wasm). */ +/** List the edge workers deployed to an environment without source. */ export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { const api = API(); const response = await api.query< EdgeWorkersQueryResult >( { @@ -98,16 +98,19 @@ export async function listEdgeWorkers( appId: number, envId: number ): Promise< } /** - * 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. + * Fetch a single worker by name. The schema has no single-worker query, so this + * requests the environment's workers and filters client-side. Source is fetched + * only when explicitly requested. */ export async function getEdgeWorker( appId: number, envId: number, - name: string + name: string, + options: { includeSource?: boolean } = {} ): Promise< EdgeWorker | null > { const api = API(); + const fields = + options.includeSource === true ? `${ EDGE_WORKER_FIELDS }\nsource` : EDGE_WORKER_FIELDS; const response = await api.query< EdgeWorkersQueryResult >( { query: gql` query EdgeWorkerDetail($appId: Int!) { @@ -115,9 +118,7 @@ export async function getEdgeWorker( environments { id edgeWorkers { - ${ EDGE_WORKER_FIELDS } - source - wasmBinary + ${ fields } } } } diff --git a/src/lib/api/error-debug.ts b/src/lib/api/error-debug.ts new file mode 100644 index 000000000..fe0ad4705 --- /dev/null +++ b/src/lib/api/error-debug.ts @@ -0,0 +1,17 @@ +export function safeGraphQLErrorDebugInfo( + operation: string, + errors: readonly { + path?: readonly ( string | number )[]; + extensions?: Record< string, unknown >; + }[] +) { + return errors.map( error => { + const code = error.extensions?.code; + + return { + operation, + path: error.path ?? [], + ...( typeof code === 'string' ? { code } : {} ), + }; + } ); +} diff --git a/src/lib/edge-workers/types.ts b/src/lib/edge-workers/types.ts index c0a019eef..c3e9d7e1f 100644 --- a/src/lib/edge-workers/types.ts +++ b/src/lib/edge-workers/types.ts @@ -87,6 +87,4 @@ export interface EdgeWorker { 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 b057b595efb71f39e87857c5dbf5da7b9a50756e Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 13:12:28 -0500 Subject: [PATCH 30/41] docs(edge-workers): ship safe scaffold guidance --- README.md | 1 + __tests__/bin/vip-edge-workers-build.js | 114 +++++++++ __tests__/bin/vip-edge-workers-deploy.js | 19 +- __tests__/bin/vip-edge-workers-new.js | 111 +++++++++ __tests__/lib/edge-workers/toolchains.js | 13 +- docs/EDGE-WORKERS.md | 223 ++++++++++++++++++ src/bin/vip-edge-workers-build.js | 5 +- src/bin/vip-edge-workers-deploy.js | 6 +- src/bin/vip-edge-workers-new.js | 7 + src/bin/vip-edge-workers-validate.js | 3 +- .../toolchains/assemblyscript/constants.ts | 4 +- .../toolchains/assemblyscript/templates.ts | 49 ++-- 12 files changed, 517 insertions(+), 38 deletions(-) create mode 100644 __tests__/bin/vip-edge-workers-build.js create mode 100644 __tests__/bin/vip-edge-workers-new.js create mode 100644 docs/EDGE-WORKERS.md diff --git a/README.md b/README.md index 4ba04c3f6..eccc97f6c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ VIP-CLI is a tool for interacting with and managing your [WordPress VIP applicat - [CONTRIBUTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/CONTRIBUTING.md) for information on how to contribute patches and features, also issue and pull request labels. - [DEBUGGING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/DEBUGGING.md) for information on how to debug the software. - [TESTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/TESTING.md) for details on testing the software and individual tasks. +- [EDGE-WORKERS.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/EDGE-WORKERS.md) for the edge-worker scaffold, validation, deployment, and operational safety contract. - [RELEASING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/RELEASING.md) for details on deploying a new release. - [SECURITY.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/SECURITY.md) for information if you **found a security issue**. diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js new file mode 100644 index 000000000..10d05d638 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-build.js @@ -0,0 +1,114 @@ +import { edgeWorkersBuildCommand } from '../../src/bin/vip-edge-workers-build'; +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'; +import * as tracker from '../../src/lib/tracker'; + +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/edge-workers', () => ( { + buildWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + discoverWorkers: jest.fn(), + findWorker: jest.fn(), + resolveProjectDir: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +const worker = name => ( { + dir: `/project/workers/${ name }`, + manifest: { name, entry: 'assembly/index.ts' }, +} ); + +describe( 'edgeWorkersBuildCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.findWorker.mockImplementation( ( _projectDir, name ) => worker( name ) ); + project.discoverWorkers.mockReturnValue( [ worker( 'alpha' ), worker( 'beta' ) ] ); + lib.buildWorker.mockImplementation( ( _projectDir, selectedWorker ) => ( { + wasmPath: `/project/build/${ selectedWorker.manifest.name }.wasm`, + sizeBytes: selectedWorker.manifest.name.length, + } ) ); + } ); + + it( 'builds one named worker', async () => { + await edgeWorkersBuildCommand( [ 'alpha' ] ); + + expect( project.findWorker ).toHaveBeenCalledWith( '/project', 'alpha' ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( lib.buildWorker ).toHaveBeenCalledWith( '/project', worker( 'alpha' ) ); + } ); + + it( 'builds all discovered workers when no name is supplied', async () => { + await edgeWorkersBuildCommand(); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'builds all discovered workers with --all', async () => { + await edgeWorkersBuildCommand( [], { all: true } ); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'reports when the project has no workers', async () => { + project.discoverWorkers.mockReturnValue( [] ); + + await expect( edgeWorkersBuildCommand() ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'No workers found in this project. Create one with `vip edge-workers new`.' + ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { + name: undefined, + error: 'No workers found in this project. Create one with `vip edge-workers new`.', + } ); + expect( lib.buildWorker ).not.toHaveBeenCalled(); + } ); + + it( 'reports a build failure without success telemetry', async () => { + lib.buildWorker.mockImplementation( () => { + throw new Error( 'compiler failed' ); + } ); + + await expect( edgeWorkersBuildCommand( [ 'alpha' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { + name: 'alpha', + error: 'compiler failed', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_build_command_success', + expect.anything() + ); + } ); + + it( 'prints the relative artifact path and byte size', async () => { + await edgeWorkersBuildCommand( [ 'alpha' ] ); + + expect( console.log ).toHaveBeenCalledWith( '✓ Built "alpha" → build/alpha.wasm (5 bytes)' ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_success', { + count: 1, + } ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index 30b00b6aa..0f4ac881b 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -1,4 +1,5 @@ import { edgeWorkersDeployCommand } from '../../src/bin/vip-edge-workers-deploy'; +import command from '../../src/lib/cli/command'; import * as exit from '../../src/lib/cli/exit'; import * as format from '../../src/lib/cli/format'; import * as confirmation from '../../src/lib/edge-workers/confirmation'; @@ -13,12 +14,18 @@ jest.spyOn( exit, 'withError' ).mockImplementation( () => { } ); jest.mock( '../../src/lib/cli/command', () => { + const options = []; const commandMock = { argv: () => commandMock, examples: () => commandMock, - option: () => commandMock, + option: ( ...args ) => { + options.push( args ); + return commandMock; + }, }; - return jest.fn( () => commandMock ); + const createCommand = jest.fn( () => commandMock ); + createCommand.options = options; + return createCommand; } ); jest.mock( '../../src/lib/cli/format', () => ( { @@ -120,6 +127,14 @@ describe( 'edgeWorkersDeployCommand()', () => { } ); } ); + it( 'explains create and update source behavior in --skip-source help', () => { + expect( command.options ).toContainEqual( [ + 'skip-source', + 'Do not store source on create; preserve stored source on update.', + false, + ] ); + } ); + it( 'prepares every discovered worker for --all', async () => { await edgeWorkersDeployCommand( [], { ...opts, all: true } ); diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js new file mode 100644 index 000000000..60a36bb92 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-new.js @@ -0,0 +1,111 @@ +import { edgeWorkersNewCommand } from '../../src/bin/vip-edge-workers-new'; +import * as exit from '../../src/lib/cli/exit'; +import * as project from '../../src/lib/edge-workers/project'; +import * as toolchains from '../../src/lib/edge-workers/toolchains'; +import * as tracker from '../../src/lib/tracker'; + +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/edge-workers/project', () => ( { + readProjectDescriptor: jest.fn(), + readWorkerManifest: jest.fn(), + resolveProjectDir: jest.fn(), + WORKERS_DIR: 'workers', + writeWorkerManifest: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/toolchains', () => ( { + getToolchain: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'edgeWorkersNewCommand()', () => { + const scaffoldWorker = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + scaffoldWorker.mockReset(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.readProjectDescriptor.mockReturnValue( { type: 'assemblyscript' } ); + project.readWorkerManifest.mockReturnValue( { + name: 'demo', + entry: 'assembly/index.ts', + } ); + toolchains.getToolchain.mockReturnValue( { scaffoldWorker } ); + } ); + + it( 'rejects a non-portable name before resolving or modifying a project', async () => { + await expect( edgeWorkersNewCommand( [ 'bad/name' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( scaffoldWorker ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { + name: 'bad/name', + error: 'Invalid worker name "bad/name".', + } ); + } ); + + it( 'writes and reports an explicit request scope', async () => { + await edgeWorkersNewCommand( [ 'demo' ], { location: 'starts_with:/api/' } ); + + expect( project.writeWorkerManifest ).toHaveBeenCalledWith( '/project/workers/demo', { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/api/' }, + } ); + expect( console.log ).toHaveBeenCalledWith( 'Scope: starts_with "/api/".' ); + } ); + + it( 'reports that an omitted location applies to all requests', async () => { + await edgeWorkersNewCommand( [ 'demo' ] ); + + expect( project.writeWorkerManifest ).not.toHaveBeenCalled(); + expect( console.log ).toHaveBeenCalledWith( + 'Scope: all requests. Set location in worker.json before deployment to narrow it.' + ); + } ); + + it( 'reports a toolchain failure without success telemetry or guidance', async () => { + scaffoldWorker.mockImplementation( () => { + throw new Error( 'scaffold failed' ); + } ); + + await expect( edgeWorkersNewCommand( [ 'demo' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { + name: 'demo', + error: 'scaffold failed', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_new_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + } ); + + it( 'prints safe success guidance for a non-production environment', async () => { + await edgeWorkersNewCommand( [ 'demo' ] ); + + expect( scaffoldWorker ).toHaveBeenCalledWith( '/project', 'demo' ); + expect( console.log ).toHaveBeenCalledWith( ' vip @my-site.develop edge-workers deploy demo' ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_success', { + name: 'demo', + type: 'assemblyscript', + } ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 85881640e..5083f71c9 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -33,8 +33,11 @@ describe( 'edge-workers toolchains', () => { 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' ); + expect( pkg.dependencies[ '@automattic/vip-edge-workers-sdk' ] ).toBe( '0.3.0' ); + expect( pkg.devDependencies.assemblyscript ).toBe( '0.27.0' ); + + const readme = fs.readFileSync( path.join( project, 'README.md' ), 'utf8' ); + expect( readme ).toContain( 'Commit the generated `package-lock.json`' ); } ); it( 'scaffolds a project in an existing empty directory', () => { @@ -77,6 +80,12 @@ describe( 'edge-workers toolchains', () => { entry: 'assembly/index.ts', } ); expect( fs.existsSync( path.join( workerDir, 'assembly', 'index.ts' ) ) ).toBe( true ); + + const source = fs.readFileSync( path.join( workerDir, 'assembly', 'index.ts' ), 'utf8' ); + expect( source ).toContain( 'on_client_response' ); + expect( source ).not.toMatch( /^\s*on_client_request,?$/m ); + expect( source ).not.toMatch( /^\s*on_origin_request,?$/m ); + expect( source ).not.toMatch( /^\s*on_origin_response,?$/m ); } ); it( 'refuses to scaffold a worker that already exists', () => { diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md new file mode 100644 index 000000000..8c3ab2b15 --- /dev/null +++ b/docs/EDGE-WORKERS.md @@ -0,0 +1,223 @@ +# Edge workers + +This guide is the operator contract for scaffolding, validating, deploying, and managing VIP +edge workers with VIP-CLI. + +## 1. Prerequisites and the inactive-create guarantee + +Use Node.js 22.19.0 or newer, npm 8 or newer, an authenticated VIP-CLI session, and access to the +target application and environment. Start in a non-production environment. + +The safe lifecycle depends on `createEdgeWorker` creating every new worker with `active: false`. +That is a required platform contract, not a behavior this repository can prove. Release remains +blocked until Task 8 records authoritative evidence from the API owner that inactive creation is +guaranteed. Do not deploy or publish this CLI feature on the basis of the client implementation +alone. + +## 2. Project layout and exact dependency versions + +`vip edge-workers init` creates the project root; `new` and `build` extend it with the per-worker +and generated paths shown below: + +```text +edge-workers/ +├── edge-workers.json +├── package.json +├── tsconfig.json +├── lib/ # optional shared modules +├── build/ # created by build +└── workers/ + └── / + ├── worker.json + └── assembly/index.ts +``` + +`build/` is created when a worker is compiled and is ignored by Git. The generated direct +dependencies are exact: `@automattic/vip-edge-workers-sdk` is `0.3.0` and `assemblyscript` is +`0.27.0`. The starter exports only `alloc` and `on_client_response`; the other request phases are +commented examples and are not active WASM exports. + +## 3. `edge-workers.json` schema + +The project descriptor selects the toolchain for every worker in the project: + +```json +{ + "type": "assemblyscript", + "sdk": "@automattic/vip-edge-workers-sdk@0.3.0" +} +``` + +`type` is required and currently accepts only `assemblyscript`. `sdk` is optional metadata that +records the generated SDK dependency. Workers are discovered from `workers/*/worker.json`; the +project descriptor is not a worker registry. + +## 4. `worker.json` schema and location tri-state + +Each worker has its own manifest: + +```json +{ + "name": "security-headers", + "entry": "assembly/index.ts", + "location": { + "operator": "starts_with", + "value": "/api/" + }, + "on_failure": "continue" +} +``` + +- `name` is required, is unique within an environment, and must be a portable file name of at most + 64 characters. Path separators, control characters, Windows-reserved names, `.` and `..`, and + trailing dots or spaces are rejected. +- `entry` is required, must be relative, and must stay inside the worker directory. +- `location` is optional. Its `operator` is `contains`, `equals`, `starts_with`, or `ends_with`, and + `value` must be a non-empty string. +- `on_failure` is optional and is either `continue` or `error`. + +`location` has three distinct update states: + +| Manifest state | Create | Update | +| --------------- | --------------------- | --------------------------------------------------- | +| Omitted | Apply to all requests | Preserve the stored location | +| `null` | Apply to all requests | Clear the stored location and apply to all requests | +| Location object | Store that location | Replace the stored location | + +## 5. Safe scaffold workflow and the lockfile + +Initialize only an absent or empty target directory, install the pinned direct dependencies, and +commit the npm lockfile: + +```sh +vip edge-workers init +cd edge-workers +npm install +git add package-lock.json +git commit -m "build: lock edge-worker dependencies" +vip edge-workers new security-headers --location starts_with:/api/ +``` + +`new` validates the worker name and location before writing. Without `--location`, it reports that +the worker applies to all requests and directs you to edit `worker.json` before deployment if that +scope is too broad. A generated worker activates only the `client_response` phase. Implement and +review that handler before activating any commented phase example. + +Use `--path ` with `new`, `build`, `validate`, or `deploy` when auto-discovery should not be +used. + +## 6. Build and validation limits + +Build one worker with `vip edge-workers build `. Both `vip edge-workers build` and +`vip edge-workers build --all` build every discovered worker. Each successful line reports the +relative `build/.wasm` path and exact byte size. The build stops on the first compiler error; +an empty project is an error. + +Validate against a non-production environment before deploying: + +```sh +vip @example-app.develop edge-workers validate security-headers +vip @example-app.develop edge-workers validate --all +``` + +Validation parses each selected worker manifest. A normal build also reads the project descriptor; +then validation compiles the worker and sends the compiled WASM to the environment's server-side +dry-run validator. It reports validity and detected phases. Validation does not execute requests +and does not prove runtime behavior, performance, routing correctness, or application +compatibility. `--skip-build` reads the existing `build/.wasm`; it does not verify that the +artifact matches the current source. + +## 7. Deployment plan fields and production confirmation + +Deploy prepares every selected worker before applying any remote mutation. Preparation reconciles +the name as a create or update, builds or reads the artifact, validates it unless +`--skip-validate` is passed, determines location and source behavior, and then prints a plan with: + +- `worker`, `action`, and current `active` state; +- `current_scope` and `proposed_scope`; +- `validation` and detected `phases`; +- compiled `bytes`; and +- `source` mode (`store`, `omit`, or `preserve`). + +Review the entire plan. A production deploy requires an interactive confirmation naming every +worker, unless the operator deliberately passes `--skip-confirmation`. A worker name and `--all` +cannot be combined. `--skip-build`, `--skip-validate`, and `--skip-confirmation` remove safety +checks and should be used only when the omitted step has separate, current evidence. + +Subject to the unresolved inactive-create guarantee in section 1, creating or updating a worker +does not enable it. Enabling is a separate command. + +## 8. Source storage and `--skip-source` + +By default, deploy stores the worker's UTF-8 entry file alongside the WASM binary. It does not +archive the full project or shared modules. `get` omits source by default; pass `--source` to make +the additional on-demand source query and print the stored value. + +`--skip-source` means: do not store source on create; preserve stored source on update. Without +the flag, an update replaces the stored source with the current entry file, including an empty +file. The plan's `source` column shows the selected behavior before mutation. + +## 9. Enable, disable, delete, and rollback + +- `enable ` makes a deployed worker active. Production requires confirmation or the explicit + `--skip-confirmation` bypass. +- `disable ` makes a deployed worker inactive. +- `delete ` permanently removes a deployed worker. It prompts in every environment unless + `--force` is passed. + +There is no automatic rollback command and an `--all` failure is not rolled back. To recover, +disable the affected worker first, inspect its current state, then deploy a reviewed known-good +source/artifact or delete the worker if permanent removal is intended. Do not describe a redeploy +as a rollback unless the exact prior source, manifest, dependencies, and compiled artifact are +available and verified. + +## 10. `--all` and partial failures + +`build` with no name, `build --all`, `validate --all`, and `deploy --all` operate on workers in +stable name order. Deploy preparation completes for all selected workers before remote writes +begin, so a preparation or validation failure applies none of them. + +Application is sequential. If a create or update fails, deployment stops immediately and reports +the workers already applied, the failed worker, the workers not applied, and the original cause. +It does not retry or roll back already-applied workers. Reconcile the reported names with `list` +and `get` before retrying. + +## 11. Automation flags and `VIP_NON_INTERACTIVE=1` + +Automation should provide an explicit `@app.environment` alias (or equivalent app/environment +options), an explicit worker name or `--all`, and `--path` when the working directory is not inside +the project. Relevant bypasses are `--skip-build`, `--skip-validate`, `--skip-source`, and +`--skip-confirmation` for deploy; `--skip-build` for validate; `--skip-confirmation` for enable; +and `--force` for delete. `list` supports the global `--format` output option. + +Set `VIP_NON_INTERACTIVE=1` to prevent interactive edge-worker production confirmation. In that +mode, production `deploy` and `enable` fail closed unless `--skip-confirmation` is also supplied. +The environment variable is not approval: the bypass flag must represent an explicit operator or +pipeline authorization. Delete confirmation is independent; use `--force` only with equivalent +authorization. + +## 12. Non-production manual lifecycle + +Exercise the complete lifecycle on a non-production environment first: + +```sh +vip edge-workers init +cd edge-workers +npm install +# Review and commit package-lock.json. +vip edge-workers new security-headers --location starts_with:/api/ +# Implement and review workers/security-headers/assembly/index.ts. +vip edge-workers build security-headers +vip @example-app.develop edge-workers validate security-headers +vip @example-app.develop edge-workers deploy security-headers +vip @example-app.develop edge-workers list +vip @example-app.develop edge-workers get security-headers --source +vip @example-app.develop edge-workers enable security-headers +# Send controlled requests and observe application behavior. +vip @example-app.develop edge-workers disable security-headers +``` + +Do not enable the deployed worker until the inactive-create guarantee in section 1 has owner +evidence and the printed deployment plan matches the reviewed artifact, phases, scope, source +mode, and byte size. Promote to production only after the non-production lifecycle and a separate +production change review succeed. diff --git a/src/bin/vip-edge-workers-build.js b/src/bin/vip-edge-workers-build.js index ea09b4a31..0e27b6e4e 100644 --- a/src/bin/vip-edge-workers-build.js +++ b/src/bin/vip-edge-workers-build.js @@ -7,6 +7,7 @@ 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'; +import UserError from '../lib/user-error'; const usage = 'vip edge-workers build'; @@ -33,7 +34,9 @@ export async function edgeWorkersBuildCommand( args = [], opt = {} ) { 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`.' ); + throw new UserError( + 'No workers found in this project. Create one with `vip edge-workers new`.' + ); } for ( const worker of workers ) { diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js index 3ccfc09b0..92931f2ac 100644 --- a/src/bin/vip-edge-workers-deploy.js +++ b/src/bin/vip-edge-workers-deploy.js @@ -135,7 +135,11 @@ command( { .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 ) + .option( + 'skip-source', + 'Do not store source on create; preserve stored source on update.', + false + ) .option( 'skip-confirmation', 'Skip the production deployment confirmation.', false ) .examples( examples ) .argv( process.argv, edgeWorkersDeployCommand ); diff --git a/src/bin/vip-edge-workers-new.js b/src/bin/vip-edge-workers-new.js index 24740f26b..4429e5cd8 100644 --- a/src/bin/vip-edge-workers-new.js +++ b/src/bin/vip-edge-workers-new.js @@ -14,6 +14,7 @@ import { } from '../lib/edge-workers/project'; import { getToolchain } from '../lib/edge-workers/toolchains'; import { EDGE_WORKER_LOCATION_OPERATORS } from '../lib/edge-workers/types'; +import { validateWorkerName } from '../lib/edge-workers/validation'; import { trackEvent } from '../lib/tracker'; const usage = 'vip edge-workers new'; @@ -44,6 +45,7 @@ export async function edgeWorkersNewCommand( args = [], opt = {} ) { } try { + validateWorkerName( name ); // 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 } ); @@ -59,6 +61,11 @@ export async function edgeWorkersNewCommand( args = [], opt = {} ) { const entryDir = path.join( WORKERS_DIR, name ); console.log( `✓ Created worker "${ name }" in ${ path.join( projectDir, entryDir ) }` ); + console.log( + location + ? `Scope: ${ location.operator } "${ location.value }".` + : 'Scope: all requests. Set location in worker.json before deployment to narrow it.' + ); console.log( '\nEdit the worker, then deploy it with:' ); console.log( ` vip @my-site.develop edge-workers deploy ${ name }` ); } catch ( err ) { diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js index c0f57cc2c..0595420e6 100644 --- a/src/bin/vip-edge-workers-validate.js +++ b/src/bin/vip-edge-workers-validate.js @@ -12,7 +12,8 @@ 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.', + description: + 'Validate the local manifest and compiled WASM without deploying or executing requests.', }, { usage: 'vip @example-app.develop edge-workers validate --all', diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts index c8de96077..a3ff66eb6 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.3.0'; -export const ASSEMBLYSCRIPT_VERSION = '^0.27.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'; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts index 51ac29d3e..52be87405 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts @@ -55,6 +55,9 @@ vip edge-workers new my-worker # scaffold a new worker vip @my-site.develop edge-workers deploy my-worker \`\`\` +Commit the generated \`package-lock.json\` after \`npm install\` so installs use the +reviewed dependency tree in local development and automation. + Shared AssemblyScript modules go in \`lib/\` and can be imported from any worker. ## Parsing JSON @@ -65,36 +68,24 @@ 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 => {} ); + return `import { Response, onClientResponse } from '${ SDK_PACKAGE }'; -// Client response: runs before the response reaches the client. -onClientResponse( ( res: Response ): void => {} ); +export { alloc, on_client_response } from '${ SDK_PACKAGE }/assembly/index'; -// Origin response: runs after origin responds (cache miss); what you set here -// governs what the host caches. -onOriginResponse( ( res: Response ): void => {} ); +// Client response: runs before the response reaches the client. +onClientResponse( ( response: Response ): void => {} ); + +// Other available phases are intentionally inactive. To activate one, add its +// SDK type and hook to the import above, its host entrypoint to the export above, +// and its handler below. Do not export a phase without implementing its hook. +// +// Client request: Request, onClientRequest, on_client_request +// onClientRequest( ( request: Request ): void => {} ); +// +// Origin request: Request, onOriginRequest, on_origin_request +// onOriginRequest( ( request: Request ): void => {} ); +// +// Origin response: Response, onOriginResponse, on_origin_response +// onOriginResponse( ( response: Response ): void => {} ); `; } From 305128fe2f09c0675e9b16560dfef02f761faf02 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 13:20:17 -0500 Subject: [PATCH 31/41] fix(edge-workers): address scaffold review gaps --- __tests__/bin/vip-edge-workers-build.js | 6 +++++- __tests__/bin/vip-edge-workers-new.js | 26 +++++++++++++++++++++---- docs/EDGE-WORKERS.md | 2 ++ src/bin/vip-edge-workers-new.js | 2 +- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js index 10d05d638..9f585e781 100644 --- a/__tests__/bin/vip-edge-workers-build.js +++ b/__tests__/bin/vip-edge-workers-build.js @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { edgeWorkersBuildCommand } from '../../src/bin/vip-edge-workers-build'; import * as exit from '../../src/lib/cli/exit'; import * as lib from '../../src/lib/edge-workers'; @@ -106,7 +108,9 @@ describe( 'edgeWorkersBuildCommand()', () => { it( 'prints the relative artifact path and byte size', async () => { await edgeWorkersBuildCommand( [ 'alpha' ] ); - expect( console.log ).toHaveBeenCalledWith( '✓ Built "alpha" → build/alpha.wasm (5 bytes)' ); + expect( console.log ).toHaveBeenCalledWith( + `✓ Built "alpha" → ${ path.relative( '/project', '/project/build/alpha.wasm' ) } (5 bytes)` + ); expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_success', { count: 1, } ); diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js index 60a36bb92..9c4dd774b 100644 --- a/__tests__/bin/vip-edge-workers-new.js +++ b/__tests__/bin/vip-edge-workers-new.js @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { edgeWorkersNewCommand } from '../../src/bin/vip-edge-workers-new'; import * as exit from '../../src/lib/cli/exit'; import * as project from '../../src/lib/edge-workers/project'; @@ -63,12 +65,28 @@ describe( 'edgeWorkersNewCommand()', () => { it( 'writes and reports an explicit request scope', async () => { await edgeWorkersNewCommand( [ 'demo' ], { location: 'starts_with:/api/' } ); - expect( project.writeWorkerManifest ).toHaveBeenCalledWith( '/project/workers/demo', { + expect( project.writeWorkerManifest ).toHaveBeenCalledWith( + path.join( '/project', 'workers', 'demo' ), + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/api/' }, + } + ); + expect( console.log ).toHaveBeenCalledWith( 'Scope: starts_with "/api/".' ); + } ); + + it( 'rejects an explicitly empty location before resolving or modifying a project', async () => { + await expect( edgeWorkersNewCommand( [ 'demo' ], { location: '' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( scaffoldWorker ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { name: 'demo', - entry: 'assembly/index.ts', - location: { operator: 'starts_with', value: '/api/' }, + error: expect.stringContaining( 'Invalid location ""' ), } ); - expect( console.log ).toHaveBeenCalledWith( 'Scope: starts_with "/api/".' ); } ); it( 'reports that an omitted location applies to all requests', async () => { diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 8c3ab2b15..037603910 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -215,6 +215,8 @@ vip @example-app.develop edge-workers get security-headers --source vip @example-app.develop edge-workers enable security-headers # Send controlled requests and observe application behavior. vip @example-app.develop edge-workers disable security-headers +# Confirm permanent deletion when prompted. +vip @example-app.develop edge-workers delete security-headers ``` Do not enable the deployed worker until the inactive-create guarantee in section 1 has owner diff --git a/src/bin/vip-edge-workers-new.js b/src/bin/vip-edge-workers-new.js index 4429e5cd8..5350e677d 100644 --- a/src/bin/vip-edge-workers-new.js +++ b/src/bin/vip-edge-workers-new.js @@ -47,7 +47,7 @@ export async function edgeWorkersNewCommand( args = [], opt = {} ) { try { validateWorkerName( name ); // Parse up front so a bad --location doesn't leave a half-created worker behind. - const location = opt.location ? parseLocationOption( opt.location ) : undefined; + const location = opt.location !== undefined ? parseLocationOption( opt.location ) : undefined; const projectDir = resolveProjectDir( { path: opt.path } ); const descriptor = readProjectDescriptor( projectDir ); getToolchain( descriptor.type ).scaffoldWorker( projectDir, name ); From b118e52a7fc4c887be7978eb9cd37f2abb861522 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 13:33:05 -0500 Subject: [PATCH 32/41] test(edge-workers): complete lifecycle coverage --- __tests__/bin/vip-edge-workers-build.js | 5 ++++ __tests__/bin/vip-edge-workers-delete.js | 8 +++++++ __tests__/bin/vip-edge-workers-deploy.js | 8 +++++++ __tests__/bin/vip-edge-workers-disable.js | 8 +++++++ __tests__/bin/vip-edge-workers-enable.js | 2 ++ __tests__/bin/vip-edge-workers-get.js | 5 ++++ __tests__/bin/vip-edge-workers-init.js | 3 +++ __tests__/bin/vip-edge-workers-list.js | 27 ++++++++++++++++++++++ __tests__/bin/vip-edge-workers-new.js | 3 +++ __tests__/bin/vip-edge-workers-validate.js | 12 ++++++++++ src/bin/vip-edge-workers-delete.js | 2 +- src/bin/vip-edge-workers-disable.js | 2 +- src/bin/vip-edge-workers-enable.js | 2 +- src/bin/vip-edge-workers-validate.js | 12 +++++----- 14 files changed, 90 insertions(+), 9 deletions(-) diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js index 9f585e781..e44f71141 100644 --- a/__tests__/bin/vip-edge-workers-build.js +++ b/__tests__/bin/vip-edge-workers-build.js @@ -103,6 +103,8 @@ describe( 'edgeWorkersBuildCommand()', () => { 'edge_workers_build_command_success', expect.anything() ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); } ); it( 'prints the relative artifact path and byte size', async () => { @@ -114,5 +116,8 @@ describe( 'edgeWorkersBuildCommand()', () => { expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_success', { count: 1, } ); + const buildOrder = lib.buildWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( buildOrder ).toBeLessThan( successOrder ); } ); } ); diff --git a/__tests__/bin/vip-edge-workers-delete.js b/__tests__/bin/vip-edge-workers-delete.js index 2b833f1cd..6cd98ff68 100644 --- a/__tests__/bin/vip-edge-workers-delete.js +++ b/__tests__/bin/vip-edge-workers-delete.js @@ -68,6 +68,14 @@ describe( 'edgeWorkersDeleteCommand()', () => { expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'missing' ); expect( confirm ).not.toHaveBeenCalled(); expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + expect.anything() + ); } ); it( 'confirms the exact resolved target before deleting', async () => { diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index 0f4ac881b..2ec501a3c 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -288,6 +288,14 @@ describe( 'edgeWorkersDeployCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( 'Failed to deploy edge worker: worker "beta" failed validation' ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); } ); it( 'reports exact progress and the original cause after a partial failure', async () => { diff --git a/__tests__/bin/vip-edge-workers-disable.js b/__tests__/bin/vip-edge-workers-disable.js index 5176536d5..8b6457873 100644 --- a/__tests__/bin/vip-edge-workers-disable.js +++ b/__tests__/bin/vip-edge-workers-disable.js @@ -65,6 +65,14 @@ describe( 'edgeWorkersDisableCommand()', () => { expect( confirm ).not.toHaveBeenCalled(); expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + expect.anything() + ); } ); it( 'reports API rejection without success output or telemetry', async () => { diff --git a/__tests__/bin/vip-edge-workers-enable.js b/__tests__/bin/vip-edge-workers-enable.js index 2fc4cc0bd..52e2409cf 100644 --- a/__tests__/bin/vip-edge-workers-enable.js +++ b/__tests__/bin/vip-edge-workers-enable.js @@ -77,6 +77,8 @@ describe( 'edgeWorkersEnableCommand()', () => { expect( confirm ).not.toHaveBeenCalled(); expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, 3, diff --git a/__tests__/bin/vip-edge-workers-get.js b/__tests__/bin/vip-edge-workers-get.js index a0654a469..0e3347269 100644 --- a/__tests__/bin/vip-edge-workers-get.js +++ b/__tests__/bin/vip-edge-workers-get.js @@ -67,6 +67,9 @@ describe( 'edgeWorkersGetCommand()', () => { 'edge_workers_get_command_success', { name: 'headers' } ); + const apiOrder = edgeWorkersApi.getEdgeWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( apiOrder ).toBeLessThan( successOrder ); } ); it( 'requests and prints stored source only for --source', async () => { @@ -100,6 +103,8 @@ describe( 'edgeWorkersGetCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( 'No edge worker named "missing" is deployed to this environment.' ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, 3, diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js index 7e6c08ef5..704b06a3e 100644 --- a/__tests__/bin/vip-edge-workers-init.js +++ b/__tests__/bin/vip-edge-workers-init.js @@ -46,6 +46,9 @@ describe( 'edgeWorkersInitCommand()', () => { expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'Created a new assemblyscript edge-workers project' ) ); + const scaffoldOrder = scaffoldProject.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( scaffoldOrder ).toBeLessThan( successOrder ); } ); it( 'reports an unsupported type without scaffolding or success telemetry', async () => { diff --git a/__tests__/bin/vip-edge-workers-list.js b/__tests__/bin/vip-edge-workers-list.js index 616493d2d..89ae1743d 100644 --- a/__tests__/bin/vip-edge-workers-list.js +++ b/__tests__/bin/vip-edge-workers-list.js @@ -1,6 +1,7 @@ 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'; +import * as tracker from '../../src/lib/tracker'; jest.spyOn( console, 'log' ).mockImplementation( () => {} ); jest.spyOn( exit, 'withError' ).mockImplementation( () => { @@ -56,6 +57,15 @@ describe( 'edgeWorkersListCommand()', () => { modified: '2026-06-04', }, ] ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_success', + { count: 1 } + ); + const apiOrder = api.listEdgeWorkers.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( apiOrder ).toBeLessThan( successOrder ); } ); it( 'shows a friendly message and returns an empty array when there are none', async () => { @@ -69,10 +79,27 @@ describe( 'edgeWorkersListCommand()', () => { ); } ); + it( 'returns empty JSON data without friendly prose when there are no workers', async () => { + api.listEdgeWorkers.mockResolvedValue( [] ); + + const rows = await edgeWorkersListCommand( [], { ...opts, format: 'json' } ); + + expect( rows ).toEqual( [] ); + expect( console.log ).not.toHaveBeenCalled(); + } ); + 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' ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_success', + expect.anything() + ); } ); } ); diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js index 9c4dd774b..632a22e92 100644 --- a/__tests__/bin/vip-edge-workers-new.js +++ b/__tests__/bin/vip-edge-workers-new.js @@ -125,5 +125,8 @@ describe( 'edgeWorkersNewCommand()', () => { name: 'demo', type: 'assemblyscript', } ); + const scaffoldOrder = scaffoldWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( scaffoldOrder ).toBeLessThan( successOrder ); } ); } ); diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js index 2c3c3c611..e86244428 100644 --- a/__tests__/bin/vip-edge-workers-validate.js +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -68,6 +68,9 @@ describe( 'edgeWorkersValidateCommand()', () => { expect( lib.buildWorker ).toHaveBeenCalledWith( '/proj', worker ); expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'V0FTTQ==' ); expect( exit.withError ).not.toHaveBeenCalled(); + const validationOrder = api.validateEdgeWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( validationOrder ).toBeLessThan( successOrder ); } ); it( 'uses the prebuilt artifact with --skip-build', async () => { @@ -93,6 +96,14 @@ describe( 'edgeWorkersValidateCommand()', () => { 'EXIT_WITH_ERROR' ); expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( 'failed validation' ) ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_success', + expect.anything() + ); } ); it( 'does not report validation success when the API rejects', async () => { @@ -126,6 +137,7 @@ describe( 'edgeWorkersValidateCommand()', () => { it( 'errors when no worker name and no --all is given', async () => { await expect( edgeWorkersValidateCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( 'supply a worker name' ) ); diff --git a/src/bin/vip-edge-workers-delete.js b/src/bin/vip-edge-workers-delete.js index 9b380fe7d..053aeef65 100644 --- a/src/bin/vip-edge-workers-delete.js +++ b/src/bin/vip-edge-workers-delete.js @@ -25,7 +25,7 @@ export async function edgeWorkersDeleteCommand( args = [], opt = {} ) { try { const worker = await findEdgeWorkerByName( app.id, env.id, name ); if ( ! worker ) { - exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); } await confirmEdgeWorkerDeletion( diff --git a/src/bin/vip-edge-workers-disable.js b/src/bin/vip-edge-workers-disable.js index 99960ee54..f8d7cbb0f 100644 --- a/src/bin/vip-edge-workers-disable.js +++ b/src/bin/vip-edge-workers-disable.js @@ -23,7 +23,7 @@ export async function edgeWorkersDisableCommand( args = [], opt = {} ) { try { const worker = await findEdgeWorkerByName( app.id, env.id, name ); if ( ! worker ) { - exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); } await setEdgeWorkerActive( env.id, worker.id, false ); diff --git a/src/bin/vip-edge-workers-enable.js b/src/bin/vip-edge-workers-enable.js index 0ffd91e76..81b798b91 100644 --- a/src/bin/vip-edge-workers-enable.js +++ b/src/bin/vip-edge-workers-enable.js @@ -28,7 +28,7 @@ export async function edgeWorkersEnableCommand( args = [], opt = {} ) { try { const worker = await findEdgeWorkerByName( app.id, env.id, name ); if ( ! worker ) { - exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); } await confirmProductionEdgeWorkerMutation( diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js index 0595420e6..821460d87 100644 --- a/src/bin/vip-edge-workers-validate.js +++ b/src/bin/vip-edge-workers-validate.js @@ -42,12 +42,12 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { if ( opt.all ) { workers = discoverWorkers( projectDir ); if ( ! workers.length ) { - exit.withError( 'No workers found in this project.' ); + throw new Error( '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`.' ); + throw new Error( 'Please supply a worker name to validate, or pass `--all`.' ); } // Validate sequentially for clear, ordered output. @@ -69,6 +69,10 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { } } + if ( invalidCount > 0 ) { + throw new Error( `${ invalidCount } worker(s) failed validation.` ); + } + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_success', { count: workers.length, invalid: invalidCount, @@ -80,10 +84,6 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { } ); exit.withError( `Failed to validate edge worker: ${ err.message }` ); } - - if ( invalidCount > 0 ) { - exit.withError( `${ invalidCount } worker(s) failed validation.` ); - } } command( { From edaebb703153b2786d02f4ed1548968f851584c6 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 13:45:39 -0500 Subject: [PATCH 33/41] test(edge-workers): assert single terminal exits --- __tests__/bin/vip-edge-workers-init.js | 1 + __tests__/bin/vip-edge-workers-new.js | 1 + __tests__/bin/vip-edge-workers-validate.js | 1 + 3 files changed, 3 insertions(+) diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js index 704b06a3e..5b441d523 100644 --- a/__tests__/bin/vip-edge-workers-init.js +++ b/__tests__/bin/vip-edge-workers-init.js @@ -66,6 +66,7 @@ describe( 'edgeWorkersInitCommand()', () => { expect.anything() ); expect( console.log ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); } ); it( 'reports a scaffold collision without success telemetry or output', async () => { diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js index 632a22e92..33b5838dd 100644 --- a/__tests__/bin/vip-edge-workers-new.js +++ b/__tests__/bin/vip-edge-workers-new.js @@ -114,6 +114,7 @@ describe( 'edgeWorkersNewCommand()', () => { expect.anything() ); expect( console.log ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); } ); it( 'prints safe success guidance for a non-production environment', async () => { diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js index e86244428..71fd63cd2 100644 --- a/__tests__/bin/vip-edge-workers-validate.js +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -122,6 +122,7 @@ describe( 'edgeWorkersValidateCommand()', () => { 'edge_workers_validate_command_success', expect.anything() ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); } ); it( 'validates every worker with --all', async () => { From 45a25a7b5577c83e082ebd82181e3848ae8102cb Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 14:22:56 -0500 Subject: [PATCH 34/41] fix(edge-workers): close final hardening gaps --- __tests__/bin/vip-edge-workers-build.js | 15 ++- __tests__/bin/vip-edge-workers-delete.js | 6 + __tests__/bin/vip-edge-workers-deploy.js | 23 ++++ __tests__/bin/vip-edge-workers-disable.js | 6 + __tests__/bin/vip-edge-workers-enable.js | 6 + __tests__/bin/vip-edge-workers-get.js | 31 +++++ __tests__/bin/vip-edge-workers-init.js | 2 +- __tests__/bin/vip-edge-workers-list.js | 47 +++++++ __tests__/bin/vip-edge-workers-new.js | 6 +- __tests__/bin/vip-edge-workers-validate.js | 23 ++++ __tests__/lib/api-edge-workers.test.ts | 35 ++++++ __tests__/lib/cli/command.js | 11 ++ __tests__/lib/cli/format.js | 22 +++- .../lib/edge-workers/confirmation.test.ts | 39 ++++++ __tests__/lib/edge-workers/deployment.test.ts | 35 ++++++ __tests__/lib/edge-workers/location.js | 7 ++ __tests__/lib/edge-workers/project.js | 60 +++++++++ __tests__/lib/edge-workers/toolchains.js | 119 ++++++++++++++++++ __tests__/lib/edge-workers/validation.test.ts | 16 +++ docs/EDGE-WORKERS.md | 6 +- src/bin/vip-edge-workers-build.js | 10 +- src/bin/vip-edge-workers-delete.js | 7 +- src/bin/vip-edge-workers-deploy.js | 17 +-- src/bin/vip-edge-workers-disable.js | 7 +- src/bin/vip-edge-workers-enable.js | 7 +- src/bin/vip-edge-workers-get.js | 31 +++-- src/bin/vip-edge-workers-init.js | 19 +-- src/bin/vip-edge-workers-list.js | 21 ++-- src/bin/vip-edge-workers-new.js | 17 ++- src/bin/vip-edge-workers-validate.js | 18 ++- src/lib/api/edge-workers.ts | 27 +++- src/lib/cli/format.ts | 12 +- src/lib/edge-workers/confirmation.ts | 19 ++- src/lib/edge-workers/deployment.ts | 9 +- src/lib/edge-workers/index.ts | 33 +++-- src/lib/edge-workers/location.ts | 7 +- src/lib/edge-workers/output.ts | 16 +++ src/lib/edge-workers/project.ts | 6 +- .../toolchains/assemblyscript/index.ts | 31 +++-- src/lib/edge-workers/validation.ts | 111 +++++++++++++++- 40 files changed, 835 insertions(+), 105 deletions(-) create mode 100644 src/lib/edge-workers/output.ts diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js index e44f71141..02daf3d3b 100644 --- a/__tests__/bin/vip-edge-workers-build.js +++ b/__tests__/bin/vip-edge-workers-build.js @@ -83,22 +83,29 @@ describe( 'edgeWorkersBuildCommand()', () => { ); expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { name: undefined, - error: 'No workers found in this project. Create one with `vip edge-workers new`.', + error: 'build_failed', } ); expect( lib.buildWorker ).not.toHaveBeenCalled(); } ); - it( 'reports a build failure without success telemetry', async () => { + it( 'keeps compiler diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_BUILD_SECRET'; + const sourcePath = '/private/customer/project/workers/alpha/assembly/index.ts'; + const diagnostic = `Compilation failed at ${ sourcePath }: const token = "${ secret }";\n\u001b[31merror`; lib.buildWorker.mockImplementation( () => { - throw new Error( 'compiler failed' ); + throw new Error( diagnostic ); } ); await expect( edgeWorkersBuildCommand( [ 'alpha' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { name: 'alpha', - error: 'compiler failed', + error: 'build_failed', } ); + expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); expect( tracker.trackEvent ).not.toHaveBeenCalledWith( 'edge_workers_build_command_success', expect.anything() diff --git a/__tests__/bin/vip-edge-workers-delete.js b/__tests__/bin/vip-edge-workers-delete.js index 6cd98ff68..5e387ac02 100644 --- a/__tests__/bin/vip-edge-workers-delete.js +++ b/__tests__/bin/vip-edge-workers-delete.js @@ -130,6 +130,12 @@ describe( 'edgeWorkersDeleteCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( 'Failed to delete edge worker: API unavailable' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_error', + { name: 'headers', error: 'delete_failed' } + ); expect( console.log ).not.toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index 2ec501a3c..ea8e74b02 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -298,6 +298,29 @@ describe( 'edgeWorkersDeployCommand()', () => { ); } ); + it( 'keeps preparation diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_DEPLOY_SECRET'; + const sourcePath = '/private/customer/project/workers/headers/assembly/index.ts'; + deployment.prepareEdgeWorkerDeploymentPlan.mockRejectedValue( + new Error( `Compiler printed ${ sourcePath }: ${ secret }\n\u001b[31merror` ) + ); + + await expect( edgeWorkersDeployCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_error', + { name: 'headers', error: 'deploy_failed' } + ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); + } ); + it( 'reports exact progress and the original cause after a partial failure', async () => { const cause = new Error( 'request timed out' ); deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( diff --git a/__tests__/bin/vip-edge-workers-disable.js b/__tests__/bin/vip-edge-workers-disable.js index 8b6457873..2b9f413dd 100644 --- a/__tests__/bin/vip-edge-workers-disable.js +++ b/__tests__/bin/vip-edge-workers-disable.js @@ -86,6 +86,12 @@ describe( 'edgeWorkersDisableCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( 'Failed to disable edge worker: API unavailable' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_error', + { name: 'headers', error: 'disable_failed' } + ); expect( console.log ).not.toHaveBeenCalledWith( '✓ Disabled edge worker "headers".' ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, diff --git a/__tests__/bin/vip-edge-workers-enable.js b/__tests__/bin/vip-edge-workers-enable.js index 52e2409cf..a8b0424c1 100644 --- a/__tests__/bin/vip-edge-workers-enable.js +++ b/__tests__/bin/vip-edge-workers-enable.js @@ -148,6 +148,12 @@ describe( 'edgeWorkersEnableCommand()', () => { expect( exit.withError ).toHaveBeenCalledWith( 'Failed to enable edge worker: API unavailable' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_error', + { name: 'headers', error: 'enable_failed' } + ); expect( console.log ).not.toHaveBeenCalledWith( '✓ Enabled edge worker "headers".' ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, diff --git a/__tests__/bin/vip-edge-workers-get.js b/__tests__/bin/vip-edge-workers-get.js index 0e3347269..ed397ab46 100644 --- a/__tests__/bin/vip-edge-workers-get.js +++ b/__tests__/bin/vip-edge-workers-get.js @@ -95,6 +95,31 @@ describe( 'edgeWorkersGetCommand()', () => { expect( console.log ).toHaveBeenCalledWith( '(no source stored)' ); } ); + it( 'neutralizes terminal controls in remote details and stored source', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { + ...worker, + name: 'headers\u001b[2J', + location: { operator: 'starts_with', value: '/api/\u001b[31m' }, + phases: [ 'client_response\nforged' ], + onFailure: 'continue\u0007', + createdAt: '2026-08-18\rforged', + updatedAt: '2026-08-19\u009b31m', + source: 'export {};\n\u001b[2JSECRET', + } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + const output = console.log.mock.calls + .flat() + .filter( value => value !== '\nSource:' ) + .join( '|' ); + // eslint-disable-next-line no-control-regex + expect( output ).not.toMatch( /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/ ); + expect( output ).not.toContain( 'client_response\nforged' ); + expect( output ).toContain( String.raw`\u001b` ); + expect( output ).toContain( String.raw`\u000a` ); + } ); + it( 'reports a missing worker without false success', async () => { edgeWorkersApi.getEdgeWorker.mockResolvedValue( null ); @@ -119,6 +144,12 @@ describe( 'edgeWorkersGetCommand()', () => { await expect( edgeWorkersGetCommand( [ 'headers' ], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); expect( exit.withError ).toHaveBeenCalledWith( 'Failed to get edge worker: API unavailable' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_error', + { name: 'headers', error: 'get_failed' } + ); expect( console.log ).not.toHaveBeenCalled(); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( 1, diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js index 5b441d523..c863201cf 100644 --- a/__tests__/bin/vip-edge-workers-init.js +++ b/__tests__/bin/vip-edge-workers-init.js @@ -80,7 +80,7 @@ describe( 'edgeWorkersInitCommand()', () => { expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_init_command_error', { type: 'assemblyscript', - error: 'target is not empty', + error: 'init_failed', } ); expect( tracker.trackEvent ).not.toHaveBeenCalledWith( 'edge_workers_init_command_success', diff --git a/__tests__/bin/vip-edge-workers-list.js b/__tests__/bin/vip-edge-workers-list.js index 89ae1743d..2af99918c 100644 --- a/__tests__/bin/vip-edge-workers-list.js +++ b/__tests__/bin/vip-edge-workers-list.js @@ -88,11 +88,58 @@ describe( 'edgeWorkersListCommand()', () => { expect( console.log ).not.toHaveBeenCalled(); } ); + it( 'neutralizes terminal controls in remote table fields', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers\u001b[2J', + active: true, + phases: [ 'client_response\nforged' ], + location: { operator: 'starts_with', value: '/api/\u001b[31m' }, + onFailure: 'continue\u0007', + updatedAt: '2026-06-04\rforged', + }, + ] ); + + const [ row ] = await edgeWorkersListCommand( [], opts ); + const output = Object.values( row ).join( '|' ); + + // eslint-disable-next-line no-control-regex + expect( output ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( output ).toContain( String.raw`\u001b` ); + expect( output ).toContain( String.raw`\u000a` ); + } ); + + it( 'leaves JSON row values intact for JSON.stringify to escape', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers\u001b[2J', + active: false, + phases: [], + location: null, + onFailure: 'continue', + updatedAt: '2026-06-04', + }, + ] ); + + const [ row ] = await edgeWorkersListCommand( [], { ...opts, format: 'json' } ); + + expect( row.name ).toBe( 'headers\u001b[2J' ); + expect( JSON.stringify( [ row ] ) ).toContain( String.raw`\u001b` ); + } ); + 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' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_error', + { error: 'list_failed' } + ); expect( exit.withError ).toHaveBeenCalledTimes( 1 ); expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js index 33b5838dd..c083dbb2d 100644 --- a/__tests__/bin/vip-edge-workers-new.js +++ b/__tests__/bin/vip-edge-workers-new.js @@ -58,7 +58,7 @@ describe( 'edgeWorkersNewCommand()', () => { expect( scaffoldWorker ).not.toHaveBeenCalled(); expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { name: 'bad/name', - error: 'Invalid worker name "bad/name".', + error: 'new_failed', } ); } ); @@ -85,7 +85,7 @@ describe( 'edgeWorkersNewCommand()', () => { expect( scaffoldWorker ).not.toHaveBeenCalled(); expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { name: 'demo', - error: expect.stringContaining( 'Invalid location ""' ), + error: 'new_failed', } ); } ); @@ -107,7 +107,7 @@ describe( 'edgeWorkersNewCommand()', () => { expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { name: 'demo', - error: 'scaffold failed', + error: 'new_failed', } ); expect( tracker.trackEvent ).not.toHaveBeenCalledWith( 'edge_workers_new_command_success', diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js index 71fd63cd2..9f35010ea 100644 --- a/__tests__/bin/vip-edge-workers-validate.js +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -125,6 +125,29 @@ describe( 'edgeWorkersValidateCommand()', () => { expect( exit.withError ).toHaveBeenCalledTimes( 1 ); } ); + it( 'keeps compiler diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_VALIDATE_SECRET'; + const sourcePath = '/private/customer/project/workers/my-worker/assembly/index.ts'; + lib.buildWorker.mockImplementation( () => { + throw new Error( `Compiler printed ${ sourcePath }: ${ secret }\n\u001b[31merror` ); + } ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_error', + { name: 'my-worker', error: 'validate_failed' } + ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); + } ); + it( 'validates every worker with --all', async () => { project.discoverWorkers.mockReturnValue( [ worker, diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts index db97be2fa..d43311f3d 100644 --- a/__tests__/lib/api-edge-workers.test.ts +++ b/__tests__/lib/api-edge-workers.test.ts @@ -6,10 +6,12 @@ import { createEdgeWorker, deleteEdgeWorker, getEdgeWorker, + listEdgeWorkers, setEdgeWorkerActive, updateEdgeWorker, validateEdgeWorker, } from '../../src/lib/api/edge-workers'; +import UserError from '../../src/lib/user-error'; import type { DocumentNode } from 'graphql'; @@ -59,6 +61,39 @@ describe( 'edge worker read query contracts', () => { expect( query ).toContain( 'source' ); expect( query ).not.toContain( 'wasmBinary' ); } ); + + it.each( [ + [ 'missing data', undefined ], + [ 'null app', { app: null } ], + [ 'non-object app', { app: 'not-an-app' } ], + [ 'missing environments', { app: {} } ], + [ 'null environments', { app: { environments: null } } ], + [ 'non-array environments', { app: { environments: {} } } ], + [ 'malformed environment', { app: { environments: [ null ] } } ], + [ + 'wrongly typed target environment id', + { app: { environments: [ { id: '3', edgeWorkers: [] } ] } }, + ], + [ 'missing target environment', { app: { environments: [ { id: 4, edgeWorkers: [] } ] } } ], + [ 'missing edgeWorkers', { app: { environments: [ { id: 3 } ] } } ], + [ 'null edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: null } ] } } ], + [ 'non-array edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: {} } ] } } ], + ] )( 'fails closed for %s', async ( _label, data ) => { + mockQuery.mockResolvedValueOnce( { data: data as never } ); + + const read = listEdgeWorkers( 1, 3 ); + + await expect( read ).rejects.toBeInstanceOf( UserError ); + await expect( read ).rejects.toThrow( /EdgeWorkers query returned an invalid response/ ); + } ); + + it( 'preserves a legitimate empty edgeWorkers array', async () => { + mockQuery.mockResolvedValueOnce( { + data: { app: { environments: [ { id: 3, edgeWorkers: [] } ] } }, + } ); + + await expect( listEdgeWorkers( 1, 3 ) ).resolves.toEqual( [] ); + } ); } ); describe( 'edge worker mutation result contracts', () => { diff --git a/__tests__/lib/cli/command.js b/__tests__/lib/cli/command.js index 2a42541e5..e4c58e9fd 100644 --- a/__tests__/lib/cli/command.js +++ b/__tests__/lib/cli/command.js @@ -197,6 +197,17 @@ describe( 'utils/cli/command', () => { } ); describe( 'option parsing', () => { + it( 'prints valid empty JSON from the real formatted command wrapper', async () => { + const cmd = command( { format: true, requiredArgs: 0 } ); + + await cmd.argv( + [ process.execPath, '/path/to/vip-edge-workers-list.js', '--format=json' ], + async () => [] + ); + + expect( console.log ).toHaveBeenCalledWith( '[]' ); + } ); + it( 'does not duplicate defaults when an explicit value matches the default', async () => { const cmd = command( { requiredArgs: 0 } ).option( 'type', 'Log type', 'app' ); diff --git a/__tests__/lib/cli/format.js b/__tests__/lib/cli/format.js index 5fd8d8d06..34e014621 100644 --- a/__tests__/lib/cli/format.js +++ b/__tests__/lib/cli/format.js @@ -1,6 +1,26 @@ -import { formatBytes, formatDuration, requoteArgs, table } from '../../../src/lib/cli/format'; +import { + formatBytes, + formatData, + formatDuration, + requoteArgs, + table, +} from '../../../src/lib/cli/format'; describe( 'utils/cli/format', () => { + it( 'formats an empty JSON collection as a valid array', () => { + expect( formatData( [], 'json' ) ).toBe( '[]' ); + } ); + + it( 'JSON-escapes terminal controls without changing parsed data', () => { + const data = [ { value: 'safe\u007f\u009b[31m' } ]; + + const formatted = formatData( data, 'json' ); + + expect( formatted ).not.toMatch( /[\u007f-\u009f]/ ); + expect( formatted ).toContain( String.raw`\u007f\u009b` ); + expect( JSON.parse( formatted ) ).toEqual( data ); + } ); + describe( 'requoteArgs', () => { it.each( [ { diff --git a/__tests__/lib/edge-workers/confirmation.test.ts b/__tests__/lib/edge-workers/confirmation.test.ts index bddd2785e..1d7603055 100644 --- a/__tests__/lib/edge-workers/confirmation.test.ts +++ b/__tests__/lib/edge-workers/confirmation.test.ts @@ -134,6 +134,26 @@ describe( 'confirmProductionEdgeWorkerMutation()', () => { ); } ); + it( 'neutralizes terminal controls in production confirmation identities', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { + ...productionRequest, + appName: 'example\u001b[2J', + envType: 'production', + workerNames: [ 'headers\nforged' ], + }, + confirmFn + ); + + const message = confirmFn.mock.calls[ 0 ][ 0 ]; + // eslint-disable-next-line no-control-regex + expect( message ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( message ).toContain( String.raw`\u001b` ); + expect( message ).toContain( String.raw`\u000a` ); + } ); + it( 'rejects non-interactive production without bypass', async () => { const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); @@ -195,6 +215,25 @@ describe( 'confirmEdgeWorkerDeletion()', () => { ); } ); + it( 'neutralizes terminal controls in deletion confirmation identities', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmEdgeWorkerDeletion( + { + ...request, + appName: 'example\u001b[2J', + workerName: 'headers\nforged', + }, + confirmFn + ); + + const message = confirmFn.mock.calls[ 0 ][ 0 ]; + // eslint-disable-next-line no-control-regex + expect( message ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( message ).toContain( String.raw`\u001b` ); + expect( message ).toContain( String.raw`\u000a` ); + } ); + it( 'throws UserError when the user declines deletion', async () => { const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); diff --git a/__tests__/lib/edge-workers/deployment.test.ts b/__tests__/lib/edge-workers/deployment.test.ts index 9b9889921..6e15eab5f 100644 --- a/__tests__/lib/edge-workers/deployment.test.ts +++ b/__tests__/lib/edge-workers/deployment.test.ts @@ -222,6 +222,22 @@ describe( 'prepareEdgeWorkerDeploymentPlan()', () => { expect( updateEdgeWorker ).not.toHaveBeenCalled(); } ); + it( 'does no preparation or mutation when the remote read state is malformed', async () => { + jest + .mocked( listEdgeWorkers ) + .mockRejectedValue( new Error( 'EdgeWorkers query returned an invalid response.' ) ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + /invalid response/ + ); + expect( buildWorker ).not.toHaveBeenCalled(); + expect( readPrebuiltWorker ).not.toHaveBeenCalled(); + expect( validateEdgeWorker ).not.toHaveBeenCalled(); + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + it( 'uses prebuilt artifacts and records skipped validation when requested', async () => { jest.mocked( readPrebuiltWorker ).mockReturnValue( { wasmPath: '/project/build/headers.wasm', @@ -308,6 +324,25 @@ describe( 'deploymentPlanRows()', () => { ] ); expect( plan[ 0 ] ).toEqual( itemBeforePreview ); } ); + + it( 'neutralizes terminal controls in remote preview fields', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ + remoteWorker( { + location: { operator: 'starts_with', value: '/api/\u001b[2J' }, + } ), + ] ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response\u001b[31m' as never ], + errors: [], + } ); + + const [ row ] = deploymentPlanRows( await prepareEdgeWorkerDeploymentPlan( options() ) ); + const rendered = Object.values( row ).join( '|' ); + + expect( rendered ).not.toContain( '\u001b' ); + expect( rendered ).toContain( String.raw`\u001b` ); + } ); } ); describe( 'applyEdgeWorkerDeploymentPlan()', () => { diff --git a/__tests__/lib/edge-workers/location.js b/__tests__/lib/edge-workers/location.js index 3f3da23f9..61145f745 100644 --- a/__tests__/lib/edge-workers/location.js +++ b/__tests__/lib/edge-workers/location.js @@ -18,4 +18,11 @@ describe( 'parseLocationOption()', () => { expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); } ); + + it.each( [ 'starts_with:/api/\u001b[2J', 'equals:/safe\nforged output' ] )( + 'rejects control characters in %p', + raw => { + expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); + } + ); } ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js index 23b773fdd..b83b83bfe 100644 --- a/__tests__/lib/edge-workers/project.js +++ b/__tests__/lib/edge-workers/project.js @@ -144,6 +144,14 @@ describe( 'edge-workers project', () => { expect( findWorker( project, 'alpha' ).manifest.name ).toBe( 'alpha' ); } ); + it( 'prefers an exact manifest name over a directory-name fallback', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'target', { name: 'directory-fallback' } ); + makeWorker( project, 'other-directory', { name: 'target' } ); + + expect( findWorker( project, 'target' ).manifest.name ).toBe( 'target' ); + } ); + it( 'throws listing available workers when not found', () => { const project = makeProject( path.join( tmp, 'proj' ) ); makeWorker( project, 'alpha' ); @@ -160,6 +168,22 @@ describe( 'edge-workers project', () => { expect( () => readWorkerSource( worker ) ).toThrow( /Could not read worker source/ ); } ); + it( 'rejects an entry symlink that escapes the worker directory', () => { + const workerDir = path.join( tmp, 'worker' ); + const entryDir = path.join( workerDir, 'assembly' ); + const outside = path.join( tmp, 'secret.ts' ); + fs.mkdirSync( entryDir, { recursive: true } ); + fs.writeFileSync( outside, 'TOP_SECRET_SOURCE' ); + fs.symlinkSync( outside, path.join( entryDir, 'index.ts' ), 'file' ); + + const worker = { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readWorkerSource( worker ) ).toThrow( /Worker entry must stay within/ ); + } ); + it( 'rejects traversal in prebuilt artifact names', () => { const worker = { dir: path.join( tmp, 'worker' ), @@ -169,5 +193,41 @@ describe( 'edge-workers project', () => { /Invalid worker name/ ); } ); + + it( 'rejects a prebuilt artifact symlink that escapes the project', () => { + const project = makeProject( path.join( tmp, 'project' ) ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'secret.wasm' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outside, 'TOP_SECRET_WASM' ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readPrebuiltWorker( project, worker ) ).toThrow( + /Worker build artifact must stay within/ + ); + } ); + + it( 'rejects a prebuilt artifact symlink that escapes the build directory', () => { + const project = makeProject( path.join( tmp, 'project' ) ); + const buildDir = path.join( project, 'build' ); + const outsideBuild = path.join( project, 'project-secret.wasm' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outsideBuild, 'PROJECT_SECRET_WASM' ); + fs.symlinkSync( outsideBuild, path.join( buildDir, 'demo.wasm' ), 'file' ); + + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readPrebuiltWorker( project, worker ) ).toThrow( + /Worker build artifact must stay within/ + ); + } ); } ); } ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 5083f71c9..9888d0401 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -5,11 +6,21 @@ import path from 'node:path'; import { readProjectDescriptor, readWorkerManifest } from '../../../src/lib/edge-workers/project'; import { getToolchain } from '../../../src/lib/edge-workers/toolchains'; +jest.mock( 'node:child_process', () => ( { + spawnSync: jest.fn(), +} ) ); + describe( 'edge-workers toolchains', () => { let tmp; beforeEach( () => { tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-tc-' ) ); + spawnSync.mockClear(); + spawnSync.mockImplementation( ( _command, args ) => { + const outFile = args[ args.indexOf( '--outFile' ) + 1 ]; + fs.writeFileSync( outFile, 'compiled wasm' ); + return { status: 0, stderr: '', stdout: '' }; + } ); } ); afterEach( () => { @@ -112,6 +123,24 @@ describe( 'edge-workers toolchains', () => { expect( () => tc.compile( project, worker ) ).toThrow( /Worker entry must stay within/ ); } ); + it( 'rejects an entry symlink that escapes the worker directory before compiling', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entryDir = path.join( workerDir, 'assembly' ); + const outside = path.join( tmp, 'secret.ts' ); + fs.mkdirSync( entryDir, { recursive: true } ); + fs.writeFileSync( outside, 'TOP_SECRET_SOURCE' ); + fs.symlinkSync( outside, path.join( entryDir, 'index.ts' ), 'file' ); + const worker = { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => tc.compile( project, worker ) ).toThrow( /Worker entry must stay within/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + } ); + it( 'rejects a worker name that escapes the build directory', () => { const project = path.join( tmp, 'proj' ); tc.scaffoldProject( project ); @@ -126,6 +155,96 @@ describe( 'edge-workers toolchains', () => { expect( fs.existsSync( path.join( tmp, 'outside.wasm' ) ) ).toBe( false ); } ); + it( 'rejects a symlinked build root before the compiler can write outside the project', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const outsideBuild = path.join( tmp, 'outside-build' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( outsideBuild ); + fs.symlinkSync( + outsideBuild, + path.join( project, 'build' ), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build directory must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.existsSync( path.join( outsideBuild, 'demo.wasm' ) ) ).toBe( false ); + } ); + + it( 'rejects a symlinked build output before the compiler can overwrite its target', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'outside.wasm' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outside, 'DO NOT OVERWRITE' ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build artifact must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.readFileSync( outside, 'utf8' ) ).toBe( 'DO NOT OVERWRITE' ); + } ); + + it( 'rejects a dangling build-output symlink before the compiler can create its target', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'not-yet-created.wasm' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( buildDir ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build artifact must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.existsSync( outside ) ).toBe( false ); + } ); + + it( 'creates a real build directory inside the canonical project root', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + + const output = tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ); + + expect( fs.lstatSync( path.join( project, 'build' ) ).isDirectory() ).toBe( true ); + expect( fs.lstatSync( path.join( project, 'build' ) ).isSymbolicLink() ).toBe( false ); + expect( fs.realpathSync( output ) ).toBe( + path.join( fs.realpathSync( project ), 'build', 'demo.wasm' ) + ); + } ); + it( 'ensureAvailable throws when the compiler is missing', () => { const project = path.join( tmp, 'proj' ); tc.scaffoldProject( project ); diff --git a/__tests__/lib/edge-workers/validation.test.ts b/__tests__/lib/edge-workers/validation.test.ts index a699ae252..6769a2858 100644 --- a/__tests__/lib/edge-workers/validation.test.ts +++ b/__tests__/lib/edge-workers/validation.test.ts @@ -120,6 +120,22 @@ describe( 'parseWorkerManifest', () => { ).toThrow( /invalid location/ ); } ); + it.each( [ '\u001b[31m/admin', '/safe\nforged output' ] )( + 'rejects control characters in location value %p', + value => { + expect( () => + parseWorkerManifest( + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value }, + }, + file + ) + ).toThrow( /invalid location value/ ); + } + ); + it( 'rejects invalid failure behavior', () => { expect( () => parseWorkerManifest( diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 037603910..097ac43d4 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -144,8 +144,10 @@ worker, unless the operator deliberately passes `--skip-confirmation`. A worker cannot be combined. `--skip-build`, `--skip-validate`, and `--skip-confirmation` remove safety checks and should be used only when the omitted step has separate, current evidence. -Subject to the unresolved inactive-create guarantee in section 1, creating or updating a worker -does not enable it. Enabling is a separate command. +Subject to the unresolved inactive-create guarantee in section 1, a newly created worker is +inactive and must be enabled separately. Updating preserves the worker's current active state: an +update to an already-active worker therefore applies the uploaded code and configuration live +immediately. Disable an active worker first when the update must not become live on deployment. ## 8. Source storage and `--skip-source` diff --git a/src/bin/vip-edge-workers-build.js b/src/bin/vip-edge-workers-build.js index 0e27b6e4e..1201efde0 100644 --- a/src/bin/vip-edge-workers-build.js +++ b/src/bin/vip-edge-workers-build.js @@ -5,6 +5,7 @@ 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 { escapeTerminalText } from '../lib/edge-workers/output'; import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; import { trackEvent } from '../lib/tracker'; import UserError from '../lib/user-error'; @@ -42,17 +43,16 @@ export async function edgeWorkersBuildCommand( args = [], opt = {} ) { for ( const worker of workers ) { const { wasmPath, sizeBytes } = buildWorker( projectDir, worker ); console.log( - `✓ Built "${ worker.manifest.name }" → ${ path.relative( - projectDir, - wasmPath + `✓ Built "${ escapeTerminalText( worker.manifest.name ) }" → ${ escapeTerminalText( + 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 ); + await trackEvent( 'edge_workers_build_command_error', { name, error: 'build_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); } } diff --git a/src/bin/vip-edge-workers-delete.js b/src/bin/vip-edge-workers-delete.js index 053aeef65..277728809 100644 --- a/src/bin/vip-edge-workers-delete.js +++ b/src/bin/vip-edge-workers-delete.js @@ -4,6 +4,7 @@ import { appQuery, deleteEdgeWorker, findEdgeWorkerByName } from '../lib/api/edg import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; import { confirmEdgeWorkerDeletion } from '../lib/edge-workers/confirmation'; +import { escapeTerminalText } from '../lib/edge-workers/output'; import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; @@ -41,13 +42,13 @@ export async function edgeWorkersDeleteCommand( args = [], opt = {} ) { await deleteEdgeWorker( env.id, worker.id ); await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_success', { name } ); - console.log( `✓ Deleted edge worker "${ name }".` ); + console.log( `✓ Deleted edge worker "${ escapeTerminalText( name ) }".` ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_error', { name, - error: err.message, + error: 'delete_failed', } ); - exit.withError( `Failed to delete edge worker: ${ err.message }` ); + exit.withError( `Failed to delete edge worker: ${ escapeTerminalText( err.message ) }` ); } } diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js index 92931f2ac..b49b6b54f 100644 --- a/src/bin/vip-edge-workers-deploy.js +++ b/src/bin/vip-edge-workers-deploy.js @@ -14,6 +14,7 @@ import { deploymentPlanRows, prepareEdgeWorkerDeploymentPlan, } from '../lib/edge-workers/deployment'; +import { escapeTerminalText } from '../lib/edge-workers/output'; import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; @@ -36,14 +37,14 @@ const examples = [ ]; function errorMessage( error ) { - return error instanceof Error ? error.message : String( error ); + return escapeTerminalText( error instanceof Error ? error.message : String( error ) ); } function partialFailureMessage( error ) { return ( - `Deployment stopped at "${ error.failedName }". ` + - `Applied: ${ error.appliedNames.join( ', ' ) || 'none' }. ` + - `Not applied: ${ error.unappliedNames.join( ', ' ) || 'none' }. ` + + `Deployment stopped at "${ escapeTerminalText( error.failedName ) }". ` + + `Applied: ${ error.appliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + + `Not applied: ${ error.unappliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + `Cause: ${ errorMessage( error.cause ) }` ); } @@ -102,9 +103,11 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { await applyEdgeWorkerDeploymentPlan( env.id, plan, ( item, deployed ) => { const action = item.action === 'create' ? 'created' : 'updated'; - const phasesNote = `, phases: ${ deployed.phases.join( ', ' ) || 'none' }`; + const phasesNote = `, phases: ${ + deployed.phases.map( escapeTerminalText ).join( ', ' ) || 'none' + }`; console.log( - `✓ ${ action } "${ item.worker.manifest.name }" ` + + `✓ ${ action } "${ escapeTerminalText( item.worker.manifest.name ) }" ` + `(${ item.artifact.sizeBytes } bytes${ phasesNote })` ); } ); @@ -115,7 +118,7 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { name, - error: errorMessage( err ), + error: 'deploy_failed', } ); exit.withError( err instanceof DeploymentApplyError diff --git a/src/bin/vip-edge-workers-disable.js b/src/bin/vip-edge-workers-disable.js index f8d7cbb0f..b7e29101c 100644 --- a/src/bin/vip-edge-workers-disable.js +++ b/src/bin/vip-edge-workers-disable.js @@ -3,6 +3,7 @@ import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers disable'; @@ -29,13 +30,13 @@ export async function edgeWorkersDisableCommand( args = [], opt = {} ) { 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 }".` ); + console.log( `✓ Disabled edge worker "${ escapeTerminalText( name ) }".` ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_error', { name, - error: err.message, + error: 'disable_failed', } ); - exit.withError( `Failed to disable edge worker: ${ err.message }` ); + exit.withError( `Failed to disable edge worker: ${ escapeTerminalText( err.message ) }` ); } } diff --git a/src/bin/vip-edge-workers-enable.js b/src/bin/vip-edge-workers-enable.js index 81b798b91..59087e0e0 100644 --- a/src/bin/vip-edge-workers-enable.js +++ b/src/bin/vip-edge-workers-enable.js @@ -7,6 +7,7 @@ import { confirmProductionEdgeWorkerMutation, isInteractiveEdgeWorkers, } from '../lib/edge-workers/confirmation'; +import { escapeTerminalText } from '../lib/edge-workers/output'; import { confirm } from '../lib/envvar/input'; import { trackEventWithEnv } from '../lib/tracker'; @@ -46,13 +47,13 @@ export async function edgeWorkersEnableCommand( args = [], opt = {} ) { 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 }".` ); + console.log( `✓ Enabled edge worker "${ escapeTerminalText( name ) }".` ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_error', { name, - error: err.message, + error: 'enable_failed', } ); - exit.withError( `Failed to enable edge worker: ${ err.message }` ); + exit.withError( `Failed to enable edge worker: ${ escapeTerminalText( err.message ) }` ); } } diff --git a/src/bin/vip-edge-workers-get.js b/src/bin/vip-edge-workers-get.js index 42e0d879b..bab27b15f 100644 --- a/src/bin/vip-edge-workers-get.js +++ b/src/bin/vip-edge-workers-get.js @@ -4,6 +4,7 @@ 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 { escapeTerminalText } from '../lib/edge-workers/output'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers get'; @@ -36,9 +37,9 @@ export async function edgeWorkersGetCommand( args = [], opt = {} ) { } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { name, - error: err.message, + error: 'get_failed', } ); - exit.withError( `Failed to get edge worker: ${ err.message }` ); + exit.withError( `Failed to get edge worker: ${ escapeTerminalText( err.message ) }` ); } if ( ! worker ) { @@ -46,31 +47,39 @@ export async function edgeWorkersGetCommand( args = [], opt = {} ) { name, error: 'Not found', } ); - exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + exit.withError( + `No edge worker named "${ escapeTerminalText( 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 }"` + ? `${ escapeTerminalText( worker.location.operator ) } "${ escapeTerminalText( + worker.location.value + ) }"` : 'all requests'; console.log( keyValue( [ - { key: 'ID', value: worker.id }, - { key: 'Name', value: worker.name }, + { key: 'ID', value: escapeTerminalText( worker.id ) }, + { key: 'Name', value: escapeTerminalText( worker.name ) }, { key: 'Active', value: worker.active ? 'yes' : 'no' }, - { key: 'Phases', value: ( worker.phases || [] ).join( ', ' ) }, + { key: 'Phases', value: ( worker.phases || [] ).map( escapeTerminalText ).join( ', ' ) }, { key: 'Location', value: location }, - { key: 'On failure', value: worker.onFailure }, - { key: 'Created', value: worker.createdAt }, - { key: 'Modified', value: worker.updatedAt }, + { key: 'On failure', value: escapeTerminalText( worker.onFailure ) }, + { key: 'Created', value: escapeTerminalText( worker.createdAt ) }, + { key: 'Modified', value: escapeTerminalText( worker.updatedAt ) }, ] ) ); if ( includeSource ) { console.log( '\nSource:' ); - console.log( worker.source ?? '(no source stored)' ); + console.log( + worker.source === null || worker.source === undefined + ? '(no source stored)' + : escapeTerminalText( worker.source ) + ); } } diff --git a/src/bin/vip-edge-workers-init.js b/src/bin/vip-edge-workers-init.js index 7540d7c1c..17845b0df 100644 --- a/src/bin/vip-edge-workers-init.js +++ b/src/bin/vip-edge-workers-init.js @@ -4,6 +4,7 @@ import path from 'node:path'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; 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'; @@ -32,24 +33,28 @@ export async function edgeWorkersInitCommand( args = [], opt = {} ) { 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( - ', ' - ) }.` + `Unsupported type "${ escapeTerminalText( + 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_error', { type, error: 'init_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); } await trackEvent( 'edge_workers_init_command_success', { type } ); - console.log( `✓ Created a new ${ type } edge-workers project in ${ projectDir }` ); + console.log( + `✓ Created a new ${ escapeTerminalText( type ) } edge-workers project in ${ escapeTerminalText( + projectDir + ) }` + ); console.log( '\nNext steps:' ); - console.log( ` cd ${ targetArg }` ); + console.log( ` cd ${ escapeTerminalText( targetArg ) }` ); console.log( ' npm install' ); console.log( ' vip edge-workers new my-worker' ); } diff --git a/src/bin/vip-edge-workers-list.js b/src/bin/vip-edge-workers-list.js index 99ed9ef4a..d0227fc26 100644 --- a/src/bin/vip-edge-workers-list.js +++ b/src/bin/vip-edge-workers-list.js @@ -3,6 +3,7 @@ import { appQuery, listEdgeWorkers } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers list'; @@ -14,12 +15,12 @@ const examples = [ }, ]; -function formatLocation( location ) { +function formatLocation( location, escape ) { if ( ! location ) { return 'all requests'; } - return `${ location.operator } "${ location.value }"`; + return `${ escape( location.operator ) } "${ escape( location.value ) }"`; } export async function edgeWorkersListCommand( _args = [], opt = {} ) { @@ -32,9 +33,9 @@ export async function edgeWorkersListCommand( _args = [], opt = {} ) { workers = await listEdgeWorkers( app.id, env.id ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_error', { - error: err.message, + error: 'list_failed', } ); - exit.withError( `Failed to list edge workers: ${ err.message }` ); + exit.withError( `Failed to list edge workers: ${ escapeTerminalText( err.message ) }` ); } await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_success', { @@ -46,14 +47,16 @@ export async function edgeWorkersListCommand( _args = [], opt = {} ) { return []; } + const escape = opt.format === 'json' ? value => value : escapeTerminalText; + return workers.map( worker => ( { id: worker.id, - name: worker.name, + name: escape( worker.name ), active: worker.active ? 'yes' : 'no', - phases: ( worker.phases || [] ).join( ', ' ), - location: formatLocation( worker.location ), - on_failure: worker.onFailure, - modified: worker.updatedAt, + phases: ( worker.phases || [] ).map( escape ).join( ', ' ), + location: formatLocation( worker.location, escape ), + on_failure: escape( worker.onFailure ), + modified: escape( worker.updatedAt ), } ) ); } diff --git a/src/bin/vip-edge-workers-new.js b/src/bin/vip-edge-workers-new.js index 5350e677d..9bfdf2c13 100644 --- a/src/bin/vip-edge-workers-new.js +++ b/src/bin/vip-edge-workers-new.js @@ -5,6 +5,7 @@ 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 { escapeTerminalText } from '../lib/edge-workers/output'; import { readProjectDescriptor, readWorkerManifest, @@ -60,17 +61,23 @@ export async function edgeWorkersNewCommand( args = [], opt = {} ) { 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( + `✓ Created worker "${ escapeTerminalText( name ) }" in ${ escapeTerminalText( + path.join( projectDir, entryDir ) + ) }` + ); console.log( location - ? `Scope: ${ location.operator } "${ location.value }".` + ? `Scope: ${ escapeTerminalText( location.operator ) } "${ escapeTerminalText( + location.value + ) }".` : 'Scope: all requests. Set location in worker.json before deployment to narrow it.' ); console.log( '\nEdit the worker, then deploy it with:' ); - console.log( ` vip @my-site.develop edge-workers deploy ${ name }` ); + console.log( ` vip @my-site.develop edge-workers deploy ${ escapeTerminalText( name ) }` ); } catch ( err ) { - await trackEvent( 'edge_workers_new_command_error', { name, error: err.message } ); - exit.withError( err.message ); + await trackEvent( 'edge_workers_new_command_error', { name, error: 'new_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); } } diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js index 821460d87..26ca7a0bf 100644 --- a/src/bin/vip-edge-workers-validate.js +++ b/src/bin/vip-edge-workers-validate.js @@ -4,6 +4,7 @@ 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 { escapeTerminalText } from '../lib/edge-workers/output'; import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; import { trackEventWithEnv } from '../lib/tracker'; @@ -61,11 +62,16 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { if ( result && ! result.valid ) { invalidCount++; - const errors = ( result.errors || [] ).join( '; ' ) || 'unknown error'; - console.log( `✕ "${ worker.manifest.name }" is invalid: ${ errors }` ); + const errors = + ( result.errors || [] ).map( escapeTerminalText ).join( '; ' ) || 'unknown error'; + console.log( + `✕ "${ escapeTerminalText( worker.manifest.name ) }" is invalid: ${ errors }` + ); } else { - const phases = ( result?.phases || [] ).join( ', ' ) || 'none'; - console.log( `✓ "${ worker.manifest.name }" is valid (phases: ${ phases })` ); + const phases = ( result?.phases || [] ).map( escapeTerminalText ).join( ', ' ) || 'none'; + console.log( + `✓ "${ escapeTerminalText( worker.manifest.name ) }" is valid (phases: ${ phases })` + ); } } @@ -80,9 +86,9 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_error', { name, - error: err.message, + error: 'validate_failed', } ); - exit.withError( `Failed to validate edge worker: ${ err.message }` ); + exit.withError( `Failed to validate edge worker: ${ escapeTerminalText( err.message ) }` ); } } diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts index a6ad0f425..034916ac0 100644 --- a/src/lib/api/edge-workers.ts +++ b/src/lib/api/edge-workers.ts @@ -62,9 +62,32 @@ interface EdgeWorkersQueryResult { } | null; } +function isObject( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +function invalidReadResponse(): never { + throw new UserError( 'EdgeWorkers query returned an invalid response.' ); +} + function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: number ): EdgeWorker[] { - const env = result?.app?.environments?.find( candidate => candidate.id === envId ); - return env?.edgeWorkers ?? []; + if ( ! isObject( result ) || ! isObject( result.app ) ) { + return invalidReadResponse(); + } + const environments = result.app.environments; + if ( ! Array.isArray( environments ) ) { + return invalidReadResponse(); + } + if ( + ! environments.every( candidate => isObject( candidate ) && typeof candidate.id === 'number' ) + ) { + return invalidReadResponse(); + } + const env = environments.find( candidate => candidate.id === envId ); + if ( ! env || ! Array.isArray( env.edgeWorkers ) ) { + return invalidReadResponse(); + } + return env.edgeWorkers; } function requireMutationPayload< T >( operation: string, value: T | null | undefined ): T { diff --git a/src/lib/cli/format.ts b/src/lib/cli/format.ts index 4ced099ec..de23d4e7e 100644 --- a/src/lib/cli/format.ts +++ b/src/lib/cli/format.ts @@ -24,7 +24,7 @@ export function formatData( format: OutputFormat ): string { if ( ! data.length ) { - return ''; + return format === 'json' ? '[]' : ''; } switch ( format ) { @@ -32,7 +32,7 @@ export function formatData( return ids( data as Record< string, unknown >[] ); case 'json': - return JSON.stringify( data, null, '\t' ); + return json( data ); case 'csv': return csv( data as Record< string, unknown >[] ); @@ -46,6 +46,14 @@ export function formatData( } } +function json( data: Record< string, unknown >[] | Tuple[] ): string { + return JSON.stringify( data, null, '\t' ).replace( + // JSON.stringify already escapes C0 controls, but not DEL or C1 controls. + /[\u007f-\u009f]/g, + character => `\\u${ character.charCodeAt( 0 ).toString( 16 ).padStart( 4, '0' ) }` + ); +} + export function formatEnvironment( environment: string ): string { if ( 'production' === environment.toLowerCase() ) { return chalk.red( environment.toUpperCase() ); diff --git a/src/lib/edge-workers/confirmation.ts b/src/lib/edge-workers/confirmation.ts index 19e8bcdb1..e679b1562 100644 --- a/src/lib/edge-workers/confirmation.ts +++ b/src/lib/edge-workers/confirmation.ts @@ -1,4 +1,5 @@ import UserError from '../user-error'; +import { escapeTerminalText } from './output'; export interface ProductionMutationConfirmationRequest { action: 'deploy' | 'enable'; @@ -45,8 +46,16 @@ export async function confirmProductionEdgeWorkerMutation( request.action === 'deploy' ? `Deploy ${ request.workerNames.length } edge worker${ request.workerNames.length === 1 ? '' : 's' - } (${ request.workerNames.join( ', ' ) }) to ${ request.appName }.${ request.envType }?` - : `Enable edge worker "${ request.workerNames[ 0 ] }" on ${ request.appName }.${ request.envType }?`; + } (${ request.workerNames + .map( escapeTerminalText ) + .join( ', ' ) }) to ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }?` + : `Enable edge worker "${ escapeTerminalText( + request.workerNames[ 0 ] + ) }" on ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }?`; if ( ! ( await confirmFn( message ) ) ) { throw new UserError( 'Command cancelled by user.' ); @@ -62,7 +71,11 @@ export async function confirmEdgeWorkerDeletion( } const confirmed = await confirmFn( - `Permanently delete edge worker "${ request.workerName }" from ${ request.appName }.${ request.envType }?` + `Permanently delete edge worker "${ escapeTerminalText( + request.workerName + ) }" from ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }?` ); if ( ! confirmed ) { throw new UserError( 'Command cancelled by user.' ); diff --git a/src/lib/edge-workers/deployment.ts b/src/lib/edge-workers/deployment.ts index c88177f5e..fb0a57691 100644 --- a/src/lib/edge-workers/deployment.ts +++ b/src/lib/edge-workers/deployment.ts @@ -6,6 +6,7 @@ import { } from '../api/edge-workers'; import UserError from '../user-error'; import { buildWorker, readPrebuiltWorker, readWorkerSource } from './index'; +import { escapeTerminalText } from './output'; import type { DiscoveredWorker, EdgeWorker, EdgeWorkerLocation, EdgeWorkerPhase } from './types'; import type { EdgeWorkerWriteInput } from '../api/edge-workers'; @@ -174,20 +175,22 @@ export async function prepareEdgeWorkerDeploymentPlan( } function formatLocation( location: EdgeWorkerLocation | null ): string { - return location ? `${ location.operator } "${ location.value }"` : 'all requests'; + return location + ? `${ escapeTerminalText( location.operator ) } "${ escapeTerminalText( location.value ) }"` + : 'all requests'; } export function deploymentPlanRows( items: readonly EdgeWorkerDeploymentPlanItem[] ): Record< string, string >[] { return items.map( item => ( { - worker: item.worker.manifest.name, + worker: escapeTerminalText( item.worker.manifest.name ), action: item.action, active: item.existing?.active ? 'yes' : 'no', current_scope: formatLocation( item.currentLocation ), proposed_scope: formatLocation( item.proposedLocation ), validation: item.validation, - phases: item.phases.join( ', ' ) || 'none', + phases: item.phases.map( escapeTerminalText ).join( ', ' ) || 'none', bytes: String( item.artifact.sizeBytes ), source: item.sourceMode, } ) ); diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts index 02d29e7ff..d3df08049 100644 --- a/src/lib/edge-workers/index.ts +++ b/src/lib/edge-workers/index.ts @@ -4,12 +4,11 @@ */ import fs from 'node:fs'; -import path from 'node:path'; import UserError from '../user-error'; import { readProjectDescriptor } from './project'; import { getToolchain } from './toolchains'; -import { resolvePathWithin, validateWorkerName } from './validation'; +import { resolveExistingPathWithin, resolvePathWithin, validateWorkerName } from './validation'; import type { DiscoveredWorker } from './types'; @@ -36,17 +35,27 @@ function encodeArtifact( wasmPath: string ): BuiltArtifact { /** Read a previously compiled artifact without recompiling (used by `deploy --skip-build`). */ export function readPrebuiltWorker( projectDir: string, worker: DiscoveredWorker ): BuiltArtifact { const name = validateWorkerName( worker.manifest.name ); - const wasmPath = resolvePathWithin( - path.join( projectDir, BUILD_DIR ), - `${ name }.wasm`, - 'Worker build artifact' - ); - if ( ! fs.existsSync( wasmPath ) ) { + const buildRoot = resolvePathWithin( projectDir, BUILD_DIR, 'Worker build directory' ); + const candidate = resolvePathWithin( buildRoot, `${ name }.wasm`, 'Worker build artifact' ); + if ( ! fs.existsSync( candidate ) ) { throw new UserError( - `No compiled artifact found for "${ worker.manifest.name }" at "${ wasmPath }". ` + + `No compiled artifact found for "${ worker.manifest.name }" at "${ candidate }". ` + 'Run `vip edge-workers build` first, or deploy without `--skip-build`.' ); } + if ( fs.lstatSync( buildRoot ).isSymbolicLink() ) { + throw new UserError( 'Worker build directory must not be a symbolic link.' ); + } + const canonicalBuildRoot = resolveExistingPathWithin( + projectDir, + BUILD_DIR, + 'Worker build directory' + ); + const wasmPath = resolveExistingPathWithin( + canonicalBuildRoot, + `${ name }.wasm`, + 'Worker build artifact' + ); return encodeArtifact( wasmPath ); } @@ -64,7 +73,11 @@ export function buildWorker( projectDir: string, worker: DiscoveredWorker ): Bui /** Read the entry source of a worker, for storing alongside the binary. */ export function readWorkerSource( worker: DiscoveredWorker ): string { - const entry = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + const candidate = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + if ( ! fs.existsSync( candidate ) ) { + throw new UserError( `Could not read worker source at "${ candidate }".` ); + } + const entry = resolveExistingPathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); try { return fs.readFileSync( entry, 'utf8' ); } catch { diff --git a/src/lib/edge-workers/location.ts b/src/lib/edge-workers/location.ts index 7c5de7e57..21995a3ed 100644 --- a/src/lib/edge-workers/location.ts +++ b/src/lib/edge-workers/location.ts @@ -5,6 +5,7 @@ */ import UserError from '../user-error'; +import { hasTerminalControlCharacters } from './output'; import { EDGE_WORKER_LOCATION_OPERATORS } from './types'; import type { EdgeWorkerLocation, EdgeWorkerLocationOperator } from './types'; @@ -15,7 +16,11 @@ export function parseLocationOption( raw: string ): EdgeWorkerLocation { 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 ) { + if ( + ! ( EDGE_WORKER_LOCATION_OPERATORS as string[] ).includes( operator ) || + ! value || + hasTerminalControlCharacters( value ) + ) { throw new UserError( `Invalid location "${ raw }". Use ":", where is one of: ` + `${ EDGE_WORKER_LOCATION_OPERATORS.join( ', ' ) } (e.g. "starts_with:/api/").` diff --git a/src/lib/edge-workers/output.ts b/src/lib/edge-workers/output.ts new file mode 100644 index 000000000..9e4e4676f --- /dev/null +++ b/src/lib/edge-workers/output.ts @@ -0,0 +1,16 @@ +// C0, DEL, and C1 controls can alter terminal state or forge surrounding output. +// eslint-disable-next-line no-control-regex +const TERMINAL_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; +// eslint-disable-next-line no-control-regex +const TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; + +export function hasTerminalControlCharacters( value: string ): boolean { + return TERMINAL_CONTROL_CHARACTER.test( value ); +} + +/** Render untrusted text without allowing it to emit terminal control characters. */ +export function escapeTerminalText( value: unknown ): string { + return String( value ).replace( TERMINAL_CONTROL_CHARACTERS, character => { + return `\\u${ character.charCodeAt( 0 ).toString( 16 ).padStart( 4, '0' ) }`; + } ); +} diff --git a/src/lib/edge-workers/project.ts b/src/lib/edge-workers/project.ts index f666abf6f..157782139 100644 --- a/src/lib/edge-workers/project.ts +++ b/src/lib/edge-workers/project.ts @@ -172,9 +172,9 @@ export function discoverWorkers( projectDir: string ): DiscoveredWorker[] { */ 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 - ); + const match = + workers.find( worker => worker.manifest.name === name ) ?? + workers.find( worker => path.basename( worker.dir ) === name ); if ( ! match ) { const available = workers.map( worker => worker.manifest.name ).join( ', ' ) || '(none)'; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/index.ts b/src/lib/edge-workers/toolchains/assemblyscript/index.ts index 2a6d3554a..e792036ce 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/index.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -18,7 +18,12 @@ 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 { WORKERS_DIR, writeProjectDescriptor, writeWorkerManifest } from '../../project'; -import { resolvePathWithin, validateWorkerName } from '../../validation'; +import { + resolveExistingPathWithin, + resolveOutputPathWithin, + resolvePathWithin, + validateWorkerName, +} from '../../validation'; import type { DiscoveredWorker } from '../../types'; import type { Toolchain } from '../index'; @@ -97,19 +102,20 @@ const toolchain: Toolchain = { compile( projectDir: string, worker: DiscoveredWorker ): string { const asc = ascBinaryPath( projectDir ); - const entry = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); - if ( ! fs.existsSync( entry ) ) { - throw new UserError( `Worker entry file not found: "${ entry }".` ); + const entryCandidate = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + if ( ! fs.existsSync( entryCandidate ) ) { + throw new UserError( `Worker entry file not found: "${ entryCandidate }".` ); } + const entry = resolveExistingPathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); const nodeModules = path.join( projectDir, 'node_modules' ); const workerName = validateWorkerName( worker.manifest.name ); - const outFile = resolvePathWithin( - path.join( projectDir, BUILD_DIR ), - `${ workerName }.wasm`, - 'Worker build artifact' + const outFile = resolveOutputPathWithin( + projectDir, + path.join( BUILD_DIR, `${ workerName }.wasm` ), + 'Worker build artifact', + 'Worker build directory' ); - fs.mkdirSync( path.dirname( outFile ), { recursive: true } ); const args = [ entry, @@ -151,7 +157,12 @@ const toolchain: Toolchain = { ); } - return outFile; + return resolveOutputPathWithin( + projectDir, + path.join( BUILD_DIR, `${ workerName }.wasm` ), + 'Worker build artifact', + 'Worker build directory' + ); }, }; diff --git a/src/lib/edge-workers/validation.ts b/src/lib/edge-workers/validation.ts index 6c3406cfe..9402693fc 100644 --- a/src/lib/edge-workers/validation.ts +++ b/src/lib/edge-workers/validation.ts @@ -1,6 +1,8 @@ +import fs from 'node:fs'; import path from 'node:path'; import UserError from '../user-error'; +import { hasTerminalControlCharacters } from './output'; import { EDGE_WORKER_LOCATION_OPERATORS, SUPPORTED_EDGE_WORKER_TYPES } from './types'; import type { @@ -55,6 +57,109 @@ export function resolvePathWithin( root: string, relativePath: string, label: st return resolvedPath; } +function isPathWithin( root: string, candidate: string ): boolean { + const relative = path.relative( root, candidate ); + return ( + relative === '' || + ( relative !== '..' && + ! relative.startsWith( `..${ path.sep }` ) && + ! path.isAbsolute( relative ) ) + ); +} + +function realpath( target: string, label: string ): string { + try { + return fs.realpathSync.native( target ); + } catch { + throw new UserError( `${ label } could not be resolved at "${ target }".` ); + } +} + +function lstatIfExists( target: string ): fs.Stats | undefined { + try { + return fs.lstatSync( target ); + } catch ( error ) { + if ( ( error as NodeJS.ErrnoException ).code === 'ENOENT' ) { + return undefined; + } + throw error; + } +} + +/** Resolve an existing input and require its canonical target to remain below the canonical root. */ +export function resolveExistingPathWithin( + root: string, + relativePath: string, + label: string +): string { + const resolvedPath = resolvePathWithin( root, relativePath, label ); + const canonicalRoot = realpath( path.resolve( root ), `${ label } root` ); + const canonicalPath = realpath( resolvedPath, label ); + + if ( ! isPathWithin( canonicalRoot, canonicalPath ) ) { + throw new UserError( `${ label } must stay within "${ canonicalRoot }".` ); + } + + return canonicalPath; +} + +/** + * Prepare an output path below an existing root. Existing parent components and + * the output itself must not be symlinks. Missing parents are created one at a + * time and then canonicalized below the root before the path is returned. + */ +export function resolveOutputPathWithin( + root: string, + relativePath: string, + label: string, + directoryLabel: string +): string { + const resolvedRoot = path.resolve( root ); + const resolvedPath = resolvePathWithin( resolvedRoot, relativePath, label ); + const canonicalRoot = realpath( resolvedRoot, `${ label } root` ); + const relativeParent = path.relative( resolvedRoot, path.dirname( resolvedPath ) ); + const components = relativeParent === '' ? [] : relativeParent.split( path.sep ); + let current = resolvedRoot; + + for ( const component of components ) { + current = path.join( current, component ); + const stat = lstatIfExists( current ); + if ( stat ) { + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ directoryLabel } must not be a symbolic link.` ); + } + if ( ! stat.isDirectory() ) { + throw new UserError( `${ directoryLabel } must be a directory.` ); + } + } else { + fs.mkdirSync( current ); + } + + const canonicalDirectory = realpath( current, directoryLabel ); + if ( ! isPathWithin( canonicalRoot, canonicalDirectory ) ) { + throw new UserError( `${ directoryLabel } must stay within "${ canonicalRoot }".` ); + } + } + + const canonicalParent = realpath( path.dirname( resolvedPath ), directoryLabel ); + const outputPath = path.join( canonicalParent, path.basename( resolvedPath ) ); + const outputStat = lstatIfExists( outputPath ); + if ( outputStat ) { + if ( outputStat.isSymbolicLink() ) { + throw new UserError( `${ label } must not be a symbolic link.` ); + } + if ( ! outputStat.isFile() ) { + throw new UserError( `${ label } must be a regular file.` ); + } + const canonicalOutput = realpath( outputPath, label ); + if ( ! isPathWithin( canonicalRoot, canonicalOutput ) ) { + throw new UserError( `${ label } must stay within "${ canonicalRoot }".` ); + } + } + + return outputPath; +} + function isPlainObject( value: unknown ): value is Record< string, unknown > { return typeof value === 'object' && value !== null && ! Array.isArray( value ); } @@ -90,7 +195,11 @@ function parseLocation( value: unknown, file: string ): EdgeWorkerLocation | nul if ( ! EDGE_WORKER_LOCATION_OPERATORS.includes( value.operator as EdgeWorkerLocationOperator ) ) { throw new UserError( `Worker manifest at "${ file }" has an invalid location operator.` ); } - if ( typeof value.value !== 'string' || value.value.length === 0 ) { + if ( + typeof value.value !== 'string' || + value.value.length === 0 || + hasTerminalControlCharacters( value.value ) + ) { throw new UserError( `Worker manifest at "${ file }" has an invalid location value.` ); } return { operator: value.operator as EdgeWorkerLocationOperator, value: value.value }; From 9079c5f017d240e662d106fca2d0dca5bf4bd012 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 17:48:57 -0500 Subject: [PATCH 35/41] feat(edge-workers): support enable after deploy --- __tests__/lib/edge-workers/deployment.test.ts | 182 +++++++++++++++++- src/lib/edge-workers/deployment.ts | 55 +++++- 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/edge-workers/deployment.test.ts b/__tests__/lib/edge-workers/deployment.test.ts index 6e15eab5f..5911d98ae 100644 --- a/__tests__/lib/edge-workers/deployment.test.ts +++ b/__tests__/lib/edge-workers/deployment.test.ts @@ -1,6 +1,7 @@ import { createEdgeWorker, listEdgeWorkers, + setEdgeWorkerActive, updateEdgeWorker, validateEdgeWorker, } from '../../../src/lib/api/edge-workers'; @@ -17,6 +18,7 @@ import type { EdgeWorker } from '../../../src/lib/edge-workers/types'; jest.mock( '../../../src/lib/api/edge-workers', () => ( { createEdgeWorker: jest.fn(), listEdgeWorkers: jest.fn(), + setEdgeWorkerActive: jest.fn(), updateEdgeWorker: jest.fn(), validateEdgeWorker: jest.fn(), } ) ); @@ -60,6 +62,7 @@ const options = ( workers = [ localWorker() ] ) => ( { skipBuild: false, skipValidate: false, skipSource: false, + enableAfterDeploy: false, } ); describe( 'prepareEdgeWorkerDeploymentPlan()', () => { @@ -285,7 +288,8 @@ describe( 'deploymentPlanRows()', () => { { worker: 'headers', action: 'update', - active: 'yes', + current_active: 'active', + final_active: 'active', current_scope: 'starts_with "/api/"', proposed_scope: 'all requests', validation: 'passed', @@ -313,7 +317,8 @@ describe( 'deploymentPlanRows()', () => { { worker: 'headers', action: 'create', - active: 'no', + current_active: 'new', + final_active: 'inactive', current_scope: 'all requests', proposed_scope: 'all requests', validation: 'passed', @@ -322,9 +327,65 @@ describe( 'deploymentPlanRows()', () => { source: 'omit', }, ] ); + expect( plan[ 0 ] ).toMatchObject( { + enableAfterDeploy: false, + intendedActive: false, + } ); expect( plan[ 0 ] ).toEqual( itemBeforePreview ); } ); + it( 'renders an active final state for a create when enable is requested', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'new', + final_active: 'active', + } ), + ] ); + } ); + + it( 'renders an active final state for an inactive update when enable is requested', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker( { active: false } ) ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'inactive', + final_active: 'active', + } ), + ] ); + } ); + + it( 'keeps an already-active update active when enable is requested', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'active', + final_active: 'active', + } ), + ] ); + } ); + it( 'neutralizes terminal controls in remote preview fields', async () => { jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker( { @@ -387,10 +448,85 @@ describe( 'applyEdgeWorkerDeploymentPlan()', () => { expect( createEdgeWorker ).toHaveBeenCalledWith( 3, plan[ 0 ].input ); expect( updateEdgeWorker ).toHaveBeenCalledWith( 3, 84, plan[ 1 ].input ); + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); expect( order ).toEqual( [ 'create', 'applied:headers:7', 'update', 'applied:redirects:84' ] ); expect( plan ).toEqual( planBeforeApply ); } ); + it( 'enables an inactive create after upload and reports the activated worker', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { id: 7, location: null, active: false } ); + const enabled = remoteWorker( { id: 7, location: null, active: true } ); + const order: string[] = []; + jest.mocked( createEdgeWorker ).mockImplementation( () => { + order.push( 'create' ); + return Promise.resolve( uploaded ); + } ); + jest.mocked( setEdgeWorkerActive ).mockImplementation( () => { + order.push( 'enable' ); + return Promise.resolve( enabled ); + } ); + const onApplied = jest.fn( ( _item: unknown, result: EdgeWorker ) => { + order.push( `applied:headers:${ result.active }` ); + } ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], enabled ); + expect( order ).toEqual( [ 'create', 'enable', 'applied:headers:true' ] ); + } ); + + it( 'enables an inactive update after upload and reports the activated worker', async () => { + const existing = remoteWorker( { active: false } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existing ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { active: false } ); + const enabled = remoteWorker( { active: true } ); + const order: string[] = []; + jest.mocked( updateEdgeWorker ).mockImplementation( () => { + order.push( 'update' ); + return Promise.resolve( uploaded ); + } ); + jest.mocked( setEdgeWorkerActive ).mockImplementation( () => { + order.push( 'enable' ); + return Promise.resolve( enabled ); + } ); + const onApplied = jest.fn( ( _item: unknown, result: EdgeWorker ) => { + order.push( `applied:headers:${ result.active }` ); + } ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 42, true ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], enabled ); + expect( order ).toEqual( [ 'update', 'enable', 'applied:headers:true' ] ); + } ); + + it( 'does not redundantly enable an update that remains active after upload', async () => { + const existing = remoteWorker( { active: true } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existing ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { active: true } ); + jest.mocked( updateEdgeWorker ).mockResolvedValue( uploaded ); + const onApplied = jest.fn(); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], uploaded ); + } ); + it( 'reports applied, failed, and unapplied names without retry or rollback', async () => { const workers = [ 'alpha', 'beta', 'gamma' ].map( name => localWorker( { name } ) ); jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); @@ -406,17 +542,59 @@ describe( 'applyEdgeWorkerDeploymentPlan()', () => { await expect( application ).rejects.toBeInstanceOf( DeploymentApplyError ); await expect( application ).rejects.toMatchObject( { + stage: 'upload', appliedNames: [ 'alpha' ], failedName: 'beta', unappliedNames: [ 'gamma' ], + uploadCompleted: false, + activeAfterUpload: null, cause, } ); expect( createEdgeWorker ).toHaveBeenCalledTimes( 2 ); expect( updateEdgeWorker ).not.toHaveBeenCalled(); + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); expect( onApplied ).toHaveBeenCalledTimes( 1 ); expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], expect.objectContaining( { name: 'alpha' } ) ); } ); + + it( 'reports an ambiguous enable failure after upload and stops later workers', async () => { + const workers = [ 'alpha', 'beta', 'gamma' ].map( name => localWorker( { name } ) ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options( workers ), + enableAfterDeploy: true, + } ); + const alphaUploaded = remoteWorker( { id: 1, name: 'alpha', active: false } ); + const alphaEnabled = remoteWorker( { id: 1, name: 'alpha', active: true } ); + const betaUploaded = remoteWorker( { id: 2, name: 'beta', active: false } ); + const cause = new Error( 'request timed out' ); + jest + .mocked( createEdgeWorker ) + .mockResolvedValueOnce( alphaUploaded ) + .mockResolvedValueOnce( betaUploaded ); + jest + .mocked( setEdgeWorkerActive ) + .mockResolvedValueOnce( alphaEnabled ) + .mockRejectedValueOnce( cause ); + const onApplied = jest.fn(); + + const application = applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + await expect( application ).rejects.toMatchObject( { + stage: 'enable', + appliedNames: [ 'alpha' ], + failedName: 'beta', + unappliedNames: [ 'gamma' ], + uploadCompleted: true, + activeAfterUpload: false, + cause, + } ); + expect( createEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( setEdgeWorkerActive ).toHaveBeenCalledTimes( 2 ); + expect( onApplied ).toHaveBeenCalledTimes( 1 ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], alphaEnabled ); + } ); } ); diff --git a/src/lib/edge-workers/deployment.ts b/src/lib/edge-workers/deployment.ts index fb0a57691..0798c5fa8 100644 --- a/src/lib/edge-workers/deployment.ts +++ b/src/lib/edge-workers/deployment.ts @@ -1,6 +1,7 @@ import { createEdgeWorker, listEdgeWorkers, + setEdgeWorkerActive, updateEdgeWorker, validateEdgeWorker, } from '../api/edge-workers'; @@ -22,6 +23,8 @@ export interface EdgeWorkerDeploymentPlanItem { currentLocation: EdgeWorkerLocation | null; proposedLocation: EdgeWorkerLocation | null; sourceMode: 'store' | 'omit' | 'preserve'; + enableAfterDeploy: boolean; + intendedActive: boolean; } export interface EdgeWorkerDeploymentPlanOptions { @@ -32,6 +35,7 @@ export interface EdgeWorkerDeploymentPlanOptions { skipBuild: boolean; skipValidate: boolean; skipSource: boolean; + enableAfterDeploy: boolean; } export type EdgeWorkerAppliedCallback = ( @@ -43,18 +47,29 @@ export class DeploymentApplyError extends Error { public readonly appliedNames: string[]; public readonly failedName: string; public readonly unappliedNames: string[]; + public readonly stage: 'upload' | 'enable'; + public readonly uploadCompleted: boolean; + public readonly activeAfterUpload: boolean | null; constructor( appliedNames: string[], failedName: string, unappliedNames: string[], - cause: unknown + cause: unknown, + state: { + stage: 'upload' | 'enable'; + uploadCompleted: boolean; + activeAfterUpload: boolean | null; + } ) { super( `Failed to apply edge worker "${ failedName }".`, { cause } ); this.name = 'DeploymentApplyError'; this.appliedNames = appliedNames; this.failedName = failedName; this.unappliedNames = unappliedNames; + this.stage = state.stage; + this.uploadCompleted = state.uploadCompleted; + this.activeAfterUpload = state.activeAfterUpload; } } @@ -143,6 +158,7 @@ async function preparePlanItem( const source = options.skipSource ? undefined : readWorkerSource( worker ); const hasLocation = Object.hasOwn( worker.manifest, 'location' ); const currentLocation = existing?.location ?? null; + const intendedActive = options.enableAfterDeploy || Boolean( existing?.active ); return { action: existing ? 'update' : 'create', @@ -155,6 +171,8 @@ async function preparePlanItem( currentLocation, proposedLocation: proposedLocationFor( worker, existing, hasLocation, currentLocation ), sourceMode: sourceModeFor( options.skipSource, existing ), + enableAfterDeploy: options.enableAfterDeploy, + intendedActive, }; } @@ -180,13 +198,21 @@ function formatLocation( location: EdgeWorkerLocation | null ): string { : 'all requests'; } +function currentActiveLabel( item: EdgeWorkerDeploymentPlanItem ): string { + if ( ! item.existing ) { + return 'new'; + } + return item.existing.active ? 'active' : 'inactive'; +} + export function deploymentPlanRows( items: readonly EdgeWorkerDeploymentPlanItem[] ): Record< string, string >[] { return items.map( item => ( { worker: escapeTerminalText( item.worker.manifest.name ), action: item.action, - active: item.existing?.active ? 'yes' : 'no', + current_active: currentActiveLabel( item ), + final_active: item.intendedActive ? 'active' : 'inactive', current_scope: formatLocation( item.currentLocation ), proposed_scope: formatLocation( item.proposedLocation ), validation: item.validation, @@ -227,12 +253,33 @@ export async function applyEdgeWorkerDeploymentPlan( [ ...appliedNames ], name, items.slice( index + 1 ).map( remaining => remaining.worker.manifest.name ), - cause + cause, + { stage: 'upload', uploadCompleted: false, activeAfterUpload: null } ); } + let finalResult = result; + if ( item.enableAfterDeploy && ! result.active ) { + try { + // eslint-disable-next-line no-await-in-loop + finalResult = await setEdgeWorkerActive( envId, result.id, true ); + } catch ( cause ) { + throw new DeploymentApplyError( + [ ...appliedNames ], + name, + items.slice( index + 1 ).map( remaining => remaining.worker.manifest.name ), + cause, + { + stage: 'enable', + uploadCompleted: true, + activeAfterUpload: result.active, + } + ); + } + } + appliedNames.push( name ); // eslint-disable-next-line no-await-in-loop - await onApplied( item, result ); + await onApplied( item, finalResult ); } } From 1a1c9d2d525f4fda2fb5e40fd4a2034d12c868fa Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 19 Aug 2026 18:21:47 -0500 Subject: [PATCH 36/41] feat(edge-workers): add explicit deploy enable flow --- __tests__/bin/vip-edge-workers-deploy.js | 174 +++++++++++++++++- .../lib/edge-workers/confirmation.test.ts | 32 ++++ docs/EDGE-WORKERS.md | 61 ++++-- src/bin/vip-edge-workers-deploy.js | 71 ++++++- src/lib/edge-workers/confirmation.ts | 19 +- 5 files changed, 316 insertions(+), 41 deletions(-) diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js index ea8e74b02..4b0a6dcdc 100644 --- a/__tests__/bin/vip-edge-workers-deploy.js +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -71,6 +71,7 @@ const opts = { skipValidate: false, skipSource: false, skipConfirmation: false, + enable: false, }; const worker = name => ( { @@ -93,6 +94,8 @@ const planItem = ( name, action = 'create' ) => ( { currentLocation: null, proposedLocation: null, sourceMode: 'store', + enableAfterDeploy: false, + intendedActive: action === 'update', } ); describe( 'edgeWorkersDeployCommand()', () => { @@ -124,9 +127,26 @@ describe( 'edgeWorkersDeployCommand()', () => { skipBuild: true, skipValidate: false, skipSource: false, + enableAfterDeploy: false, } ); } ); + it( 'registers deploy enable as an explicit default-false option', () => { + expect( command.options ).toContainEqual( [ + 'enable', + 'Enable each deployed worker after a successful upload.', + false, + ] ); + } ); + + it( 'passes activation intent when preparing a named worker', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ); + + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( + expect.objectContaining( { enableAfterDeploy: true } ) + ); + } ); + it( 'explains create and update source behavior in --skip-source help', () => { expect( command.options ).toContainEqual( [ 'skip-source', @@ -136,7 +156,7 @@ describe( 'edgeWorkersDeployCommand()', () => { } ); it( 'prepares every discovered worker for --all', async () => { - await edgeWorkersDeployCommand( [], { ...opts, all: true } ); + await edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ); expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); expect( project.findWorker ).not.toHaveBeenCalled(); @@ -146,6 +166,7 @@ describe( 'edgeWorkersDeployCommand()', () => { expect.objectContaining( { manifest: expect.objectContaining( { name: 'alpha' } ) } ), expect.objectContaining( { manifest: expect.objectContaining( { name: 'beta' } ) } ), ], + enableAfterDeploy: true, } ) ); } ); @@ -184,7 +205,9 @@ describe( 'edgeWorkersDeployCommand()', () => { order.push( 'preview' ); } ); confirm.mockImplementation( async message => { - expect( message ).toBe( 'Deploy 2 edge workers (alpha, beta) to example-app.production?' ); + expect( message ).toBe( + 'Deploy and enable 2 edge workers (alpha, beta) on example-app.production?' + ); order.push( 'confirm' ); return true; } ); @@ -194,12 +217,13 @@ describe( 'edgeWorkersDeployCommand()', () => { order.push( 'apply' ); } ); - await edgeWorkersDeployCommand( [], { ...opts, all: true } ); + await edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ); expect( order ).toEqual( [ 'rows', 'format', 'preview', 'confirm', 'apply' ] ); expect( confirmation.isInteractiveEdgeWorkers ).toHaveBeenCalledWith( { ...opts, all: true, + enable: true, } ); } ); @@ -229,6 +253,22 @@ describe( 'edgeWorkersDeployCommand()', () => { ); } ); + it( 'refuses non-interactive production deploy enable without the existing bypass', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( + edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Refusing to deploy and enable edge workers in production' ) + ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Pass --skip-confirmation' ) + ); + } ); + it( 'allows explicit production confirmation bypass without prompting', async () => { confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); @@ -240,6 +280,8 @@ describe( 'edgeWorkersDeployCommand()', () => { it( 'prints success output only from the applied callback and then tracks success', async () => { const item = planItem( 'headers', 'update' ); + item.worker.dir = '/private/customer/SENTINEL_SOURCE_PATH'; + item.input.source = 'SENTINEL_SOURCE_TEXT'; deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( async ( _envId, items, onApplied ) => { @@ -253,6 +295,7 @@ describe( 'edgeWorkersDeployCommand()', () => { id: 42, name: 'headers', phases: [ 'client_response' ], + active: true, } ); } ); @@ -260,17 +303,107 @@ describe( 'edgeWorkersDeployCommand()', () => { await edgeWorkersDeployCommand( [ 'headers' ], opts ); expect( console.log ).toHaveBeenCalledWith( - '✓ updated "headers" (7 bytes, phases: client_response)' + '✓ updated "headers"; remains active (7 bytes, phases: client_response)' ); expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( 1, 3, 'edge_workers_deploy_command_success', - { count: 1 } + { count: 1, enable: false, activeCount: 1 } ); const outputOrder = console.log.mock.invocationCallOrder.at( -1 ); const telemetryOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); expect( outputOrder ).toBeLessThan( telemetryOrder ); + const successTelemetry = tracker.trackEventWithEnv.mock.calls.find( + ( [ , , eventName ] ) => eventName === 'edge_workers_deploy_command_success' + ); + expect( JSON.stringify( successTelemetry ) ).not.toContain( 'SENTINEL_SOURCE_PATH' ); + expect( JSON.stringify( successTelemetry ) ).not.toContain( 'SENTINEL_SOURCE_TEXT' ); + } ); + + it( 'reports a create with enable from the final activated result', async () => { + const item = planItem( 'headers' ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + active: true, + } ); + } + ); + + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ); + + expect( console.log ).toHaveBeenCalledWith( + '✓ created "headers" and enabled it (7 bytes, phases: client_response)' + ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^Review created/ ) ); + } ); + + it( 'reports an upload-only create as inactive and prints one review follow-up', async () => { + const item = planItem( 'headers' ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + active: false, + } ); + } + ); + + await edgeWorkersDeployCommand( [ 'headers' ], opts ); + + expect( console.log ).toHaveBeenCalledWith( + '✓ created "headers"; inactive (7 bytes, phases: client_response)' + ); + expect( console.log ).toHaveBeenCalledWith( + 'Review created inactive edge worker "headers", then run `vip edge-workers enable ` when ready.' + ); + expect( + console.log.mock.calls.filter( ( [ message ] ) => + String( message ).startsWith( 'Review created' ) + ) + ).toHaveLength( 1 ); + } ); + + it( 'lists each upload-only --all create once in one review follow-up', async () => { + const plan = [ planItem( 'alpha' ), planItem( 'beta' ) ]; + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( plan ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + for ( const item of items ) { + await onApplied( item, { + id: item.worker.manifest.name === 'alpha' ? 1 : 2, + name: item.worker.manifest.name, + phases: [ 'client_response' ], + active: false, + } ); + } + } + ); + + await edgeWorkersDeployCommand( [], { ...opts, all: true } ); + + const guidance = console.log.mock.calls + .map( ( [ message ] ) => String( message ) ) + .find( message => message.startsWith( 'Review created' ) ); + expect( guidance ).toBe( + 'Review created inactive edge workers "alpha", "beta", then run `vip edge-workers enable ` for each one when ready.' + ); + expect( guidance?.match( /alpha/g ) ).toHaveLength( 1 ); + expect( guidance?.match( /beta/g ) ).toHaveLength( 1 ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + { count: 2, enable: false, activeCount: 0 } + ); } ); it( 'does not preview or apply when preparation fails', async () => { @@ -324,7 +457,11 @@ describe( 'edgeWorkersDeployCommand()', () => { it( 'reports exact progress and the original cause after a partial failure', async () => { const cause = new Error( 'request timed out' ); deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( - new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause ) + new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause, { + stage: 'upload', + uploadCompleted: false, + activeAfterUpload: null, + } ) ); await expect( edgeWorkersDeployCommand( [], { ...opts, all: true } ) ).rejects.toBe( @@ -341,4 +478,29 @@ describe( 'edgeWorkersDeployCommand()', () => { expect.anything() ); } ); + + it( 'reports enable-stage upload state as last confirmed and final state as unknown', async () => { + const cause = new Error( 'request timed out' ); + deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( + new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause, { + stage: 'enable', + uploadCompleted: true, + activeAfterUpload: false, + } ) + ); + + await expect( + edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Deployment uploaded "beta" and its last confirmed state was inactive, but the enable request failed. ' + + 'Final active state is unknown; verify with `vip edge-workers get beta` or `vip edge-workers list`. ' + + 'Completed: alpha. Not attempted: gamma. Cause: request timed out' + ); + expect( exit.withError ).not.toHaveBeenCalledWith( + expect.stringContaining( 'remains inactive' ) + ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^Review created/ ) ); + } ); } ); diff --git a/__tests__/lib/edge-workers/confirmation.test.ts b/__tests__/lib/edge-workers/confirmation.test.ts index 1d7603055..b49254f63 100644 --- a/__tests__/lib/edge-workers/confirmation.test.ts +++ b/__tests__/lib/edge-workers/confirmation.test.ts @@ -10,6 +10,7 @@ const productionRequest = { appName: 'example-app', envType: 'production', workerNames: [ 'headers', 'redirects' ], + enableAfterDeploy: false, skipConfirmation: false, nonInteractive: false, }; @@ -121,6 +122,20 @@ describe( 'confirmProductionEdgeWorkerMutation()', () => { ); } ); + it( 'prompts once for upload and activation when deploy enable is requested', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, enableAfterDeploy: true }, + confirmFn + ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Deploy and enable 2 edge workers (headers, redirects) on example-app.production?' + ); + expect( confirmFn ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'prompts with the exact worker identity for interactive production enables', async () => { const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); @@ -166,6 +181,23 @@ describe( 'confirmProductionEdgeWorkerMutation()', () => { expect( confirmFn ).not.toHaveBeenCalled(); } ); + it( 'rejects non-interactive production activation with only the existing bypass guidance', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await expect( + confirmProductionEdgeWorkerMutation( + { ...productionRequest, enableAfterDeploy: true, nonInteractive: true }, + confirmFn + ) + ).rejects.toEqual( + new UserError( + 'Refusing to deploy and enable edge workers in production without confirmation. ' + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + it( 'throws UserError when the user declines', async () => { const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 097ac43d4..7167aa021 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -8,11 +8,10 @@ edge workers with VIP-CLI. Use Node.js 22.19.0 or newer, npm 8 or newer, an authenticated VIP-CLI session, and access to the target application and environment. Start in a non-production environment. -The safe lifecycle depends on `createEdgeWorker` creating every new worker with `active: false`. -That is a required platform contract, not a behavior this repository can prove. Release remains -blocked until Task 8 records authoritative evidence from the API owner that inactive creation is -guaranteed. Do not deploy or publish this CLI feature on the basis of the client implementation -alone. +The platform API creates every new worker with `active: false`; create does not accept an active +input. The API also applies a database default of inactive as defense in depth. VIP-CLI relies on +this enforced contract: deploy uploads a new worker first, confirms the returned inactive state, +and enables it only when the operator explicitly passes `--enable`. ## 2. Project layout and exact dependency versions @@ -133,7 +132,7 @@ Deploy prepares every selected worker before applying any remote mutation. Prepa the name as a create or update, builds or reads the artifact, validates it unless `--skip-validate` is passed, determines location and source behavior, and then prints a plan with: -- `worker`, `action`, and current `active` state; +- `worker`, `action`, `current_active`, and `final_active`; - `current_scope` and `proposed_scope`; - `validation` and detected `phases`; - compiled `bytes`; and @@ -144,10 +143,15 @@ worker, unless the operator deliberately passes `--skip-confirmation`. A worker cannot be combined. `--skip-build`, `--skip-validate`, and `--skip-confirmation` remove safety checks and should be used only when the omitted step has separate, current evidence. -Subject to the unresolved inactive-create guarantee in section 1, a newly created worker is -inactive and must be enabled separately. Updating preserves the worker's current active state: an -update to an already-active worker therefore applies the uploaded code and configuration live -immediately. Disable an active worker first when the update must not become live on deployment. +Deploy is upload-only by default. A newly created worker remains inactive, and an update to an +inactive worker remains inactive. Pass `--enable` to enable a newly created or currently inactive +worker after its upload succeeds. An update to an already-active worker stays active and skips the +redundant enable request, even when `--enable` is present; the uploaded code and configuration +therefore become live immediately. Disable an active worker first when the update must not become +live on deployment. + +The plan and the single deployment confirmation cover both the upload and requested enable phase. +For `--all`, every worker's planned final state is visible before any remote mutation begins. ## 8. Source storage and `--skip-source` @@ -173,24 +177,34 @@ source/artifact or delete the worker if permanent removal is intended. Do not de as a rollback unless the exact prior source, manifest, dependencies, and compiled artifact are available and verified. +If the enable phase of `deploy --enable` fails or times out, the worker's final active state is +unknown. The command reports the last confirmed upload result and does not retry, roll back, +disable, or delete the worker automatically. Use `edge-workers list` and `edge-workers get ` +to verify the remote state before taking another action. Do not assume the worker remained +inactive. + ## 10. `--all` and partial failures `build` with no name, `build --all`, `validate --all`, and `deploy --all` operate on workers in stable name order. Deploy preparation completes for all selected workers before remote writes begin, so a preparation or validation failure applies none of them. -Application is sequential. If a create or update fails, deployment stops immediately and reports -the workers already applied, the failed worker, the workers not applied, and the original cause. -It does not retry or roll back already-applied workers. Reconcile the reported names with `list` -and `get` before retrying. +Application is sequential. Each worker's upload completes before its enable phase can begin. If a +create or update fails, deployment stops immediately and reports the workers already applied, the +failed worker, the workers not applied, and the original cause. If an enable fails, deployment +reports that worker's confirmed upload state, treats its final active state as unknown, and stops +before later workers. It does not retry, roll back, disable, or delete already-applied workers. +Reconcile the reported names with `list` and `get` before retrying. ## 11. Automation flags and `VIP_NON_INTERACTIVE=1` Automation should provide an explicit `@app.environment` alias (or equivalent app/environment options), an explicit worker name or `--all`, and `--path` when the working directory is not inside the project. Relevant bypasses are `--skip-build`, `--skip-validate`, `--skip-source`, and -`--skip-confirmation` for deploy; `--skip-build` for validate; `--skip-confirmation` for enable; -and `--force` for delete. `list` supports the global `--format` output option. +`--skip-confirmation` for deploy. Deploy also accepts `--enable`, which is an action request rather +than a safety bypass and defaults to false. Validate accepts `--skip-build`; enable accepts +`--skip-confirmation`; delete accepts `--force`. `list` supports the global `--format` output +option. There is no edge-workers `--non-interactive` flag or other confirmation bypass. Set `VIP_NON_INTERACTIVE=1` to prevent interactive edge-worker production confirmation. In that mode, production `deploy` and `enable` fail closed unless `--skip-confirmation` is also supplied. @@ -217,11 +231,18 @@ vip @example-app.develop edge-workers get security-headers --source vip @example-app.develop edge-workers enable security-headers # Send controlled requests and observe application behavior. vip @example-app.develop edge-workers disable security-headers +# Update the source while inactive, then upload and explicitly enable after the upload. +vip @example-app.develop edge-workers deploy security-headers --enable +# An update while already active becomes live on upload and skips a redundant enable request. +vip @example-app.develop edge-workers deploy security-headers --enable +vip @example-app.develop edge-workers disable security-headers +# Set location to null in worker.json and deploy to clear the stored location. +# Omit location on a later update to preserve the stored location. # Confirm permanent deletion when prompted. vip @example-app.develop edge-workers delete security-headers ``` -Do not enable the deployed worker until the inactive-create guarantee in section 1 has owner -evidence and the printed deployment plan matches the reviewed artifact, phases, scope, source -mode, and byte size. Promote to production only after the non-production lifecycle and a separate -production change review succeed. +Before each enable, verify that the printed deployment plan matches the reviewed artifact, phases, +scope, source mode, byte size, and final active state. If enable does not return a confirmed +result, stop and verify with `list` and `get`; do not infer the final state. Promote to production +only after the non-production lifecycle and a separate production change review succeed. diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js index b49b6b54f..f92a64ea2 100644 --- a/src/bin/vip-edge-workers-deploy.js +++ b/src/bin/vip-edge-workers-deploy.js @@ -41,6 +41,24 @@ function errorMessage( error ) { } function partialFailureMessage( error ) { + if ( error.stage === 'enable' ) { + const failedName = escapeTerminalText( error.failedName ); + let activeAfterUpload = 'unknown'; + if ( error.activeAfterUpload === true ) { + activeAfterUpload = 'active'; + } else if ( error.activeAfterUpload === false ) { + activeAfterUpload = 'inactive'; + } + return ( + `Deployment uploaded "${ failedName }" and its last confirmed state was ${ activeAfterUpload }, ` + + 'but the enable request failed. Final active state is unknown; verify with ' + + `\`vip edge-workers get ${ failedName }\` or \`vip edge-workers list\`. ` + + `Completed: ${ error.appliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + + `Not attempted: ${ + error.unappliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' + }. Cause: ${ errorMessage( error.cause ) }` + ); + } return ( `Deployment stopped at "${ escapeTerminalText( error.failedName ) }". ` + `Applied: ${ error.appliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + @@ -49,9 +67,39 @@ function partialFailureMessage( error ) { ); } +function appliedResultMessage( item, deployed ) { + const name = escapeTerminalText( item.worker.manifest.name ); + let result; + if ( item.action === 'create' ) { + result = deployed.active + ? `created "${ name }" and enabled it` + : `created "${ name }"; inactive`; + } else if ( deployed.active ) { + result = item.existing?.active + ? `updated "${ name }"; remains active` + : `updated "${ name }" and enabled it`; + } else { + result = `updated "${ name }"; remains inactive`; + } + + const phasesNote = `, phases: ${ + deployed.phases.map( escapeTerminalText ).join( ', ' ) || 'none' + }`; + return `✓ ${ result } (${ item.artifact.sizeBytes } bytes${ phasesNote })`; +} + +function inactiveCreateGuidance( workerNames ) { + const names = workerNames.map( name => `"${ escapeTerminalText( name ) }"` ).join( ', ' ); + if ( workerNames.length === 1 ) { + return `Review created inactive edge worker ${ names }, then run \`vip edge-workers enable \` when ready.`; + } + return `Review created inactive edge workers ${ names }, then run \`vip edge-workers enable \` for each one when ready.`; +} + export async function edgeWorkersDeployCommand( args = [], opt = {} ) { const { app, env } = opt; const name = args[ 0 ]; + const enableAfterDeploy = Boolean( opt.enable ); await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_execute', { name, @@ -85,6 +133,7 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { skipBuild: Boolean( opt.skipBuild ), skipValidate: Boolean( opt.skipValidate ), skipSource: Boolean( opt.skipSource ), + enableAfterDeploy, } ); console.log( formatData( deploymentPlanRows( plan ), 'table' ) ); @@ -95,25 +144,30 @@ export async function edgeWorkersDeployCommand( args = [], opt = {} ) { appName: app.name, envType: env.type, workerNames: plan.map( item => item.worker.manifest.name ), + enableAfterDeploy, skipConfirmation: Boolean( opt.skipConfirmation ), nonInteractive: ! isInteractiveEdgeWorkers( opt ), }, confirm ); + const finalResults = []; + const createdInactiveNames = []; await applyEdgeWorkerDeploymentPlan( env.id, plan, ( item, deployed ) => { - const action = item.action === 'create' ? 'created' : 'updated'; - const phasesNote = `, phases: ${ - deployed.phases.map( escapeTerminalText ).join( ', ' ) || 'none' - }`; - console.log( - `✓ ${ action } "${ escapeTerminalText( item.worker.manifest.name ) }" ` + - `(${ item.artifact.sizeBytes } bytes${ phasesNote })` - ); + finalResults.push( deployed ); + if ( ! enableAfterDeploy && item.action === 'create' && ! deployed.active ) { + createdInactiveNames.push( item.worker.manifest.name ); + } + console.log( appliedResultMessage( item, deployed ) ); } ); + if ( createdInactiveNames.length ) { + console.log( inactiveCreateGuidance( createdInactiveNames ) ); + } await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_success', { count: plan.length, + enable: enableAfterDeploy, + activeCount: finalResults.filter( worker => worker.active ).length, } ); } catch ( err ) { await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { @@ -143,6 +197,7 @@ command( { 'Do not store source on create; preserve stored source on update.', false ) + .option( 'enable', 'Enable each deployed worker after a successful upload.', false ) .option( 'skip-confirmation', 'Skip the production deployment confirmation.', false ) .examples( examples ) .argv( process.argv, edgeWorkersDeployCommand ); diff --git a/src/lib/edge-workers/confirmation.ts b/src/lib/edge-workers/confirmation.ts index e679b1562..32a736bd9 100644 --- a/src/lib/edge-workers/confirmation.ts +++ b/src/lib/edge-workers/confirmation.ts @@ -6,6 +6,7 @@ export interface ProductionMutationConfirmationRequest { appName: string; envType: string; workerNames: readonly string[]; + enableAfterDeploy: boolean; skipConfirmation: boolean; nonInteractive: boolean; } @@ -36,21 +37,25 @@ export async function confirmProductionEdgeWorkerMutation( } if ( request.nonInteractive ) { + const action = + request.action === 'deploy' && request.enableAfterDeploy + ? 'deploy and enable' + : request.action; throw new UserError( - `Refusing to ${ request.action } edge workers in production without confirmation. ` + + `Refusing to ${ action } edge workers in production without confirmation. ` + 'Pass --skip-confirmation to proceed non-interactively.' ); } const message = request.action === 'deploy' - ? `Deploy ${ request.workerNames.length } edge worker${ - request.workerNames.length === 1 ? '' : 's' - } (${ request.workerNames + ? `${ request.enableAfterDeploy ? 'Deploy and enable' : 'Deploy' } ${ + request.workerNames.length + } edge worker${ request.workerNames.length === 1 ? '' : 's' } (${ request.workerNames .map( escapeTerminalText ) - .join( ', ' ) }) to ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( - request.envType - ) }?` + .join( ', ' ) }) ${ request.enableAfterDeploy ? 'on' : 'to' } ${ escapeTerminalText( + request.appName + ) }.${ escapeTerminalText( request.envType ) }?` : `Enable edge worker "${ escapeTerminalText( request.workerNames[ 0 ] ) }" on ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( From 7ea4cd8245cce5ffad2ae47e81913e916f4a99ab Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 10:33:03 -0500 Subject: [PATCH 37/41] fix(edge-workers): address review feedback --- __tests__/bin/vip-edge-workers-init.js | 6 ++- __tests__/lib/edge-workers/toolchains.js | 4 +- src/lib/cli/format.ts | 10 +++- src/lib/edge-workers/confirmation.ts | 35 ++++++++----- src/lib/edge-workers/output.ts | 12 +++-- src/lib/edge-workers/validation.ts | 66 +++++++++++++----------- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js index c863201cf..de17d51e9 100644 --- a/__tests__/bin/vip-edge-workers-init.js +++ b/__tests__/bin/vip-edge-workers-init.js @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { edgeWorkersInitCommand } from '../../src/bin/vip-edge-workers-init'; import * as exit from '../../src/lib/cli/exit'; import * as toolchains from '../../src/lib/edge-workers/toolchains'; @@ -36,7 +38,9 @@ describe( 'edgeWorkersInitCommand()', () => { it( 'scaffolds the requested project and prints next steps', async () => { await edgeWorkersInitCommand( [ './infra/edge' ], { type: 'assemblyscript' } ); - expect( scaffoldProject ).toHaveBeenCalledWith( expect.stringMatching( /infra\/edge$/ ) ); + expect( scaffoldProject ).toHaveBeenCalledWith( + path.resolve( process.cwd(), 'infra', 'edge' ) + ); expect( tracker.trackEvent ).toHaveBeenNthCalledWith( 1, 'edge_workers_init_command_execute', { type: 'assemblyscript', } ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 9888d0401..93de21d8f 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -240,8 +240,8 @@ describe( 'edge-workers toolchains', () => { expect( fs.lstatSync( path.join( project, 'build' ) ).isDirectory() ).toBe( true ); expect( fs.lstatSync( path.join( project, 'build' ) ).isSymbolicLink() ).toBe( false ); - expect( fs.realpathSync( output ) ).toBe( - path.join( fs.realpathSync( project ), 'build', 'demo.wasm' ) + expect( fs.realpathSync.native( output ) ).toBe( + path.join( fs.realpathSync.native( project ), 'build', 'demo.wasm' ) ); } ); diff --git a/src/lib/cli/format.ts b/src/lib/cli/format.ts index de23d4e7e..fdbffbb86 100644 --- a/src/lib/cli/format.ts +++ b/src/lib/cli/format.ts @@ -46,11 +46,19 @@ export function formatData( } } +function escapeControlCharacter( character: string ): string { + const codePoint = character.codePointAt( 0 ); + if ( codePoint === undefined ) { + return ''; + } + return String.raw`\u${ codePoint.toString( 16 ).padStart( 4, '0' ) }`; +} + function json( data: Record< string, unknown >[] | Tuple[] ): string { return JSON.stringify( data, null, '\t' ).replace( // JSON.stringify already escapes C0 controls, but not DEL or C1 controls. /[\u007f-\u009f]/g, - character => `\\u${ character.charCodeAt( 0 ).toString( 16 ).padStart( 4, '0' ) }` + escapeControlCharacter ); } diff --git a/src/lib/edge-workers/confirmation.ts b/src/lib/edge-workers/confirmation.ts index 32a736bd9..48af5e834 100644 --- a/src/lib/edge-workers/confirmation.ts +++ b/src/lib/edge-workers/confirmation.ts @@ -28,6 +28,26 @@ export function isInteractiveEdgeWorkers( options: { nonInteractive?: boolean } ); } +function productionMutationConfirmationMessage( + request: ProductionMutationConfirmationRequest +): string { + const target = `${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }`; + if ( request.action === 'enable' ) { + return `Enable edge worker "${ escapeTerminalText( + request.workerNames[ 0 ] + ) }" on ${ target }?`; + } + + const action = request.enableAfterDeploy ? 'Deploy and enable' : 'Deploy'; + const workerLabel = request.workerNames.length === 1 ? 'edge worker' : 'edge workers'; + const preposition = request.enableAfterDeploy ? 'on' : 'to'; + const workerNames = request.workerNames.map( escapeTerminalText ).join( ', ' ); + + return `${ action } ${ request.workerNames.length } ${ workerLabel } (${ workerNames }) ${ preposition } ${ target }?`; +} + export async function confirmProductionEdgeWorkerMutation( request: ProductionMutationConfirmationRequest, confirmFn: EdgeWorkerConfirmFunction @@ -47,20 +67,7 @@ export async function confirmProductionEdgeWorkerMutation( ); } - const message = - request.action === 'deploy' - ? `${ request.enableAfterDeploy ? 'Deploy and enable' : 'Deploy' } ${ - request.workerNames.length - } edge worker${ request.workerNames.length === 1 ? '' : 's' } (${ request.workerNames - .map( escapeTerminalText ) - .join( ', ' ) }) ${ request.enableAfterDeploy ? 'on' : 'to' } ${ escapeTerminalText( - request.appName - ) }.${ escapeTerminalText( request.envType ) }?` - : `Enable edge worker "${ escapeTerminalText( - request.workerNames[ 0 ] - ) }" on ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( - request.envType - ) }?`; + const message = productionMutationConfirmationMessage( request ); if ( ! ( await confirmFn( message ) ) ) { throw new UserError( 'Command cancelled by user.' ); diff --git a/src/lib/edge-workers/output.ts b/src/lib/edge-workers/output.ts index 9e4e4676f..0d179709f 100644 --- a/src/lib/edge-workers/output.ts +++ b/src/lib/edge-workers/output.ts @@ -4,13 +4,19 @@ const TERMINAL_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; // eslint-disable-next-line no-control-regex const TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; +function escapeControlCharacter( character: string ): string { + const codePoint = character.codePointAt( 0 ); + if ( codePoint === undefined ) { + return ''; + } + return String.raw`\u${ codePoint.toString( 16 ).padStart( 4, '0' ) }`; +} + export function hasTerminalControlCharacters( value: string ): boolean { return TERMINAL_CONTROL_CHARACTER.test( value ); } /** Render untrusted text without allowing it to emit terminal control characters. */ export function escapeTerminalText( value: unknown ): string { - return String( value ).replace( TERMINAL_CONTROL_CHARACTERS, character => { - return `\\u${ character.charCodeAt( 0 ).toString( 16 ).padStart( 4, '0' ) }`; - } ); + return String( value ).replace( TERMINAL_CONTROL_CHARACTERS, escapeControlCharacter ); } diff --git a/src/lib/edge-workers/validation.ts b/src/lib/edge-workers/validation.ts index 9402693fc..47cd1483b 100644 --- a/src/lib/edge-workers/validation.ts +++ b/src/lib/edge-workers/validation.ts @@ -86,6 +86,40 @@ function lstatIfExists( target: string ): fs.Stats | undefined { } } +function assertCanonicalPathWithin( root: string, candidate: string, label: string ): void { + if ( ! isPathWithin( root, candidate ) ) { + throw new UserError( `${ label } must stay within "${ root }".` ); + } +} + +function ensureOutputDirectory( target: string, label: string ): void { + const stat = lstatIfExists( target ); + if ( ! stat ) { + fs.mkdirSync( target ); + return; + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } must not be a symbolic link.` ); + } + if ( ! stat.isDirectory() ) { + throw new UserError( `${ label } must be a directory.` ); + } +} + +function validateExistingOutput( outputPath: string, canonicalRoot: string, label: string ): void { + const stat = lstatIfExists( outputPath ); + if ( ! stat ) { + return; + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } must not be a symbolic link.` ); + } + if ( ! stat.isFile() ) { + throw new UserError( `${ label } must be a regular file.` ); + } + assertCanonicalPathWithin( canonicalRoot, realpath( outputPath, label ), label ); +} + /** Resolve an existing input and require its canonical target to remain below the canonical root. */ export function resolveExistingPathWithin( root: string, @@ -123,39 +157,13 @@ export function resolveOutputPathWithin( for ( const component of components ) { current = path.join( current, component ); - const stat = lstatIfExists( current ); - if ( stat ) { - if ( stat.isSymbolicLink() ) { - throw new UserError( `${ directoryLabel } must not be a symbolic link.` ); - } - if ( ! stat.isDirectory() ) { - throw new UserError( `${ directoryLabel } must be a directory.` ); - } - } else { - fs.mkdirSync( current ); - } - - const canonicalDirectory = realpath( current, directoryLabel ); - if ( ! isPathWithin( canonicalRoot, canonicalDirectory ) ) { - throw new UserError( `${ directoryLabel } must stay within "${ canonicalRoot }".` ); - } + ensureOutputDirectory( current, directoryLabel ); + assertCanonicalPathWithin( canonicalRoot, realpath( current, directoryLabel ), directoryLabel ); } const canonicalParent = realpath( path.dirname( resolvedPath ), directoryLabel ); const outputPath = path.join( canonicalParent, path.basename( resolvedPath ) ); - const outputStat = lstatIfExists( outputPath ); - if ( outputStat ) { - if ( outputStat.isSymbolicLink() ) { - throw new UserError( `${ label } must not be a symbolic link.` ); - } - if ( ! outputStat.isFile() ) { - throw new UserError( `${ label } must be a regular file.` ); - } - const canonicalOutput = realpath( outputPath, label ); - if ( ! isPathWithin( canonicalRoot, canonicalOutput ) ) { - throw new UserError( `${ label } must stay within "${ canonicalRoot }".` ); - } - } + validateExistingOutput( outputPath, canonicalRoot, label ); return outputPath; } From daed446c5e552c1ed4299c74fad2d9f030e9c45b Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 10:44:18 -0500 Subject: [PATCH 38/41] ci: ensure Docker runs on Windows tests --- .github/workflows/windows-tests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index a620195c5..bf669ca25 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -37,5 +37,15 @@ jobs: - name: Unit Tests run: npm run jest + # Work around intermittent hosted-runner Docker startup failures: + # https://github.com/actions/runner-images/issues/13729 + - name: Ensure Docker is running + shell: powershell + run: | + $dockerService = Get-Service -Name "docker" + if ($dockerService.Status -ne "Running") { + Start-Service -Name "docker" + } + - name: Test Command line run: ./__tests__/e2e_test.bat From 6d353b38874875c6a8df02b3a7104f5750983329 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 12:13:44 -0500 Subject: [PATCH 39/41] build(edge-workers): bump SDK to 0.3.2 --- __tests__/lib/edge-workers/toolchains.js | 2 +- docs/EDGE-WORKERS.md | 4 ++-- src/lib/edge-workers/toolchains/assemblyscript/constants.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 93de21d8f..127b90706 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -44,7 +44,7 @@ describe( 'edge-workers toolchains', () => { expect( fs.existsSync( path.join( project, 'workers' ) ) ).toBe( true ); const pkg = JSON.parse( fs.readFileSync( path.join( project, 'package.json' ), 'utf8' ) ); - expect( pkg.dependencies[ '@automattic/vip-edge-workers-sdk' ] ).toBe( '0.3.0' ); + expect( pkg.dependencies[ '@automattic/vip-edge-workers-sdk' ] ).toBe( '0.3.2' ); expect( pkg.devDependencies.assemblyscript ).toBe( '0.27.0' ); const readme = fs.readFileSync( path.join( project, 'README.md' ), 'utf8' ); diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 7167aa021..90d1c3d9e 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -32,7 +32,7 @@ edge-workers/ ``` `build/` is created when a worker is compiled and is ignored by Git. The generated direct -dependencies are exact: `@automattic/vip-edge-workers-sdk` is `0.3.0` and `assemblyscript` is +dependencies are exact: `@automattic/vip-edge-workers-sdk` is `0.3.2` and `assemblyscript` is `0.27.0`. The starter exports only `alloc` and `on_client_response`; the other request phases are commented examples and are not active WASM exports. @@ -43,7 +43,7 @@ The project descriptor selects the toolchain for every worker in the project: ```json { "type": "assemblyscript", - "sdk": "@automattic/vip-edge-workers-sdk@0.3.0" + "sdk": "@automattic/vip-edge-workers-sdk@0.3.2" } ``` diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts index a3ff66eb6..5fa4cd7f1 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.3.0'; +export const SDK_VERSION = '0.3.2'; export const ASSEMBLYSCRIPT_VERSION = '0.27.0'; export const DEFAULT_ENTRY = 'assembly/index.ts'; export const BUILD_DIR = 'build'; From a0da7b403299bdf7772dccb1c6c82e75181f26fa Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Fri, 21 Aug 2026 10:04:52 -0500 Subject: [PATCH 40/41] fix(edge-workers): address review feedback - api: set exitOnError:false on all edge-worker API calls so GraphQL failures propagate to the command try/catch instead of exiting the process, restoring the intended error paths for deploy/disable/enable/get - api: filter the environment server-side via environments(id: $envId) rather than fetching all environments and filtering client-side - build/validate: reject a worker name together with --all - assemblyscript scaffold: lstat the target so a symlinked directory is rejected rather than followed - readPrebuiltWorker: reject a symlinked build artifact, not just the dir - project: reject symlinked descriptor/manifest before reading - location/project: guard value-less --location/--path (boolean) with a clear UserError instead of a TypeError - dedupe BUILD_DIR into project.ts alongside the other layout constants Co-Authored-By: Claude Opus 4.8 --- __tests__/bin/vip-edge-workers-build.js | 13 ++++++ __tests__/bin/vip-edge-workers-validate.js | 11 +++++ __tests__/lib/api-edge-workers.test.ts | 32 +++++++++++++ __tests__/lib/edge-workers/location.js | 6 +++ __tests__/lib/edge-workers/project.js | 33 +++++++++++++- __tests__/lib/edge-workers/toolchains.js | 10 +++++ src/bin/vip-edge-workers-build.js | 7 ++- src/bin/vip-edge-workers-validate.js | 4 ++ src/lib/api/edge-workers.ts | 42 ++++++++--------- src/lib/edge-workers/index.ts | 8 ++-- src/lib/edge-workers/location.ts | 9 ++++ src/lib/edge-workers/project.ts | 45 +++++++++++++------ .../toolchains/assemblyscript/constants.ts | 1 - .../toolchains/assemblyscript/index.ts | 7 +-- .../toolchains/assemblyscript/templates.ts | 10 +---- 15 files changed, 184 insertions(+), 54 deletions(-) diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js index 02daf3d3b..064993b8c 100644 --- a/__tests__/bin/vip-edge-workers-build.js +++ b/__tests__/bin/vip-edge-workers-build.js @@ -73,6 +73,19 @@ describe( 'edgeWorkersBuildCommand()', () => { expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 ); } ); + it( 'rejects a worker name together with --all', async () => { + await expect( edgeWorkersBuildCommand( [ 'alpha' ], { all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Supply either a worker name or --all, not both.' + ); + expect( project.findWorker ).not.toHaveBeenCalled(); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( lib.buildWorker ).not.toHaveBeenCalled(); + } ); + it( 'reports when the project has no workers', async () => { project.discoverWorkers.mockReturnValue( [] ); diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js index 9f35010ea..af57a5f51 100644 --- a/__tests__/bin/vip-edge-workers-validate.js +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -166,4 +166,15 @@ describe( 'edgeWorkersValidateCommand()', () => { expect.stringContaining( 'supply a worker name' ) ); } ); + + it( 'rejects a worker name together with --all', async () => { + await expect( + edgeWorkersValidateCommand( [ 'my-worker' ], { ...opts, all: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Supply either a worker name or --all, not both.' ) + ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( api.validateEdgeWorker ).not.toHaveBeenCalled(); + } ); } ); diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts index d43311f3d..cdc78c76c 100644 --- a/__tests__/lib/api-edge-workers.test.ts +++ b/__tests__/lib/api-edge-workers.test.ts @@ -62,6 +62,22 @@ describe( 'edge worker read query contracts', () => { expect( query ).not.toContain( 'wasmBinary' ); } ); + it( 'filters the environment server-side by id rather than client-side', async () => { + await listEdgeWorkers( 1, 3 ); + + const call = mockQuery.mock.calls[ 0 ][ 0 ] as { query: DocumentNode; variables?: unknown }; + const query = print( call.query ); + + expect( query ).toContain( 'environments(id: $envId)' ); + expect( call.variables ).toMatchObject( { appId: 1, envId: 3 } ); + } ); + + it( 'requests reads with exitOnError disabled so failures can be caught', async () => { + await listEdgeWorkers( 1, 3 ); + + expect( mockedAPI.default ).toHaveBeenCalledWith( { exitOnError: false } ); + } ); + it.each( [ [ 'missing data', undefined ], [ 'null app', { app: null } ], @@ -113,4 +129,20 @@ describe( 'edge worker mutation result contracts', () => { await expect( deleteEdgeWorker( 3, 7 ) ).rejects.toThrow( /did not confirm deletion/ ); } ); + + it.each( [ + [ 'validateEdgeWorker', () => validateEdgeWorker( 3, 'V0FTTQ==' ) ], + [ 'createEdgeWorker', () => createEdgeWorker( 3, { name: 'demo', wasmBinary: 'V0FTTQ==' } ) ], + [ 'updateEdgeWorker', () => updateEdgeWorker( 3, 7, { wasmBinary: 'V0FTTQ==' } ) ], + [ 'setEdgeWorkerActive', () => setEdgeWorkerActive( 3, 7, true ) ], + [ 'deleteEdgeWorker', () => deleteEdgeWorker( 3, 7 ) ], + ] )( 'requests %s with exitOnError disabled so failures can be caught', async ( key, call ) => { + mockMutate.mockResolvedValueOnce( { + data: { [ key ]: key === 'deleteEdgeWorker' ? true : { id: 7 } }, + } ); + + await call(); + + expect( mockedAPI.default ).toHaveBeenCalledWith( { exitOnError: false } ); + } ); } ); diff --git a/__tests__/lib/edge-workers/location.js b/__tests__/lib/edge-workers/location.js index 61145f745..08d1a4493 100644 --- a/__tests__/lib/edge-workers/location.js +++ b/__tests__/lib/edge-workers/location.js @@ -25,4 +25,10 @@ describe( 'parseLocationOption()', () => { expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); } ); + + it( 'rejects a --location flag passed without a value', () => { + // A value-less `--location` arrives as boolean true from the arg parser; + // guard so it does not throw a TypeError from `.indexOf()`. + expect( () => parseLocationOption( true ) ).toThrow( /--location flag requires a value/ ); + } ); } ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js index b83b83bfe..f37ef2465 100644 --- a/__tests__/lib/edge-workers/project.js +++ b/__tests__/lib/edge-workers/project.js @@ -66,6 +66,13 @@ describe( 'edge-workers project', () => { it( 'throws with guidance when nothing is found', () => { expect( () => resolveProjectDir( {}, tmp ) ).toThrow( /vip edge-workers init/ ); } ); + + it( 'rejects a --path flag passed without a value', () => { + // A value-less `--path` arrives as boolean true from the arg parser. + expect( () => resolveProjectDir( { path: true }, tmp ) ).toThrow( + /--path flag requires a path/ + ); + } ); } ); describe( 'descriptor', () => { @@ -94,6 +101,17 @@ describe( 'edge-workers project', () => { fs.writeFileSync( path.join( project, 'edge-workers.json' ), 'null' ); expect( () => readProjectDescriptor( project ) ).toThrow( /invalid "type"/ ); } ); + + it( 'rejects a symlinked descriptor', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + const outside = path.join( tmp, 'outside.json' ); + fs.writeFileSync( outside, '{"type":"assemblyscript"}' ); + fs.symlinkSync( outside, path.join( project, 'edge-workers.json' ), 'file' ); + expect( () => readProjectDescriptor( project ) ).toThrow( + /project descriptor at .* must not be a symbolic link/ + ); + } ); } ); describe( 'worker manifests', () => { @@ -113,6 +131,17 @@ describe( 'edge-workers project', () => { ); expect( () => readWorkerManifest( worker ) ).toThrow( /Worker entry must stay within/ ); } ); + + it( 'rejects a symlinked manifest', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + const outside = path.join( tmp, 'outside-manifest.json' ); + fs.writeFileSync( outside, '{"name":"demo","entry":"assembly/index.ts"}' ); + fs.symlinkSync( outside, path.join( worker, 'worker.json' ), 'file' ); + expect( () => readWorkerManifest( worker ) ).toThrow( + /worker manifest at .* must not be a symbolic link/ + ); + } ); } ); describe( 'discoverWorkers / findWorker', () => { @@ -208,7 +237,7 @@ describe( 'edge-workers project', () => { }; expect( () => readPrebuiltWorker( project, worker ) ).toThrow( - /Worker build artifact must stay within/ + /Worker build artifact must not be a symbolic link/ ); } ); @@ -226,7 +255,7 @@ describe( 'edge-workers project', () => { }; expect( () => readPrebuiltWorker( project, worker ) ).toThrow( - /Worker build artifact must stay within/ + /Worker build artifact must not be a symbolic link/ ); } ); } ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js index 127b90706..5b65d5c1d 100644 --- a/__tests__/lib/edge-workers/toolchains.js +++ b/__tests__/lib/edge-workers/toolchains.js @@ -80,6 +80,16 @@ describe( 'edge-workers toolchains', () => { expect( fs.readFileSync( project, 'utf8' ) ).toBe( 'customer content\n' ); } ); + it( 'refuses a symlinked directory target', () => { + const realDir = path.join( tmp, 'real-dir' ); + fs.mkdirSync( realDir ); + const project = path.join( tmp, 'linked-app' ); + fs.symlinkSync( realDir, project, 'dir' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not a directory/ ); + expect( fs.existsSync( path.join( realDir, 'edge-workers.json' ) ) ).toBe( false ); + } ); + it( 'scaffolds a worker with a manifest and entry file', () => { const project = path.join( tmp, 'proj' ); tc.scaffoldProject( project ); diff --git a/src/bin/vip-edge-workers-build.js b/src/bin/vip-edge-workers-build.js index 1201efde0..1b80e07a9 100644 --- a/src/bin/vip-edge-workers-build.js +++ b/src/bin/vip-edge-workers-build.js @@ -29,10 +29,13 @@ export async function edgeWorkersBuildCommand( args = [], opt = {} ) { await trackEvent( 'edge_workers_build_command_execute', { name, all: Boolean( opt.all ) } ); try { + if ( name && opt.all ) { + throw new UserError( 'Supply either a worker name or --all, not both.' ); + } + const projectDir = resolveProjectDir( { path: opt.path } ); - const workers = - name && ! opt.all ? [ findWorker( projectDir, name ) ] : discoverWorkers( projectDir ); + const workers = name ? [ findWorker( projectDir, name ) ] : discoverWorkers( projectDir ); if ( ! workers.length ) { throw new UserError( diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js index 26ca7a0bf..4b3a71cfb 100644 --- a/src/bin/vip-edge-workers-validate.js +++ b/src/bin/vip-edge-workers-validate.js @@ -37,6 +37,10 @@ export async function edgeWorkersValidateCommand( args = [], opt = {} ) { let invalidCount = 0; try { + if ( name && opt.all ) { + throw new Error( 'Supply either a worker name or --all, not both.' ); + } + const projectDir = resolveProjectDir( { path: opt.path } ); let workers; diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts index 034916ac0..003b45efc 100644 --- a/src/lib/api/edge-workers.ts +++ b/src/lib/api/edge-workers.ts @@ -1,7 +1,7 @@ /** * GraphQL access for edge workers. * - * The schema exposes workers under `app.environments[].edgeWorkers`, with + * The schema exposes workers under `app.environments(id:).edgeWorkers`, with * `source` as an on-demand read field, 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`. @@ -78,13 +78,13 @@ function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: numb if ( ! Array.isArray( environments ) ) { return invalidReadResponse(); } - if ( - ! environments.every( candidate => isObject( candidate ) && typeof candidate.id === 'number' ) - ) { + // The query filters by environment id server-side, so we expect the single + // matching environment (or none). Confirm the returned id matches before use. + const env = environments[ 0 ]; + if ( ! isObject( env ) || typeof env.id !== 'number' || env.id !== envId ) { return invalidReadResponse(); } - const env = environments.find( candidate => candidate.id === envId ); - if ( ! env || ! Array.isArray( env.edgeWorkers ) ) { + if ( ! Array.isArray( env.edgeWorkers ) ) { return invalidReadResponse(); } return env.edgeWorkers; @@ -99,12 +99,12 @@ function requireMutationPayload< T >( operation: string, value: T | null | undef /** List the edge workers deployed to an environment without source. */ export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.query< EdgeWorkersQueryResult >( { query: gql` - query EdgeWorkers($appId: Int!) { + query EdgeWorkers($appId: Int!, $envId: Int!) { app(id: $appId) { - environments { + environments(id: $envId) { id edgeWorkers { ${ EDGE_WORKER_FIELDS } @@ -113,7 +113,7 @@ export async function listEdgeWorkers( appId: number, envId: number ): Promise< } } `, - variables: { appId }, + variables: { appId, envId }, fetchPolicy: 'no-cache', } ); @@ -122,8 +122,8 @@ export async function listEdgeWorkers( appId: number, envId: number ): Promise< /** * Fetch a single worker by name. The schema has no single-worker query, so this - * requests the environment's workers and filters client-side. Source is fetched - * only when explicitly requested. + * requests the target environment's workers and matches by name. Source is + * fetched only when explicitly requested. */ export async function getEdgeWorker( appId: number, @@ -131,14 +131,14 @@ export async function getEdgeWorker( name: string, options: { includeSource?: boolean } = {} ): Promise< EdgeWorker | null > { - const api = API(); + const api = API( { exitOnError: false } ); const fields = options.includeSource === true ? `${ EDGE_WORKER_FIELDS }\nsource` : EDGE_WORKER_FIELDS; const response = await api.query< EdgeWorkersQueryResult >( { query: gql` - query EdgeWorkerDetail($appId: Int!) { + query EdgeWorkerDetail($appId: Int!, $envId: Int!) { app(id: $appId) { - environments { + environments(id: $envId) { id edgeWorkers { ${ fields } @@ -147,7 +147,7 @@ export async function getEdgeWorker( } } `, - variables: { appId }, + variables: { appId, envId }, fetchPolicy: 'no-cache', } ); @@ -176,7 +176,7 @@ export async function createEdgeWorker( envId: number, input: EdgeWorkerWriteInput & { name: string; wasmBinary: string } ): Promise< EdgeWorker > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.mutate< { createEdgeWorker: EdgeWorker | null } >( { mutation: gql` mutation CreateEdgeWorker($input: CreateEdgeWorkerInput!) { @@ -196,7 +196,7 @@ export async function updateEdgeWorker( edgeWorkerId: number, input: EdgeWorkerWriteInput ): Promise< EdgeWorker > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.mutate< { updateEdgeWorker: EdgeWorker | null } >( { mutation: gql` mutation UpdateEdgeWorker($input: UpdateEdgeWorkerInput!) { @@ -225,7 +225,7 @@ export async function validateEdgeWorker( envId: number, wasmBinary: string ): Promise< EdgeWorkerValidationResult > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.mutate< { validateEdgeWorker: EdgeWorkerValidationResult | null; } >( { @@ -249,7 +249,7 @@ export async function setEdgeWorkerActive( edgeWorkerId: number, active: boolean ): Promise< EdgeWorker > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.mutate< { setEdgeWorkerActive: EdgeWorker | null } >( { mutation: gql` mutation SetEdgeWorkerActive($input: SetEdgeWorkerActiveInput!) { @@ -265,7 +265,7 @@ export async function setEdgeWorkerActive( } export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< void > { - const api = API(); + const api = API( { exitOnError: false } ); const response = await api.mutate< { deleteEdgeWorker: boolean | null } >( { mutation: gql` mutation DeleteEdgeWorker($input: DeleteEdgeWorkerInput!) { diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts index d3df08049..07efeaf94 100644 --- a/src/lib/edge-workers/index.ts +++ b/src/lib/edge-workers/index.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import UserError from '../user-error'; -import { readProjectDescriptor } from './project'; +import { BUILD_DIR, readProjectDescriptor } from './project'; import { getToolchain } from './toolchains'; import { resolveExistingPathWithin, resolvePathWithin, validateWorkerName } from './validation'; @@ -18,9 +18,6 @@ export * from './location'; export { getToolchain } from './toolchains'; export * from './validation'; -/** Conventional output directory for compiled artifacts, relative to the project root. */ -export const BUILD_DIR = 'build'; - interface BuiltArtifact { wasmPath: string; base64: string; @@ -46,6 +43,9 @@ export function readPrebuiltWorker( projectDir: string, worker: DiscoveredWorker if ( fs.lstatSync( buildRoot ).isSymbolicLink() ) { throw new UserError( 'Worker build directory must not be a symbolic link.' ); } + if ( fs.lstatSync( candidate ).isSymbolicLink() ) { + throw new UserError( 'Worker build artifact must not be a symbolic link.' ); + } const canonicalBuildRoot = resolveExistingPathWithin( projectDir, BUILD_DIR, diff --git a/src/lib/edge-workers/location.ts b/src/lib/edge-workers/location.ts index 21995a3ed..38721438d 100644 --- a/src/lib/edge-workers/location.ts +++ b/src/lib/edge-workers/location.ts @@ -11,6 +11,15 @@ import { EDGE_WORKER_LOCATION_OPERATORS } from './types'; import type { EdgeWorkerLocation, EdgeWorkerLocationOperator } from './types'; export function parseLocationOption( raw: string ): EdgeWorkerLocation { + // `--location` passed without a value arrives as a boolean, not a string; + // guard so we surface a clear error instead of a TypeError from `.indexOf()`. + if ( typeof raw !== 'string' ) { + throw new UserError( + 'The --location flag requires a value in the form ":" ' + + `(e.g. "starts_with:/api/"). Operators: ${ EDGE_WORKER_LOCATION_OPERATORS.join( ', ' ) }.` + ); + } + // Split on the first colon only: the value may itself contain colons. const separator = raw.indexOf( ':' ); const operator = separator > 0 ? raw.slice( 0, separator ) : ''; diff --git a/src/lib/edge-workers/project.ts b/src/lib/edge-workers/project.ts index 157782139..8a1144280 100644 --- a/src/lib/edge-workers/project.ts +++ b/src/lib/edge-workers/project.ts @@ -24,6 +24,8 @@ import type { DiscoveredWorker, ProjectDescriptor, WorkerManifest } from './type export const PROJECT_DESCRIPTOR_FILE = 'edge-workers.json'; export const WORKER_MANIFEST_FILE = 'worker.json'; export const WORKERS_DIR = 'workers'; +/** Conventional output directory for compiled artifacts, relative to the project root. */ +export const BUILD_DIR = 'build'; /** Conventional subfolder checked when resolving from a site-repo root. */ export const CONVENTIONAL_PROJECT_DIR = 'edge-workers'; @@ -31,6 +33,27 @@ function isProjectRoot( dir: string ): boolean { return fs.existsSync( path.join( dir, PROJECT_DESCRIPTOR_FILE ) ); } +/** + * Read a project file as UTF-8, rejecting symlinks before opening so a symlinked + * descriptor/manifest can't redirect the read outside the project tree. + */ +function readProjectFile( file: string, label: string ): string { + let stat: fs.Stats; + try { + stat = fs.lstatSync( file ); + } catch { + throw new UserError( `Could not read ${ label } at "${ file }".` ); + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } at "${ file }" must not be a symbolic link.` ); + } + try { + return fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read ${ label } at "${ file }".` ); + } +} + /** * Resolve the edge-workers project directory for a command. * @@ -44,7 +67,13 @@ export function resolveProjectDir( opts: { path?: string } = {}, cwd: string = process.cwd() ): string { - if ( opts.path ) { + if ( opts.path !== undefined ) { + // `--path` passed without a value arrives as a boolean, not a string; + // guard so we surface a clear error instead of a throw from path.resolve(). + if ( typeof opts.path !== 'string' || opts.path === '' ) { + throw new UserError( 'The --path flag requires a path to the edge-workers project.' ); + } + const explicit = path.resolve( cwd, opts.path ); if ( ! isProjectRoot( explicit ) ) { throw new UserError( @@ -84,12 +113,7 @@ export function resolveProjectDir( 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 }".` ); - } + const raw = readProjectFile( file, 'project descriptor' ); let parsed: unknown; try { @@ -109,12 +133,7 @@ export function writeProjectDescriptor( projectDir: string, descriptor: ProjectD 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 }".` ); - } + const raw = readProjectFile( file, 'worker manifest' ); let parsed: unknown; try { diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts index 5fa4cd7f1..330ffe80c 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts @@ -8,4 +8,3 @@ export const SDK_PACKAGE = '@automattic/vip-edge-workers-sdk'; export const SDK_VERSION = '0.3.2'; 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 index e792036ce..f246de346 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/index.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -14,10 +14,10 @@ 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 { DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; import { GITIGNORE, PACKAGE_JSON, README, starterWorker, TSCONFIG_JSON } from './templates'; import UserError from '../../../user-error'; -import { WORKERS_DIR, writeProjectDescriptor, writeWorkerManifest } from '../../project'; +import { BUILD_DIR, WORKERS_DIR, writeProjectDescriptor, writeWorkerManifest } from '../../project'; import { resolveExistingPathWithin, resolveOutputPathWithin, @@ -36,7 +36,8 @@ function writeFileEnsuringDir( filePath: string, contents: string ): void { function assertScaffoldTargetAvailable( projectDir: string ): void { if ( ! fs.existsSync( projectDir ) ) return; - if ( ! fs.statSync( projectDir ).isDirectory() ) { + // lstatSync (not statSync) so a symlink to a directory is rejected rather than followed. + if ( ! fs.lstatSync( projectDir ).isDirectory() ) { throw new UserError( `Cannot create an edge-workers project at "${ projectDir }": target is not a directory.` ); diff --git a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts index 52be87405..41e9ebef8 100644 --- a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts +++ b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts @@ -5,14 +5,8 @@ * 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'; +import { ASSEMBLYSCRIPT_VERSION, DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; +import { BUILD_DIR, WORKERS_DIR } from '../../project'; export const PACKAGE_JSON = { name: 'edge-workers', From dbf056fad2422eeb4db05c693001f0adf0ed6f14 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Fri, 21 Aug 2026 12:16:51 -0500 Subject: [PATCH 41/41] refactor(edge-workers): simplify pickEnvWorkers after server-side filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the read queries filter by environments(id: $envId), the client-side id match, id-type scan and envId re-check are redundant — matching how app-logs/envvar consume environments(id:). pickEnvWorkers keeps only the fail-closed shape validation and no longer needs envId. Replaces the two obsolete id-mismatch tests with an empty-environments (target-not-found) fail-closed case. Co-Authored-By: Claude Opus 4.8 --- __tests__/lib/api-edge-workers.test.ts | 6 +----- src/lib/api/edge-workers.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts index cdc78c76c..fb4b1efe2 100644 --- a/__tests__/lib/api-edge-workers.test.ts +++ b/__tests__/lib/api-edge-workers.test.ts @@ -86,11 +86,7 @@ describe( 'edge worker read query contracts', () => { [ 'null environments', { app: { environments: null } } ], [ 'non-array environments', { app: { environments: {} } } ], [ 'malformed environment', { app: { environments: [ null ] } } ], - [ - 'wrongly typed target environment id', - { app: { environments: [ { id: '3', edgeWorkers: [] } ] } }, - ], - [ 'missing target environment', { app: { environments: [ { id: 4, edgeWorkers: [] } ] } } ], + [ 'empty environments (target not found)', { app: { environments: [] } } ], [ 'missing edgeWorkers', { app: { environments: [ { id: 3 } ] } } ], [ 'null edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: null } ] } } ], [ 'non-array edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: {} } ] } } ], diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts index 003b45efc..8697eabd6 100644 --- a/src/lib/api/edge-workers.ts +++ b/src/lib/api/edge-workers.ts @@ -70,7 +70,12 @@ function invalidReadResponse(): never { throw new UserError( 'EdgeWorkers query returned an invalid response.' ); } -function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: number ): EdgeWorker[] { +/** + * Extract the target environment's workers from a `environments(id:)`-filtered + * response, failing closed (UserError) on any malformed shape. The query filters + * by id server-side, so we take the single returned environment. + */ +function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined ): EdgeWorker[] { if ( ! isObject( result ) || ! isObject( result.app ) ) { return invalidReadResponse(); } @@ -78,13 +83,8 @@ function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: numb if ( ! Array.isArray( environments ) ) { return invalidReadResponse(); } - // The query filters by environment id server-side, so we expect the single - // matching environment (or none). Confirm the returned id matches before use. const env = environments[ 0 ]; - if ( ! isObject( env ) || typeof env.id !== 'number' || env.id !== envId ) { - return invalidReadResponse(); - } - if ( ! Array.isArray( env.edgeWorkers ) ) { + if ( ! isObject( env ) || ! Array.isArray( env.edgeWorkers ) ) { return invalidReadResponse(); } return env.edgeWorkers; @@ -117,7 +117,7 @@ export async function listEdgeWorkers( appId: number, envId: number ): Promise< fetchPolicy: 'no-cache', } ); - return pickEnvWorkers( response.data, envId ); + return pickEnvWorkers( response.data ); } /** @@ -151,7 +151,7 @@ export async function getEdgeWorker( fetchPolicy: 'no-cache', } ); - return pickEnvWorkers( response.data, envId ).find( worker => worker.name === name ) ?? null; + return pickEnvWorkers( response.data ).find( worker => worker.name === name ) ?? null; } /** Find a deployed worker by name, or null. Used to reconcile create-vs-update. */