From d196e1e2515526c9574af7ffb4902ccce43c5420 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:44:06 +0200 Subject: [PATCH] Add read-only session inspection commands --- README.md | 19 +++ src/commands/session/list.ts | 23 ++++ src/commands/session/status.ts | 40 ++++++ src/commands/session/view.ts | 34 +++++ src/services/platform-management.ts | 51 ++++++++ test/commands/platform-management.test.ts | 146 ++++++++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 src/commands/session/list.ts create mode 100644 src/commands/session/status.ts create mode 100644 src/commands/session/view.ts diff --git a/README.md b/README.md index 559f1d5f..d41c1083 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,25 @@ installed CLI. It does not look up profile names, project IDs, credentials, or other argument values, and it does not contact the API. Add the command for your shell to its startup file if you want completion in later sessions. +## Inspect sessions + +Use an organization API key with `sessions:read` to inspect stored session +metadata: + +```shell +polymorfa session list +polymorfa session list --project PROJECT_ID --json +polymorfa session view SESSION_ID +polymorfa session status SESSION_ID --json +``` + +`session list` reads every session in the team unless you pass a project UUID. +Use a session ID from the list for `view` or `status`; these commands do not +accept `--project`. `status` reads the stored status and optional reason from +the session record. It does not probe the device or wait for a connection. +Key metadata listing and revocation are available through `key list` and +`key revoke`; the CLI does not mint or rotate organization keys. + ## QuickLinks A QuickLink is a persistent hosted link that connects one WhatsApp number to a diff --git a/src/commands/session/list.ts b/src/commands/session/list.ts new file mode 100644 index 00000000..0eb5559c --- /dev/null +++ b/src/commands/session/list.ts @@ -0,0 +1,23 @@ +import {BaseCommand} from '../../base-command.js' +import { + listManagedSessions, + renderManagedSessions, +} from '../../services/platform-management.js' + +export default class SessionList extends BaseCommand { + static override description = + 'List session metadata for the team or one project. Requires an organization API key with sessions:read.' + static override examples = [ + '<%= config.bin %> session list', + '<%= config.bin %> session list --project PROJECT_ID --json', + ] + + async run(): Promise { + const {flags} = await this.parse(SessionList) + const result = await listManagedSessions( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderManagedSessions(result.data), flags) + } +} diff --git a/src/commands/session/status.ts b/src/commands/session/status.ts new file mode 100644 index 00000000..97e308db --- /dev/null +++ b/src/commands/session/status.ts @@ -0,0 +1,40 @@ +import {Args} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../base-command.js' +import {viewManagedSession} from '../../services/platform-management.js' + +export default class SessionStatus extends BaseCommand { + static override description = + 'Read the stored session status and reason. Requires an organization API key with sessions:read.' + static override args = { + session: Args.string({description: 'Session ID.', required: true}), + } + static override examples = ['<%= config.bin %> session status SESSION_ID'] + + async run(): Promise { + const {args, flags} = await this.parse(SessionStatus) + if (flags.project !== undefined) { + throw new PolymorfaConfigurationError( + 'session status identifies a team-unique session ID; omit --project.', + 'project', + ) + } + const result = await viewManagedSession( + await this.platformClient(flags), + args.session, + ) + const status = { + sessionId: result.data.sessionId, + status: result.data.status, + ...(result.data.statusReason === undefined + ? {} + : {statusReason: result.data.statusReason}), + } + this.writeValue( + {data: status, metadata: result.metadata}, + `${status.sessionId}\t${status.status}${status.statusReason === undefined ? '' : `\t${status.statusReason}`}`, + flags, + ) + } +} diff --git a/src/commands/session/view.ts b/src/commands/session/view.ts new file mode 100644 index 00000000..2d9e6b51 --- /dev/null +++ b/src/commands/session/view.ts @@ -0,0 +1,34 @@ +import {Args} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../base-command.js' +import { + renderManagedSession, + viewManagedSession, +} from '../../services/platform-management.js' + +export default class SessionView extends BaseCommand { + static override description = + 'Read one session by its ID. Requires an organization API key with sessions:read.' + static override args = { + session: Args.string({description: 'Session ID.', required: true}), + } + static override examples = [ + '<%= config.bin %> session view SESSION_ID --json', + ] + + async run(): Promise { + const {args, flags} = await this.parse(SessionView) + if (flags.project !== undefined) { + throw new PolymorfaConfigurationError( + 'session view identifies a team-unique session ID; omit --project.', + 'project', + ) + } + const result = await viewManagedSession( + await this.platformClient(flags), + args.session, + ) + this.writeValue(result, renderManagedSession(result.data), flags) + } +} diff --git a/src/services/platform-management.ts b/src/services/platform-management.ts index 1b7409f4..2188e3a7 100644 --- a/src/services/platform-management.ts +++ b/src/services/platform-management.ts @@ -12,9 +12,11 @@ import { type SecurityIncident, type SecurityIncidentAcknowledgement, type SessionBan, + type Session, type SessionBatchRemoveResult, type SessionBatchRequest, type SessionBatchStopResult, + type PlatformSession, type UpdateQuickLinkSettingsInput, type OrganizationQuickLinkSettings, type ProjectQuickLinkSettings, @@ -100,6 +102,55 @@ export async function listProjectTokens( return result(response.data.data, response.metadata) } +export async function listManagedSessions( + client: ClientInstance<'organization'>, + projectId?: string, +): Promise> { + const response = await client.sessions.list( + projectId === undefined ? {} : {projectId}, + ) + return result(response.data.data, response.metadata) +} + +export async function viewManagedSession( + client: ClientInstance<'organization'>, + sessionId: string, +): Promise> { + const response = await client.sessions.retrieve(sessionId) + return result(response.data.data, response.metadata) +} + +export function renderManagedSessions( + sessions: readonly PlatformSession[], +): string { + if (sessions.length === 0) return 'No sessions found.' + return sessions + .map((session) => + [ + session.sessionId, + session.name, + session.status, + session.testMode ? 'test' : 'real', + session.projectId, + ].join('\t'), + ) + .join('\n') +} + +export function renderManagedSession(session: Session): string { + return [ + `ID\t${session.sessionId}`, + `Name\t${session.name}`, + `Status\t${session.status}`, + ...(session.statusReason === undefined + ? [] + : [`Status reason\t${session.statusReason}`]), + `Connection\t${session.type}`, + `Number\t${session.testMode ? 'test' : 'real'}`, + `Updated\t${session.updatedAt}`, + ].join('\n') +} + export async function stopSessions( client: ClientInstance<'organization'>, sessionIds: readonly string[], diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index 3c6e151c..735ef2f0 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -23,6 +23,152 @@ describe('Platform management commands', () => { directory = undefined }) + test('lists team sessions and filters by exact project ID', async () => { + server = await startHttpServer({ + body: { + data: [ + { + _id: 'row_1', + _creationTime: 1_723_000_000_000, + projectId: 'project_1', + sessionId: 'session_1', + name: 'Support', + phone: null, + platform: 'android', + isBusiness: true, + testMode: false, + tierOverride: null, + status: 'CONNECTED', + messageCount: 0, + lastActiveAt: null, + paidUntil: null, + }, + ], + }, + headers: {'request-id': 'req_sessions'}, + }) + + const all = await platformEnvironment(() => runCommand('session list')) + const project = await platformEnvironment(() => + runCommand('session list --project project_1 --json'), + ) + + expect(all.error).toBeUndefined() + expect(all.stdout).toContain( + 'session_1\tSupport\tCONNECTED\treal\tproject_1', + ) + expect(project.error).toBeUndefined() + expect(JSON.parse(project.stdout)).toMatchObject({ + data: [{sessionId: 'session_1', status: 'CONNECTED'}], + metadata: {requestId: 'req_sessions', status: 200}, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sessions', + 'GET /platform/sessions?projectId=project_1', + ]) + }) + + test('views a session and reports only its stored status in the status command', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + sessionId: 'session_1', + name: 'Support', + tenantId: 'org_1', + type: 'linked_device', + testMode: false, + status: 'DISCONNECTED', + statusReason: 'PHONE_OFFLINE', + configuration: {revisions: {team: 1, project: 2, session: 3}}, + createdAt: '2026-09-26T00:00:00.000Z', + updatedAt: '2026-09-27T00:00:00.000Z', + }, + }, + }) + + const view = await platformEnvironment(() => + runCommand('session view session_1 --json'), + ) + const status = await platformEnvironment(() => + runCommand('session status session_1 --json'), + ) + const human = await platformEnvironment(() => + runCommand('session status session_1'), + ) + + expect(view.error).toBeUndefined() + expect(JSON.parse(view.stdout)).toMatchObject({ + data: {sessionId: 'session_1', status: 'DISCONNECTED'}, + }) + expect(status.error).toBeUndefined() + expect(JSON.parse(status.stdout)).toMatchObject({ + data: { + sessionId: 'session_1', + status: 'DISCONNECTED', + statusReason: 'PHONE_OFFLINE', + }, + }) + const statusJson: unknown = JSON.parse(status.stdout) + expect(statusJson).not.toHaveProperty('data.configuration') + expect(human.stdout).toBe('session_1\tDISCONNECTED\tPHONE_OFFLINE\n') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sessions/session_1', + 'GET /platform/sessions/session_1', + 'GET /platform/sessions/session_1', + ]) + }) + + test('rejects a project filter on session view and status before transport', async () => { + server = await startHttpServer({body: {success: true, data: {}}}) + + for (const command of ['view', 'status']) { + const result = await platformEnvironment(() => + runCommand(`session ${command} session_1 --project project_1`), + ) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('omit --project') + } + expect(server.requests).toHaveLength(0) + }) + + test('rejects a project token for team session reads before transport', async () => { + server = await startHttpServer({body: {data: []}}) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand('session list --json'), + ) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain( + 'Platform API requests require POLYMORFA_API_KEY', + ) + expect(server.requests).toHaveLength(0) + }) + + test('does not turn a missing session into a disconnected status', async () => { + server = await startHttpServer({ + status: 404, + body: { + error: { + type: 'not_found_error', + code: 'not_found', + message: 'session not found', + }, + }, + }) + + const result = await platformEnvironment(() => + runCommand('session status missing'), + ) + expect(result.error).toBeDefined() + expect(result.stdout).not.toContain('DISCONNECTED') + expect(server.requests).toHaveLength(1) + }) + test('reads and updates session configuration using Platform authority', async () => { server = await startHttpServer({ body: {success: true, data: {name: 'support', configuration: {}}},