diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts new file mode 100644 index 00000000000..5c1b6bea295 --- /dev/null +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -0,0 +1,63 @@ +import {queryAppLogs} from '../../../services/app-logs/query.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class Query extends BaseCommand { + static hidden = true + static summary = 'Prototype only: query app log summaries through the local App Management API.' + + static flags = { + ...globalFlags, + 'organization-id': Flags.string({ + required: true, + env: 'SHOPIFY_FLAG_ORGANIZATION_ID', + description: 'Local Business Platform organization ID.', + }), + 'client-id': Flags.string({required: true, env: 'SHOPIFY_FLAG_CLIENT_ID', description: 'App API key.'}), + minutes: Flags.integer({ + default: 15, + min: 1, + env: 'SHOPIFY_FLAG_MINUTES', + description: 'Query the last N minutes.', + }), + limit: Flags.integer({ + default: 10, + min: 1, + env: 'SHOPIFY_FLAG_LIMIT', + description: 'Maximum summary events to return.', + }), + offset: Flags.integer({ + default: 0, + min: 0, + env: 'SHOPIFY_FLAG_OFFSET', + description: 'Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.', + }), + type: Flags.string({ + multiple: true, + env: 'SHOPIFY_FLAG_TYPE', + options: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST', 'REST_REQUEST', 'FUNCTION_RUN'], + description: 'Restrict results to these event types.', + }), + demo: Flags.boolean({ + default: false, + env: 'SHOPIFY_FLAG_DEMO', + description: 'Use the loopback demo with seeded auth, not Identity login.', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(Query) + const result = await queryAppLogs({ + organizationId: flags['organization-id'], + clientId: flags['client-id'], + minutes: flags.minutes, + limit: flags.limit, + offset: flags.offset, + types: flags.type, + demo: flags.demo, + }) + outputResult(JSON.stringify(result, null, 2)) + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index a6289bba107..bec72cb4dae 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -12,6 +12,7 @@ import DoctorSubmit from './commands/app/doctor/submit.js' import Doctor from './commands/app/doctor.js' import Logs from './commands/app/logs.js' import Sources from './commands/app/app-logs/sources.js' +import QueryLogs from './commands/app/app-logs/query.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' import BulkExecute from './commands/app/bulk/execute.js' @@ -61,6 +62,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:doctor': Doctor, 'app:logs': Logs, 'app:logs:sources': Sources, + 'app:logs:query': QueryLogs, 'app:import-custom-data-definitions': ImportCustomDataDefinitions, 'app:import-extensions': ImportExtensions, 'app:info': AppInfo, diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts new file mode 100644 index 00000000000..489f075d066 --- /dev/null +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -0,0 +1,122 @@ +import {queryAppLogs} from './query.js' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {fetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {Response} from 'node-fetch' + +vi.mock('@shopify/cli-kit/node/context/fqdn') +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/session') + +const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false} + +beforeEach(() => { + vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + vi.mocked(appManagementFqdn).mockResolvedValue('app.shop.dev') + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'atkn_local-identity', + userId: 'local-user', + businessPlatformToken: 'unused', + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('refuses production services before authenticating or sending a request', async () => { + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'production') + await expect(queryAppLogs(options)).rejects.toThrow('Prototype only') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('refuses an unexpected host before obtaining a token', async () => { + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + await expect(queryAppLogs(options)).rejects.toThrow('only call app.shop.dev') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() +}) + +test('sends a bounded app query using the normal CLI authentication helper', async () => { + const result = {app_key: 'test-app', events: [{'payload.type': 'WEBHOOK_DELIVERY'}], exhaustive: false} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), {status: 200})) + await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) + expect(fetch).toHaveBeenCalledWith( + 'https://app.shop.dev/app_management/unstable/organizations/1/app_logs/query', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}), + body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'), + }), + ) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.api_key).toBe('test-app') + expect(input.limit).toBe(3) + expect(input.offset).toBe(0) + expect(Date.parse(input.end_time) - Date.parse(input.start_time)).toBe(15 * 60 * 1000) +}) + +test('uses the temporary token only for the fixed loopback demo, without logging into Identity', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'token') + await writeFile(path, 'atkn_demo-only') + vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) + vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) + expect(fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:4387/app_management/unstable/organizations/1/app_logs/query', + expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), + ) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + }) +}) + +test('surfaces API errors instead of turning them into an empty result', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('private upstream details', {status: 502})) + await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502') +}) + +test('forwards a 10000 event limit and 1000000 offset independently', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []}) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.limit).toBe(10_000) + expect(input.offset).toBe(1_000_000) +}) + +test('leaves upper policy limits to the server', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"error":"Query limits exceeded"}', {status: 400})) + await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow('HTTP 400') + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.limit).toBe(10_001) + expect(input.offset).toBe(1_000_001) +}) + +test('reads valid responses larger than one MiB without a client byte cap', async () => { + const result = {events: [{'payload.target': 'x'.repeat(2 * 1024 * 1024)}]} + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const responseOptions = {status: 200, size: init?.size} + return new Response(JSON.stringify(result), responseOptions) + }) + + await expect(queryAppLogs(options)).resolves.toEqual(result) + expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined() +}) + +test('rejects a path-injection organization ID', async () => { + await expect(queryAppLogs({...options, organizationId: '../2'})).rejects.toThrow('numeric organization ID') + expect(fetch).not.toHaveBeenCalled() +}) + +test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])( + 'rejects malformed numeric options %j before making a request', + async (invalidOptions) => { + await expect(queryAppLogs({...options, ...invalidOptions})).rejects.toThrow('positive integers') + expect(fetch).not.toHaveBeenCalled() + }, +) diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts new file mode 100644 index 00000000000..e7556b68d0d --- /dev/null +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -0,0 +1,74 @@ +import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {readFile} from '@shopify/cli-kit/node/fs' +import {fetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' + +interface QueryOptions { + organizationId: string + clientId: string + minutes: number + limit: number + offset: number + types?: string[] + demo: boolean +} + +export async function queryAppLogs(options: QueryOptions): Promise { + if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') { + throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.') + } + if (!/^\d+$/.test(options.organizationId) || !options.clientId) { + throw new AbortError('Provide a numeric organization ID and a nonempty app client ID.') + } + if ( + !Number.isInteger(options.minutes) || + options.minutes < 1 || + !Number.isInteger(options.limit) || + options.limit < 1 || + !Number.isInteger(options.offset) || + options.offset < 0 + ) { + throw new AbortError('Use positive integers for minutes and limit, and a nonnegative integer for offset.') + } + + const {origin, token} = await queryConnection(options.demo) + const end = new Date() + const response = await fetch( + `${origin}/app_management/unstable/organizations/${options.organizationId}/app_logs/query`, + { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15000), + headers: appManagementHeaders(token), + body: JSON.stringify({ + api_key: options.clientId, + start_time: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), + end_time: end.toISOString(), + limit: options.limit, + offset: options.offset, + ...(options.types ? {types: options.types} : {}), + }), + }, + ) + if (!response.ok) { + throw new AbortError(`Local log query failed (HTTP ${response.status}). Check the local API server output.`) + } + return response.json() +} + +async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { + if (demo) { + const path = process.env.APP_LOG_QUERY_DEMO_TOKEN_FILE + if (!path) throw new AbortError('Set APP_LOG_QUERY_DEMO_TOKEN_FILE to the file printed by the local demo server.') + const token = (await readFile(path)).trim() + if (!token.startsWith('atkn_') || token.length > 4096) throw new AbortError('Invalid demo token file.') + return {origin: 'http://127.0.0.1:4387', token} + } + + const host = await appManagementFqdn() + if (host !== 'app.shop.dev') throw new AbortError('This prototype can only call app.shop.dev.') + const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() + return {origin: `https://${host}`, token: appManagementToken} +} diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js index 537d650a5be..0f4854ab846 100644 --- a/packages/cli/bin/bundle.js +++ b/packages/cli/bin/bundle.js @@ -55,6 +55,7 @@ const hookEntryPoints = glob.sync('./src/hooks/*.ts', { const manifest = JSON.parse(readFileSync(joinPath(process.cwd(), 'oclif.manifest.json'), 'utf8')) const commandEntryPointOverrides = { 'app:logs:sources': 'cli/commands/app/app-logs/sources', + 'app:logs:query': 'cli/commands/app/app-logs/query', 'demo:watcher': 'cli/commands/app/demo/watcher', 'kitchen-sink': 'cli/commands/kitchen-sink/index', 'doctor-release': 'cli/commands/doctor-release/doctor-release', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e643cbb6194..ab1800731b7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3313,6 +3313,108 @@ "strict": true, "summary": "Stream detailed logs for your Shopify app." }, + "app:logs:query": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "enableJsonFlag": false, + "flags": { + "client-id": { + "description": "App API key.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "client-id", + "required": true, + "type": "option" + }, + "demo": { + "allowNo": false, + "description": "Use the loopback demo with seeded auth, not Identity login.", + "env": "SHOPIFY_FLAG_DEMO", + "name": "demo", + "type": "boolean" + }, + "limit": { + "default": 10, + "description": "Maximum summary events to return.", + "env": "SHOPIFY_FLAG_LIMIT", + "hasDynamicHelp": false, + "multiple": false, + "name": "limit", + "type": "option" + }, + "minutes": { + "default": 15, + "description": "Query the last N minutes.", + "env": "SHOPIFY_FLAG_MINUTES", + "hasDynamicHelp": false, + "multiple": false, + "name": "minutes", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "offset": { + "default": 0, + "description": "Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.", + "env": "SHOPIFY_FLAG_OFFSET", + "hasDynamicHelp": false, + "multiple": false, + "name": "offset", + "type": "option" + }, + "organization-id": { + "description": "Local Business Platform organization ID.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "required": true, + "type": "option" + }, + "type": { + "description": "Restrict results to these event types.", + "env": "SHOPIFY_FLAG_TYPE", + "hasDynamicHelp": false, + "multiple": true, + "name": "type", + "options": [ + "WEBHOOK_DELIVERY", + "GRAPHQL_REQUEST", + "REST_REQUEST", + "FUNCTION_RUN" + ], + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:logs:query", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Prototype only: query app log summaries through the local App Management API." + }, "app:logs:sources": { "aliases": [ ], diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 2f8180831e6..e60bd7293d8 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -62,6 +62,7 @@ function resolvePackageDir(packageName: string): string { const entryPointOverrides: Record = { 'app:logs:sources': 'dist/cli/commands/app/app-logs/sources.js', + 'app:logs:query': 'dist/cli/commands/app/app-logs/query.js', 'demo:watcher': 'dist/cli/commands/app/demo/watcher.js', 'kitchen-sink': 'dist/cli/commands/kitchen-sink/index.js', 'doctor-release': 'dist/cli/commands/doctor-release/doctor-release.js',