Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/commands/session/list.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {flags} = await this.parse(SessionList)
const result = await listManagedSessions(
await this.platformClient(flags),
flags.project,
)
this.writeValue(result, renderManagedSessions(result.data), flags)
}
}
40 changes: 40 additions & 0 deletions src/commands/session/status.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
34 changes: 34 additions & 0 deletions src/commands/session/view.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
51 changes: 51 additions & 0 deletions src/services/platform-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PlatformResult<readonly PlatformSession[]>> {
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<PlatformResult<Session>> {
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[],
Expand Down
146 changes: 146 additions & 0 deletions test/commands/platform-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}}},
Expand Down