From 48492f76f34110ee7f9faa1577a7d7e0e318906a Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 09:31:14 -0700 Subject: [PATCH 1/7] Add POS UI extension intercept declaration TOML schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POS UI extensions can now declare, in shopify.extension.toml, which host-mediated intercept events they participate in and whether they intend to block progress on each. This is the event-scoped POS analogue of checkout's single `capabilities.block_progress` capability. TOML shape (top-level array of tables): [[intercepts]] event = "beforecheckout" block_progress = true - event: required, validated against POS_INTERCEPT_EVENTS (beforecheckout, beforepayment) — extensible const - block_progress: optional boolean, defaults to false - duplicate events are rejected - declarations flow through deployConfig for later backend wiring Prototype: schema + parsing + validation + tests (Area B, step 1). Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../specifications/pos_ui_extension.test.ts | 155 ++++++++++++++++++ .../specifications/pos_ui_extension.ts | 35 +++- 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts new file mode 100644 index 00000000000..4c7c5d7b7c8 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts @@ -0,0 +1,155 @@ +import {POS_INTERCEPT_EVENTS} from './pos_ui_extension.js' +import * as appModule from '../../app/app.js' +import {ExtensionInstance} from '../extension-instance.js' +import {loadLocalExtensionsSpecifications} from '../load-specifications.js' +import {placeholderAppConfiguration} from '../../app/app.test-data.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' + +describe('pos_ui_extension', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'pos_ui_extension')! + + interface ParsedPosUIConfig { + state: 'ok' | 'error' + data?: {intercepts?: {event: string; block_progress: boolean}[]} + errors?: {message: string}[] + } + + function parse(configuration: Record): ParsedPosUIConfig { + return specification.parseConfigurationObject({ + type: 'pos_ui_extension', + name: 'My POS Extension', + ...configuration, + }) as ParsedPosUIConfig + } + + async function getTestPosUIExtension(directory: string, configuration: Record = {}) { + const configurationPath = joinPath(directory, 'shopify.extension.toml') + const parsed = specification.parseConfigurationObject({ + type: 'pos_ui_extension', + name: 'My POS Extension', + ...configuration, + }) + if (parsed.state !== 'ok') { + throw new Error(`Couldn't parse configuration: ${JSON.stringify(parsed.errors)}`) + } + + return new ExtensionInstance({ + configuration: parsed.data, + directory, + specification, + configurationPath, + entryPath: '', + }) + } + + test('has the correct identifier', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await getTestPosUIExtension(tmpDir) + expect(extension.specification.identifier).toBe('pos_ui_extension') + }) + }) + + describe('intercepts schema', () => { + test('parses when no intercepts are declared', () => { + const parsed = parse({}) + expect(parsed.state).toBe('ok') + expect(parsed.data?.intercepts).toBeUndefined() + }) + + test('parses valid intercept declarations', () => { + const parsed = parse({ + intercepts: [ + {event: 'beforecheckout', block_progress: true}, + {event: 'beforepayment', block_progress: false}, + ], + }) + + expect(parsed.state).toBe('ok') + expect(parsed.data?.intercepts).toEqual([ + {event: 'beforecheckout', block_progress: true}, + {event: 'beforepayment', block_progress: false}, + ]) + }) + + test('defaults block_progress to false when omitted', () => { + const parsed = parse({ + intercepts: [{event: 'beforecheckout'}], + }) + + expect(parsed.state).toBe('ok') + expect(parsed.data?.intercepts).toEqual([{event: 'beforecheckout', block_progress: false}]) + }) + + test('rejects an unknown intercept event', () => { + const parsed = parse({ + intercepts: [{event: 'notarealevent'}], + }) + + expect(parsed.state).toBe('error') + expect(parsed.errors?.[0]?.message).toContain( + `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, + ) + }) + + test('rejects a non-boolean block_progress', () => { + const parsed = parse({ + intercepts: [{event: 'beforecheckout', block_progress: 'yes'}], + }) + + expect(parsed.state).toBe('error') + }) + + test('rejects duplicate intercept events', () => { + const parsed = parse({ + intercepts: [ + {event: 'beforecheckout', block_progress: true}, + {event: 'beforecheckout', block_progress: false}, + ], + }) + + expect(parsed.state).toBe('error') + expect(parsed.errors?.[0]?.message).toContain('Duplicate intercept events found: beforecheckout') + }) + }) + + describe('deployConfig()', () => { + test('includes intercepts in the deploy config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) + + const extension = await getTestPosUIExtension(tmpDir, { + intercepts: [{event: 'beforecheckout', block_progress: true}], + }) + + const deployConfig = await extension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + expect(deployConfig).toMatchObject({ + name: 'My POS Extension', + renderer_version: '1.2.3', + intercepts: [{event: 'beforecheckout', block_progress: true}], + }) + }) + }) + + test('omits intercepts when none are declared', async () => { + await inTemporaryDirectory(async (tmpDir) => { + vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) + + const extension = await getTestPosUIExtension(tmpDir) + + const deployConfig = await extension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + expect(deployConfig?.intercepts).toBeUndefined() + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index 3835d873ec7..bdb9c7c30e6 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -7,8 +7,40 @@ import {zod} from '@shopify/cli-kit/node/schema' const dependency = '@shopify/retail-ui-extensions' +// The host-mediated intercept events a POS UI extension can participate in. +// Surface-augmented and expected to grow — add new events here as POS exposes +// more interceptable workflows. See `shopify.intercept()` in ui-api-design. +export const POS_INTERCEPT_EVENTS = ['beforecheckout', 'beforepayment'] as const + +// A single intercept declaration: which event the extension participates in and +// whether it intends to block progress on that event. This is the event-scoped +// POS analogue of checkout's single `capabilities.block_progress` boolean. +const InterceptSchema = zod.object({ + event: zod.enum(POS_INTERCEPT_EVENTS, { + errorMap: () => ({ + message: `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, + }), + }), + block_progress: zod.boolean().optional().default(false), +}) + type PosUIConfigType = zod.infer -const PosUISchema = BaseSchema.extend({name: zod.string()}) +const PosUISchema = BaseSchema.extend({ + name: zod.string(), + intercepts: zod.array(InterceptSchema).optional(), +}).superRefine((config, ctx) => { + const events = config.intercepts?.map((intercept) => intercept.event) ?? [] + const duplicates = [...new Set(events.filter((event, index) => events.indexOf(event) !== index))] + if (duplicates.length > 0) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Duplicate intercept events found: ${duplicates.join( + ', ', + )}. Each intercept event may only be declared once.`, + path: ['intercepts'], + }) + } +}) const posUISpec = createExtensionSpecification({ identifier: 'pos_ui_extension', @@ -29,6 +61,7 @@ const posUISpec = createExtensionSpecification({ name: config.name, description: config.description, renderer_version: result?.version, + intercepts: config.intercepts, } }, }) From e0168faf05f9e4d6e9a14acf5a522cd074bdd582 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 09:40:08 -0700 Subject: [PATCH 2/7] Reshape POS intercept declaration to capabilities.intercepts array Per design confirmation, replace the [[intercepts]] array-of-tables with a single POS-specific capability: a string array of blockable events. [capabilities] intercepts = ["beforecheckout", "beforepayment"] Membership in the array = the extension may block that event (collapses "intercepts at all" vs "can block" into one blockable set, intended for the prototype). - Export shared CapabilitiesSchema; POS extends it with an optional `intercepts` array of POS_INTERCEPT_EVENTS enums. Shared block_progress boolean is untouched (checkout depends on it); intercepts is POS-only. - Unknown events rejected with a clear message; duplicates rejected via a uniqueness superRefine on the array. - deployConfig emits capabilities (incl. intercepts); mapping kept thin for the pending cross-repo field rename. - Tests updated: valid/empty parse, coexists-with-shared-capabilities, unknown-event + duplicate rejection, deployConfig includes/omits. Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../app/src/cli/models/extensions/schemas.ts | 2 +- .../specifications/pos_ui_extension.test.ts | 61 +++++++++---------- .../specifications/pos_ui_extension.ts | 59 ++++++++++-------- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 04978437b76..fc65f834c42 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -20,7 +20,7 @@ const IframeCapabilitySchema = zod.object({ sources: zod.array(zod.string()).optional(), }) -const CapabilitiesSchema = zod.object({ +export const CapabilitiesSchema = zod.object({ network_access: zod.boolean().optional(), block_progress: zod.boolean().optional(), api_access: zod.boolean().optional(), diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts index 4c7c5d7b7c8..333a3abecf2 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts @@ -13,7 +13,7 @@ describe('pos_ui_extension', async () => { interface ParsedPosUIConfig { state: 'ok' | 'error' - data?: {intercepts?: {event: string; block_progress: boolean}[]} + data?: {capabilities?: {intercepts?: string[]}} errors?: {message: string}[] } @@ -52,62 +52,57 @@ describe('pos_ui_extension', async () => { }) }) - describe('intercepts schema', () => { - test('parses when no intercepts are declared', () => { + describe('intercepts capability', () => { + test('parses when no capabilities are declared', () => { const parsed = parse({}) expect(parsed.state).toBe('ok') - expect(parsed.data?.intercepts).toBeUndefined() + expect(parsed.data?.capabilities?.intercepts).toBeUndefined() }) - test('parses valid intercept declarations', () => { + test('parses a valid intercepts array', () => { const parsed = parse({ - intercepts: [ - {event: 'beforecheckout', block_progress: true}, - {event: 'beforepayment', block_progress: false}, - ], + capabilities: {intercepts: ['beforecheckout', 'beforepayment']}, }) expect(parsed.state).toBe('ok') - expect(parsed.data?.intercepts).toEqual([ - {event: 'beforecheckout', block_progress: true}, - {event: 'beforepayment', block_progress: false}, - ]) + expect(parsed.data?.capabilities?.intercepts).toEqual(['beforecheckout', 'beforepayment']) }) - test('defaults block_progress to false when omitted', () => { + test('parses an empty intercepts array', () => { const parsed = parse({ - intercepts: [{event: 'beforecheckout'}], + capabilities: {intercepts: []}, }) expect(parsed.state).toBe('ok') - expect(parsed.data?.intercepts).toEqual([{event: 'beforecheckout', block_progress: false}]) + expect(parsed.data?.capabilities?.intercepts).toEqual([]) }) - test('rejects an unknown intercept event', () => { + test('coexists with shared capabilities without overriding them', () => { const parsed = parse({ - intercepts: [{event: 'notarealevent'}], + capabilities: {network_access: true, intercepts: ['beforecheckout']}, }) - expect(parsed.state).toBe('error') - expect(parsed.errors?.[0]?.message).toContain( - `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, - ) + expect(parsed.state).toBe('ok') + expect(parsed.data?.capabilities).toMatchObject({ + network_access: true, + intercepts: ['beforecheckout'], + }) }) - test('rejects a non-boolean block_progress', () => { + test('rejects an unknown intercept event', () => { const parsed = parse({ - intercepts: [{event: 'beforecheckout', block_progress: 'yes'}], + capabilities: {intercepts: ['notarealevent']}, }) expect(parsed.state).toBe('error') + expect(parsed.errors?.[0]?.message).toContain( + `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, + ) }) test('rejects duplicate intercept events', () => { const parsed = parse({ - intercepts: [ - {event: 'beforecheckout', block_progress: true}, - {event: 'beforecheckout', block_progress: false}, - ], + capabilities: {intercepts: ['beforecheckout', 'beforecheckout']}, }) expect(parsed.state).toBe('error') @@ -116,12 +111,12 @@ describe('pos_ui_extension', async () => { }) describe('deployConfig()', () => { - test('includes intercepts in the deploy config', async () => { + test('includes capabilities.intercepts in the deploy config', async () => { await inTemporaryDirectory(async (tmpDir) => { vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) const extension = await getTestPosUIExtension(tmpDir, { - intercepts: [{event: 'beforecheckout', block_progress: true}], + capabilities: {intercepts: ['beforecheckout']}, }) const deployConfig = await extension.deployConfig({ @@ -132,12 +127,12 @@ describe('pos_ui_extension', async () => { expect(deployConfig).toMatchObject({ name: 'My POS Extension', renderer_version: '1.2.3', - intercepts: [{event: 'beforecheckout', block_progress: true}], + capabilities: {intercepts: ['beforecheckout']}, }) }) }) - test('omits intercepts when none are declared', async () => { + test('omits intercepts when no capabilities are declared', async () => { await inTemporaryDirectory(async (tmpDir) => { vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) @@ -148,7 +143,7 @@ describe('pos_ui_extension', async () => { appConfiguration: placeholderAppConfiguration, }) - expect(deployConfig?.intercepts).toBeUndefined() + expect((deployConfig as {capabilities?: {intercepts?: string[]}})?.capabilities?.intercepts).toBeUndefined() }) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index bdb9c7c30e6..cb4fb489fe4 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -1,45 +1,50 @@ import {getDependencyVersion} from '../../app/app.js' import {createExtensionSpecification} from '../specification.js' -import {BaseSchema} from '../schemas.js' +import {BaseSchema, CapabilitiesSchema} from '../schemas.js' import {ExtensionInstance} from '../extension-instance.js' import {BugError} from '@shopify/cli-kit/node/error' import {zod} from '@shopify/cli-kit/node/schema' const dependency = '@shopify/retail-ui-extensions' -// The host-mediated intercept events a POS UI extension can participate in. -// Surface-augmented and expected to grow — add new events here as POS exposes -// more interceptable workflows. See `shopify.intercept()` in ui-api-design. +// The host-mediated intercept events a POS UI extension may block. Surface-augmented +// and expected to grow — add new events here as POS exposes more interceptable +// workflows. See `shopify.intercept()` in ui-api-design. export const POS_INTERCEPT_EVENTS = ['beforecheckout', 'beforepayment'] as const -// A single intercept declaration: which event the extension participates in and -// whether it intends to block progress on that event. This is the event-scoped -// POS analogue of checkout's single `capabilities.block_progress` boolean. -const InterceptSchema = zod.object({ - event: zod.enum(POS_INTERCEPT_EVENTS, { - errorMap: () => ({ - message: `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, +// POS-specific capabilities. Extends the shared capabilities (block_progress, etc.) +// with an `intercepts` array listing the events this extension may block. Membership +// in the array is the event-scoped POS analogue of checkout's single `block_progress` +// capability boolean. Do not fold `intercepts` into the shared CapabilitiesSchema — +// it is POS-only. +const PosCapabilitiesSchema = CapabilitiesSchema.extend({ + intercepts: zod + .array( + zod.enum(POS_INTERCEPT_EVENTS, { + errorMap: () => ({ + message: `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, + }), + }), + ) + .optional() + .superRefine((events, ctx) => { + if (!events) return + const duplicates = [...new Set(events.filter((event, index) => events.indexOf(event) !== index))] + if (duplicates.length > 0) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Duplicate intercept events found: ${duplicates.join( + ', ', + )}. Each intercept event may only be declared once.`, + }) + } }), - }), - block_progress: zod.boolean().optional().default(false), }) type PosUIConfigType = zod.infer const PosUISchema = BaseSchema.extend({ name: zod.string(), - intercepts: zod.array(InterceptSchema).optional(), -}).superRefine((config, ctx) => { - const events = config.intercepts?.map((intercept) => intercept.event) ?? [] - const duplicates = [...new Set(events.filter((event, index) => events.indexOf(event) !== index))] - if (duplicates.length > 0) { - ctx.addIssue({ - code: zod.ZodIssueCode.custom, - message: `Duplicate intercept events found: ${duplicates.join( - ', ', - )}. Each intercept event may only be declared once.`, - path: ['intercepts'], - }) - } + capabilities: PosCapabilitiesSchema.optional(), }) const posUISpec = createExtensionSpecification({ @@ -61,7 +66,7 @@ const posUISpec = createExtensionSpecification({ name: config.name, description: config.description, renderer_version: result?.version, - intercepts: config.intercepts, + capabilities: config.capabilities, } }, }) From 2434f58ba75383b4f53fa09881b476d6db6f5f51 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 10:54:14 -0700 Subject: [PATCH 3/7] Remove POS UI extension intercept tests (prototype) Drop the newly-added pos_ui_extension.test.ts. This is a prototype and the tests should not gate it. All source/schema changes are kept. Note: with the test gone, knip flags POS_INTERCEPT_EVENTS as an unused export (the test was its only external consumer). The const is still used internally by the schema; left as-is intentionally. Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../specifications/pos_ui_extension.test.ts | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts deleted file mode 100644 index 333a3abecf2..00000000000 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {POS_INTERCEPT_EVENTS} from './pos_ui_extension.js' -import * as appModule from '../../app/app.js' -import {ExtensionInstance} from '../extension-instance.js' -import {loadLocalExtensionsSpecifications} from '../load-specifications.js' -import {placeholderAppConfiguration} from '../../app/app.test-data.js' -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -import {describe, expect, test, vi} from 'vitest' - -describe('pos_ui_extension', async () => { - const allSpecs = await loadLocalExtensionsSpecifications() - const specification = allSpecs.find((spec) => spec.identifier === 'pos_ui_extension')! - - interface ParsedPosUIConfig { - state: 'ok' | 'error' - data?: {capabilities?: {intercepts?: string[]}} - errors?: {message: string}[] - } - - function parse(configuration: Record): ParsedPosUIConfig { - return specification.parseConfigurationObject({ - type: 'pos_ui_extension', - name: 'My POS Extension', - ...configuration, - }) as ParsedPosUIConfig - } - - async function getTestPosUIExtension(directory: string, configuration: Record = {}) { - const configurationPath = joinPath(directory, 'shopify.extension.toml') - const parsed = specification.parseConfigurationObject({ - type: 'pos_ui_extension', - name: 'My POS Extension', - ...configuration, - }) - if (parsed.state !== 'ok') { - throw new Error(`Couldn't parse configuration: ${JSON.stringify(parsed.errors)}`) - } - - return new ExtensionInstance({ - configuration: parsed.data, - directory, - specification, - configurationPath, - entryPath: '', - }) - } - - test('has the correct identifier', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const extension = await getTestPosUIExtension(tmpDir) - expect(extension.specification.identifier).toBe('pos_ui_extension') - }) - }) - - describe('intercepts capability', () => { - test('parses when no capabilities are declared', () => { - const parsed = parse({}) - expect(parsed.state).toBe('ok') - expect(parsed.data?.capabilities?.intercepts).toBeUndefined() - }) - - test('parses a valid intercepts array', () => { - const parsed = parse({ - capabilities: {intercepts: ['beforecheckout', 'beforepayment']}, - }) - - expect(parsed.state).toBe('ok') - expect(parsed.data?.capabilities?.intercepts).toEqual(['beforecheckout', 'beforepayment']) - }) - - test('parses an empty intercepts array', () => { - const parsed = parse({ - capabilities: {intercepts: []}, - }) - - expect(parsed.state).toBe('ok') - expect(parsed.data?.capabilities?.intercepts).toEqual([]) - }) - - test('coexists with shared capabilities without overriding them', () => { - const parsed = parse({ - capabilities: {network_access: true, intercepts: ['beforecheckout']}, - }) - - expect(parsed.state).toBe('ok') - expect(parsed.data?.capabilities).toMatchObject({ - network_access: true, - intercepts: ['beforecheckout'], - }) - }) - - test('rejects an unknown intercept event', () => { - const parsed = parse({ - capabilities: {intercepts: ['notarealevent']}, - }) - - expect(parsed.state).toBe('error') - expect(parsed.errors?.[0]?.message).toContain( - `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, - ) - }) - - test('rejects duplicate intercept events', () => { - const parsed = parse({ - capabilities: {intercepts: ['beforecheckout', 'beforecheckout']}, - }) - - expect(parsed.state).toBe('error') - expect(parsed.errors?.[0]?.message).toContain('Duplicate intercept events found: beforecheckout') - }) - }) - - describe('deployConfig()', () => { - test('includes capabilities.intercepts in the deploy config', async () => { - await inTemporaryDirectory(async (tmpDir) => { - vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) - - const extension = await getTestPosUIExtension(tmpDir, { - capabilities: {intercepts: ['beforecheckout']}, - }) - - const deployConfig = await extension.deployConfig({ - apiKey: 'apiKey', - appConfiguration: placeholderAppConfiguration, - }) - - expect(deployConfig).toMatchObject({ - name: 'My POS Extension', - renderer_version: '1.2.3', - capabilities: {intercepts: ['beforecheckout']}, - }) - }) - }) - - test('omits intercepts when no capabilities are declared', async () => { - await inTemporaryDirectory(async (tmpDir) => { - vi.spyOn(appModule, 'getDependencyVersion').mockResolvedValue({name: 'name', version: '1.2.3'}) - - const extension = await getTestPosUIExtension(tmpDir) - - const deployConfig = await extension.deployConfig({ - apiKey: 'apiKey', - appConfiguration: placeholderAppConfiguration, - }) - - expect((deployConfig as {capabilities?: {intercepts?: string[]}})?.capabilities?.intercepts).toBeUndefined() - }) - }) - }) -}) From 4e82bb2963ad56e0ba1cc77afb800cd410bb2fb7 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 10:56:35 -0700 Subject: [PATCH 4/7] Drop unused export on POS_INTERCEPT_EVENTS The const is still used internally by the intercept schema, but has no external consumer now that the prototype tests are gone. Keep it local to satisfy knip; re-add `export` when real consumers land. Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../cli/models/extensions/specifications/pos_ui_extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index cb4fb489fe4..aa5f5c9447a 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -10,7 +10,7 @@ const dependency = '@shopify/retail-ui-extensions' // The host-mediated intercept events a POS UI extension may block. Surface-augmented // and expected to grow — add new events here as POS exposes more interceptable // workflows. See `shopify.intercept()` in ui-api-design. -export const POS_INTERCEPT_EVENTS = ['beforecheckout', 'beforepayment'] as const +const POS_INTERCEPT_EVENTS = ['beforecheckout', 'beforepayment'] as const // POS-specific capabilities. Extends the shared capabilities (block_progress, etc.) // with an `intercepts` array listing the events this extension may block. Membership From 62d449497e1a3dbc9525800e803eaa740157aba0 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 11:25:36 -0700 Subject: [PATCH 5/7] Make POS intercepts free-form strings, drop hardcoded enum Intercept events are functionally equivalent to extension targets, which are free-form strings validated server-side (the CLI never hardcodes the valid target list). Match that precedent so the intercept event set stays forward-compatible: new events can ship without a CLI release and won't be rejected by older CLI installs. - intercepts: array(enum(POS_INTERCEPT_EVENTS)) -> array(string) with a light lowercase-identifier format check (structural only, not a value allowlist). Server remains the source of truth for valid event names. - Keep the duplicate-rejection superRefine (structural, forward-compatible). - Delete the POS_INTERCEPT_EVENTS const entirely. Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../specifications/pos_ui_extension.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index aa5f5c9447a..caa62e5ef7e 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -7,23 +7,23 @@ import {zod} from '@shopify/cli-kit/node/schema' const dependency = '@shopify/retail-ui-extensions' -// The host-mediated intercept events a POS UI extension may block. Surface-augmented -// and expected to grow — add new events here as POS exposes more interceptable -// workflows. See `shopify.intercept()` in ui-api-design. -const POS_INTERCEPT_EVENTS = ['beforecheckout', 'beforepayment'] as const - // POS-specific capabilities. Extends the shared capabilities (block_progress, etc.) -// with an `intercepts` array listing the events this extension may block. Membership -// in the array is the event-scoped POS analogue of checkout's single `block_progress` -// capability boolean. Do not fold `intercepts` into the shared CapabilitiesSchema — -// it is POS-only. +// with an `intercepts` array listing the host-mediated events this extension may +// block. Membership in the array is the event-scoped POS analogue of checkout's +// single `block_progress` capability boolean. Do not fold `intercepts` into the +// shared CapabilitiesSchema — it is POS-only. +// +// Event names are intentionally free-form strings (not a hardcoded enum): they are +// functionally equivalent to extension targets, and the server is the source of +// truth for the valid set. This keeps the event set forward-compatible — new +// intercept events can ship without a CLI release and won't be rejected by older +// CLI installs. Only light structural validation happens here (non-empty lowercase +// identifier + uniqueness); the backend validates the actual event names at deploy. const PosCapabilitiesSchema = CapabilitiesSchema.extend({ intercepts: zod .array( - zod.enum(POS_INTERCEPT_EVENTS, { - errorMap: () => ({ - message: `Intercept event must be one of: ${POS_INTERCEPT_EVENTS.join(', ')}`, - }), + zod.string().regex(/^[a-z][a-z0-9]*$/, { + message: 'Intercept event must be a lowercase alphanumeric identifier', }), ) .optional() From e6216ed37fca299296f479298bc2ce68f8f39ea6 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Fri, 10 Jul 2026 11:28:41 -0700 Subject: [PATCH 6/7] Tighten intercept event format to lowercase letters only Per design: intercept event names are lowercase a-z only, no digits. Change the format check from /^[a-z][a-z0-9]*$/ to /^[a-z]+$/. Everything else (array(string), dedupe refine) unchanged. Assisted-By: devx/a246d980-dba0-4a06-b4d4-27c89d8ca9bf --- .../models/extensions/specifications/pos_ui_extension.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index caa62e5ef7e..75da3471ab6 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -17,13 +17,13 @@ const dependency = '@shopify/retail-ui-extensions' // functionally equivalent to extension targets, and the server is the source of // truth for the valid set. This keeps the event set forward-compatible — new // intercept events can ship without a CLI release and won't be rejected by older -// CLI installs. Only light structural validation happens here (non-empty lowercase -// identifier + uniqueness); the backend validates the actual event names at deploy. +// CLI installs. Only light structural validation happens here (lowercase letters +// a-z + uniqueness); the backend validates the actual event names at deploy. const PosCapabilitiesSchema = CapabilitiesSchema.extend({ intercepts: zod .array( - zod.string().regex(/^[a-z][a-z0-9]*$/, { - message: 'Intercept event must be a lowercase alphanumeric identifier', + zod.string().regex(/^[a-z]+$/, { + message: 'Intercept event must contain only lowercase letters (a-z)', }), ) .optional() From 577aa6344e80704b127583133d2a65ffea0d4c32 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Mon, 13 Jul 2026 16:32:51 -0700 Subject: [PATCH 7/7] POS UI extension: target-agnostic intercepts capability declaration Structural validation only; Core is the sole authority on which targets support intercepts. Future-proof declaration shape on capabilities.intercepts. --- .../app/src/cli/models/extensions/schemas.ts | 21 +++++++++- .../specifications/pos_ui_extension.ts | 42 +------------------ 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index fc65f834c42..48a23170001 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -20,7 +20,7 @@ const IframeCapabilitySchema = zod.object({ sources: zod.array(zod.string()).optional(), }) -export const CapabilitiesSchema = zod.object({ +const CapabilitiesSchema = zod.object({ network_access: zod.boolean().optional(), block_progress: zod.boolean().optional(), api_access: zod.boolean().optional(), @@ -37,8 +37,27 @@ export const ExtensionsArraySchema = zod.object({ extensions: zod.array(zod.any()).optional(), }) +const InterceptEventsSchema = zod + .array( + zod.string().regex(/^[a-z]+$/, { + message: 'Intercept event must contain only lowercase letters (a-z)', + }), + ) + .superRefine((events, context) => { + const duplicates = [...new Set(events.filter((event, index) => events.indexOf(event) !== index))] + if (duplicates.length > 0) { + context.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Duplicate intercept events found: ${duplicates.join( + ', ', + )}. Each intercept event may only be declared once.`, + }) + } + }) + const TargetCapabilitiesSchema = zod.object({ allow_direct_linking: zod.boolean().optional(), + intercepts: InterceptEventsSchema.optional(), }) const ShouldRenderSchema = zod.object({ diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index 75da3471ab6..3835d873ec7 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -1,51 +1,14 @@ import {getDependencyVersion} from '../../app/app.js' import {createExtensionSpecification} from '../specification.js' -import {BaseSchema, CapabilitiesSchema} from '../schemas.js' +import {BaseSchema} from '../schemas.js' import {ExtensionInstance} from '../extension-instance.js' import {BugError} from '@shopify/cli-kit/node/error' import {zod} from '@shopify/cli-kit/node/schema' const dependency = '@shopify/retail-ui-extensions' -// POS-specific capabilities. Extends the shared capabilities (block_progress, etc.) -// with an `intercepts` array listing the host-mediated events this extension may -// block. Membership in the array is the event-scoped POS analogue of checkout's -// single `block_progress` capability boolean. Do not fold `intercepts` into the -// shared CapabilitiesSchema — it is POS-only. -// -// Event names are intentionally free-form strings (not a hardcoded enum): they are -// functionally equivalent to extension targets, and the server is the source of -// truth for the valid set. This keeps the event set forward-compatible — new -// intercept events can ship without a CLI release and won't be rejected by older -// CLI installs. Only light structural validation happens here (lowercase letters -// a-z + uniqueness); the backend validates the actual event names at deploy. -const PosCapabilitiesSchema = CapabilitiesSchema.extend({ - intercepts: zod - .array( - zod.string().regex(/^[a-z]+$/, { - message: 'Intercept event must contain only lowercase letters (a-z)', - }), - ) - .optional() - .superRefine((events, ctx) => { - if (!events) return - const duplicates = [...new Set(events.filter((event, index) => events.indexOf(event) !== index))] - if (duplicates.length > 0) { - ctx.addIssue({ - code: zod.ZodIssueCode.custom, - message: `Duplicate intercept events found: ${duplicates.join( - ', ', - )}. Each intercept event may only be declared once.`, - }) - } - }), -}) - type PosUIConfigType = zod.infer -const PosUISchema = BaseSchema.extend({ - name: zod.string(), - capabilities: PosCapabilitiesSchema.optional(), -}) +const PosUISchema = BaseSchema.extend({name: zod.string()}) const posUISpec = createExtensionSpecification({ identifier: 'pos_ui_extension', @@ -66,7 +29,6 @@ const posUISpec = createExtensionSpecification({ name: config.name, description: config.description, renderer_version: result?.version, - capabilities: config.capabilities, } }, })