From 20483d33ef490d856f8b11c5bd5be356920464c4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 11:14:32 +0300 Subject: [PATCH 1/7] feat: create sessions through configured QuickLinks --- README.md | 36 ++++++++++ package-lock.json | 6 +- package.json | 14 +++- src/commands/quicklink/create.ts | 25 +++++++ src/commands/session/configuration/get.ts | 22 ++++++ src/commands/session/configuration/update.ts | 53 +++++++++++++++ .../testing/history-fixture/create.ts | 49 +++++++++++++ src/services/quicklinks.ts | 35 ++++++++++ test/commands/quicklinks.test.ts | 68 +++++++++++++++++++ test/services/quicklinks.test.ts | 41 +++++++++++ 10 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/commands/quicklink/create.ts create mode 100644 src/commands/session/configuration/get.ts create mode 100644 src/commands/session/configuration/update.ts create mode 100644 src/commands/testing/history-fixture/create.ts create mode 100644 src/services/quicklinks.ts create mode 100644 test/commands/quicklinks.test.ts create mode 100644 test/services/quicklinks.test.ts diff --git a/README.md b/README.md index 6cfe54d3..d5208ff3 100644 --- a/README.md +++ b/README.md @@ -648,3 +648,39 @@ npm run verify The verification gate checks formatting, lint, strict TypeScript, tests, production build, npm package contents, and the production dependency audit on Node.js 20 and 22 in hosted CI. + +### Guided session creation and configuration + +Create real or simulated sessions with `polymorfa quicklink create --project --input request.json`. +The request accepts `configuration` for connection, phone, history and test simulation. +Page copy, appearance, legal links and callbacks belong in saved widget settings. +Free-tier real accounts must pair inside the authenticated Console. + +Inspect configuration with `polymorfa session configuration get `. +Update it with `polymorfa session configuration update --input update.json`: + +```json +{ + "revision": 2, + "configuration": { + "set": {"historySync": {"mode": "deliver"}}, + "reset": ["historySync.requestFull"] + } +} +``` + +Use the revision from the preceding read. Reset removes an override so future defaults +continue to apply. A saved update can remain pending until the runtime acknowledges it. +The CLI does not offer direct session creation; QuickLinks preserve onboarding authority. + +Upload simulated history separately from configuration, then use the returned +fixture ID in `configuration.testing.configuration.historyFixtureId`: + +```sh +polymorfa testing history-fixture create --project PROJECT_ID --input history.json +``` + +The file contains `messages`, with up to 100 entries specifying `id`, +`senderPhone`, `text`, `timestamp` (Unix seconds), and `fromMe`. Senders must be +existing simulated numbers in the selected project. This command does not enable +hosted message storage. diff --git a/package-lock.json b/package-lock.json index 5981cc37..9804680f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#da65b22a8c4bf79aa378cf1848d2b7cc765ba326" + "@polymorfa/sdk": "github:polymorfa/sdks#a98d0a6afa10dd3dd09133e37e24448b9dd96c12" }, "bin": { "polymorfa": "bin/run.js" @@ -972,8 +972,8 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#da65b22a8c4bf79aa378cf1848d2b7cc765ba326", - "integrity": "sha512-+J3yBEucdKXEdNz+idSRgvnapgjFsslgX80nhbOX4zd9tc+vm6GKZskLYw9c5D81gr6uoJzY/CaNvMOKaNzCIw==", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#a98d0a6afa10dd3dd09133e37e24448b9dd96c12", + "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", "workspaces": [ "packages/calls", "packages/browser", diff --git a/package.json b/package.json index 27fcc977..baf9dbfe 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#da65b22a8c4bf79aa378cf1848d2b7cc765ba326" + "@polymorfa/sdk": "github:polymorfa/sdks#a98d0a6afa10dd3dd09133e37e24448b9dd96c12" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -195,6 +195,18 @@ }, "widget": { "description": "Inspect and update saved Connect widget settings." + }, + "quicklink": { + "description": "Create persistent onboarding invitations." + }, + "session:configuration": { + "description": "Inspect and update inherited session configuration." + }, + "testing": { + "description": "Manage simulated session fixtures." + }, + "testing:history-fixture": { + "description": "Upload project-scoped test history." } } }, diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts new file mode 100644 index 00000000..b817de45 --- /dev/null +++ b/src/commands/quicklink/create.ts @@ -0,0 +1,25 @@ +import {Flags} from '@oclif/core' +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {createQuickLink} from '../../services/quicklinks.js' + +export default class QuickLinkCreate extends BaseCommand { + static override description = + 'Create a persistent real or simulated onboarding QuickLink.' + static override flags = { + input: Flags.string({ + description: + 'JSON request file or - for stdin. Use configuration for connection, phone, history and simulation.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(QuickLinkCreate) + const result = await createQuickLink( + await this.messagingClient(flags, {serverApiKeyOnly: 'QuickLink'}), + await readJsonInput(flags.input), + flags.project, + ) + this.writeValue(result, result.data.url, flags) + } +} diff --git a/src/commands/session/configuration/get.ts b/src/commands/session/configuration/get.ts new file mode 100644 index 00000000..363d1a8f --- /dev/null +++ b/src/commands/session/configuration/get.ts @@ -0,0 +1,22 @@ +import {Args} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' + +export default class SessionConfigurationGet extends BaseCommand { + static override description = + 'Read effective session configuration, inheritance sources and revisions.' + static override args = { + session: Args.string({description: 'Session ID.', required: true}), + } + async run(): Promise { + const {args, flags} = await this.parse(SessionConfigurationGet) + const response = await ( + await this.messagingClient(flags, {serverApiKeyOnly: 'Configuration'}) + ).sessions.retrieve(args.session) + const data = response.data.data + this.writeValue( + {data, metadata: response.metadata}, + JSON.stringify(data, null, 2), + flags, + ) + } +} diff --git a/src/commands/session/configuration/update.ts b/src/commands/session/configuration/update.ts new file mode 100644 index 00000000..9b2af755 --- /dev/null +++ b/src/commands/session/configuration/update.ts @@ -0,0 +1,53 @@ +import {Args, Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type UpdateSessionRequest, +} from '@polymorfa/sdk' +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' + +export default class SessionConfigurationUpdate extends BaseCommand { + static override description = + 'Set configuration overrides or reset them to inherited defaults using a revision.' + static override args = { + session: Args.string({description: 'Session ID.', required: true}), + } + static override flags = { + input: Flags.string({ + description: + 'JSON file or stdin (-), containing revision and configuration.set/reset.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(SessionConfigurationUpdate) + const input = await readJsonInput(flags.input) + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new PolymorfaConfigurationError( + 'Configuration update must be an object.', + 'input', + ) + const body = input as Record + if ( + Object.keys(body).some( + (key) => !['revision', 'configuration'].includes(key), + ) || + !Number.isSafeInteger(body.revision) || + Number(body.revision) < 0 || + !body.configuration || + typeof body.configuration !== 'object' + ) + throw new PolymorfaConfigurationError( + 'Expected a nonnegative revision and configuration.set/reset.', + 'input', + ) + const response = await ( + await this.messagingClient(flags, {serverApiKeyOnly: 'Configuration'}) + ).sessions.update(args.session, input as UpdateSessionRequest) + this.writeValue( + {data: response.data.data, metadata: response.metadata}, + 'Configuration saved. Check application status for runtime acknowledgement.', + flags, + ) + } +} diff --git a/src/commands/testing/history-fixture/create.ts b/src/commands/testing/history-fixture/create.ts new file mode 100644 index 00000000..5cce4637 --- /dev/null +++ b/src/commands/testing/history-fixture/create.ts @@ -0,0 +1,49 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type TestingHistoryMessage, +} from '@polymorfa/sdk' +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' + +export default class TestingHistoryFixtureCreate extends BaseCommand { + static override description = + 'Upload a project-scoped simulated history fixture for a test QuickLink.' + static override flags = { + input: Flags.string({ + description: 'JSON file or - for stdin, containing a messages array.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(TestingHistoryFixtureCreate) + if (!flags.project) + throw new PolymorfaConfigurationError( + 'Select a project with --project.', + 'project', + ) + const input = await readJsonInput(flags.input) + if ( + !input || + typeof input !== 'object' || + Array.isArray(input) || + !('messages' in input) || + !Array.isArray(input.messages) + ) + throw new PolymorfaConfigurationError( + 'Fixture input must contain a messages array.', + 'input', + ) + const response = await ( + await this.messagingClient(flags, {serverApiKeyOnly: 'Testing history'}) + ).testing.createHistoryFixture( + flags.project, + input as {messages: TestingHistoryMessage[]}, + ) + this.writeValue( + {data: response.data, metadata: response.metadata}, + response.data.fixtureId, + flags, + ) + } +} diff --git a/src/services/quicklinks.ts b/src/services/quicklinks.ts new file mode 100644 index 00000000..98f56005 --- /dev/null +++ b/src/services/quicklinks.ts @@ -0,0 +1,35 @@ +import {PolymorfaConfigurationError, type MessagingClient} from '@polymorfa/sdk' + +export async function createQuickLink( + client: MessagingClient, + input: unknown, + projectId?: string, +) { + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new PolymorfaConfigurationError( + 'QuickLink input must be an object.', + 'input', + ) + const body = input as Record + const allowed = new Set([ + 'projectId', + 'customerId', + 'externalId', + 'configuration', + ]) + if (Object.keys(body).some((key) => !allowed.has(key))) + throw new PolymorfaConfigurationError( + 'QuickLinks accept configuration; presentation and callbacks belong in saved settings.', + 'input', + ) + if (projectId && body.projectId !== undefined && body.projectId !== projectId) + throw new PolymorfaConfigurationError( + 'Input projectId conflicts with --project.', + 'project', + ) + const response = await client.quickLinks.create({ + ...body, + ...(projectId ? {projectId} : {}), + }) + return {data: response.data.data, metadata: response.metadata} +} diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts new file mode 100644 index 00000000..e987cf56 --- /dev/null +++ b/test/commands/quicklinks.test.ts @@ -0,0 +1,68 @@ +import {runCommand} from '@oclif/test' +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {expect, test} from 'vitest' +import { + startHttpSequenceServer, + withEnvironment, +} from '../helpers/http-server.js' + +test('creates a QuickLink and uploads separate test history with exact project scope', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + id: 'ql_test', + url: 'https://example.test/connect/test', + session: 'reserved', + expiresAt: null, + }, + }, + }, + {status: 201, body: {fixtureId: '11111111-1111-1111-1111-111111111111'}}, + ]) + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-cli-quicklink-')) + const input = join(directory, 'quicklink.json') + const history = join(directory, 'history.json') + const configuration = { + connectionPreference: 'linked', + historySync: {consent: 'ask'}, + } + await writeFile(input, JSON.stringify({configuration})) + await writeFile(history, JSON.stringify({messages: []})) + try { + await withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const created = await runCommand( + `quicklink create --project project/1 --input ${input} --json`, + ) + expect(created.error).toBeUndefined() + expect(JSON.parse(created.stdout)).toMatchObject({ + data: {id: 'ql_test', expiresAt: null}, + }) + const uploaded = await runCommand( + `testing history-fixture create --project project/1 --input ${history} --json`, + ) + expect(uploaded.error).toBeUndefined() + }, + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /messaging/quicklinks', + 'POST /messaging/testing/project%2F1/history-fixtures', + ]) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: 'project/1', + configuration, + }) + } finally { + await server.close() + await rm(directory, {recursive: true, force: true}) + } +}) diff --git a/test/services/quicklinks.test.ts b/test/services/quicklinks.test.ts new file mode 100644 index 00000000..72c3a579 --- /dev/null +++ b/test/services/quicklinks.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it, vi} from 'vitest' +import {createQuickLink} from '../../src/services/quicklinks.js' + +describe('QuickLink creation', () => { + it('rejects per-link presentation and project replacement before transport', async () => { + const create = vi.fn() + const client = {quickLinks: {create}} + await expect( + createQuickLink(client as never, {theme: 'light'}), + ).rejects.toThrow('saved settings') + await expect( + createQuickLink(client as never, {projectId: 'other'}, 'selected'), + ).rejects.toThrow('conflicts') + expect(create).not.toHaveBeenCalled() + }) + it('forwards nested configuration with the selected project', async () => { + const create = vi.fn(() => + Promise.resolve({ + data: { + data: { + id: 'ql_test', + url: 'https://example.test/connect/test', + expiresAt: null, + }, + }, + metadata: {status: 201}, + }), + ) + const configuration = { + connectionPreference: 'linked', + historySync: {consent: 'ask'}, + } + const result = await createQuickLink( + {quickLinks: {create}} as never, + {configuration}, + 'project', + ) + expect(create).toHaveBeenCalledWith({projectId: 'project', configuration}) + expect(result.data.expiresAt).toBeNull() + }) +}) From 43f81cca68b2380a59b2ade50c185633dc791c11 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 11:22:44 +0300 Subject: [PATCH 2/7] chore: pin SDK to verified API migration revision --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9804680f..8eef61d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#a98d0a6afa10dd3dd09133e37e24448b9dd96c12" + "@polymorfa/sdk": "github:polymorfa/sdks#ff40750822975d0d67d030a3cb963d0d4e92b7b7" }, "bin": { "polymorfa": "bin/run.js" @@ -972,7 +972,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#a98d0a6afa10dd3dd09133e37e24448b9dd96c12", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#ff40750822975d0d67d030a3cb963d0d4e92b7b7", "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index baf9dbfe..9f0efb9f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#a98d0a6afa10dd3dd09133e37e24448b9dd96c12" + "@polymorfa/sdk": "github:polymorfa/sdks#ff40750822975d0d67d030a3cb963d0d4e92b7b7" }, "devDependencies": { "@eslint/js": "^9.39.5", From f9394ddbe1071391054cc86108f500047a7eb8b2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 12:26:33 +0300 Subject: [PATCH 3/7] chore(sdk): pin reviewed session configuration source --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8eef61d8..2bc7cbe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#ff40750822975d0d67d030a3cb963d0d4e92b7b7" + "@polymorfa/sdk": "github:polymorfa/sdks#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f" }, "bin": { "polymorfa": "bin/run.js" @@ -972,7 +972,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#ff40750822975d0d67d030a3cb963d0d4e92b7b7", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f", "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index 9f0efb9f..57090736 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#ff40750822975d0d67d030a3cb963d0d4e92b7b7" + "@polymorfa/sdk": "github:polymorfa/sdks#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f" }, "devDependencies": { "@eslint/js": "^9.39.5", From 9e71a8693a0322560233b424f8800fffc3fb5e68 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 12:34:21 +0300 Subject: [PATCH 4/7] chore(sdk): pin test invitation lifecycle source --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2bc7cbe9..26773937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f" + "@polymorfa/sdk": "github:polymorfa/sdks#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c" }, "bin": { "polymorfa": "bin/run.js" @@ -972,7 +972,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c", "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index 57090736..fa18e462 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#bcafa8f5dfd1fa2bffd2ebd8a6200dd6e54cb85f" + "@polymorfa/sdk": "github:polymorfa/sdks#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c" }, "devDependencies": { "@eslint/js": "^9.39.5", From 8c0b1d6264671ec92c577cabf5ea1f05e7808dc5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 12:51:50 +0300 Subject: [PATCH 5/7] chore(sdk): pin final onboarding lifecycle source --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26773937..f93d1ca8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c" + "@polymorfa/sdk": "github:polymorfa/sdks#e5f76167f2d88b29176c8c853ea6b805a8867e73" }, "bin": { "polymorfa": "bin/run.js" @@ -972,7 +972,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#e5f76167f2d88b29176c8c853ea6b805a8867e73", "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index fa18e462..3c466994 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#e1ccd2d22e355f9cb0df454fe95f1e1961c8c13c" + "@polymorfa/sdk": "github:polymorfa/sdks#e5f76167f2d88b29176c8c853ea6b805a8867e73" }, "devDependencies": { "@eslint/js": "^9.39.5", From ef13fdd7534cef5c93269bfd859d5a03f73657ed Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 13:26:21 +0300 Subject: [PATCH 6/7] fix(cli): use Platform session configuration and aligned SDK contract --- README.md | 1 + package-lock.json | 6 ++--- package.json | 2 +- src/commands/session/configuration/get.ts | 2 +- src/commands/session/configuration/update.ts | 2 +- test/commands/platform-management.test.ts | 28 ++++++++++++++++++++ test/sdk/client.test.ts | 5 +++- 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d5208ff3..b6ff38d2 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,7 @@ The request accepts `configuration` for connection, phone, history and test simu Page copy, appearance, legal links and callbacks belong in saved widget settings. Free-tier real accounts must pair inside the authenticated Console. +Session configuration uses Platform authority and requires a server API key. Inspect configuration with `polymorfa session configuration get `. Update it with `polymorfa session configuration update --input update.json`: diff --git a/package-lock.json b/package-lock.json index f93d1ca8..6046cb33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#e5f76167f2d88b29176c8c853ea6b805a8867e73" + "@polymorfa/sdk": "github:polymorfa/sdks#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e" }, "bin": { "polymorfa": "bin/run.js" @@ -972,8 +972,8 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#e5f76167f2d88b29176c8c853ea6b805a8867e73", - "integrity": "sha512-cXxbT4oJF7bgp9bUjfalWCa6zjwLEYEPm4hzMbeEdwq60fQgIptfhwkBlfH1xHrHKuoyrNnZczcx8dj5pTNo5A==", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e", + "integrity": "sha512-e2+6BRTFp5OMi7aJXnL1/LBhTfL82bOp+9+Jn8FgFKn7rtoVg15AYMBdSbM9od8mm9t1vyrcRh85D9H+MrYx9g==", "workspaces": [ "packages/calls", "packages/browser", diff --git a/package.json b/package.json index 3c466994..b34a35bb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#e5f76167f2d88b29176c8c853ea6b805a8867e73" + "@polymorfa/sdk": "github:polymorfa/sdks#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/src/commands/session/configuration/get.ts b/src/commands/session/configuration/get.ts index 363d1a8f..e83cdb1c 100644 --- a/src/commands/session/configuration/get.ts +++ b/src/commands/session/configuration/get.ts @@ -10,7 +10,7 @@ export default class SessionConfigurationGet extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(SessionConfigurationGet) const response = await ( - await this.messagingClient(flags, {serverApiKeyOnly: 'Configuration'}) + await this.platformClient(flags) ).sessions.retrieve(args.session) const data = response.data.data this.writeValue( diff --git a/src/commands/session/configuration/update.ts b/src/commands/session/configuration/update.ts index 9b2af755..34ddf6f8 100644 --- a/src/commands/session/configuration/update.ts +++ b/src/commands/session/configuration/update.ts @@ -42,7 +42,7 @@ export default class SessionConfigurationUpdate extends BaseCommand { 'input', ) const response = await ( - await this.messagingClient(flags, {serverApiKeyOnly: 'Configuration'}) + await this.platformClient(flags) ).sessions.update(args.session, input as UpdateSessionRequest) this.writeValue( {data: response.data.data, metadata: response.metadata}, diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index 893d366c..ab69c2f6 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -23,6 +23,34 @@ describe('Platform management commands', () => { directory = undefined }) + test('reads and updates session configuration using Platform authority', async () => { + server = await startHttpServer({ + body: {success: true, data: {name: 'support', configuration: {}}}, + }) + const input = await jsonInput('configuration.json', { + revision: 2, + configuration: {reset: ['historySync.mode']}, + }) + const read = await platformEnvironment(() => + runCommand('session configuration get support --json'), + ) + expect(read.error).toBeUndefined() + const update = await platformEnvironment(() => + runCommand( + `session configuration update support --input ${input} --json`, + ), + ) + expect(update.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sessions/support', + 'PUT /platform/sessions/support', + ]) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + revision: 2, + configuration: {reset: ['historySync.mode']}, + }) + }) + test('lists API-key metadata without implying that the scope mask is authoritative', async () => { server = await startHttpServer({ body: { diff --git a/test/sdk/client.test.ts b/test/sdk/client.test.ts index 410da982..df245913 100644 --- a/test/sdk/client.test.ts +++ b/test/sdk/client.test.ts @@ -79,7 +79,10 @@ describe('createRawClient', () => { expect(client.billing).toHaveProperty('usage') expect(client.billing).toHaveProperty('listTransactions') expect(client.billing).toHaveProperty('listPricing') - expect(client.billing).toHaveProperty('updateReminderSettings') + expect(client.billing).not.toHaveProperty('updateReminderSettings') + expect(client.sessions).toHaveProperty('retrieve') + expect(client.sessions).toHaveProperty('update') + expect(client.sessions).toHaveProperty('quoteTierChange') expect(client.organizations).not.toHaveProperty('update') expect(() => From ef662883892889ce55e62d8217acb39c307547b4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 15 Sep 2026 15:51:39 +0300 Subject: [PATCH 7/7] fix(configuration): validate nested session updates before requests --- README.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/commands/session/configuration/update.ts | 28 +--- src/services/session-configuration.ts | 163 +++++++++++++++++++ test/commands/platform-management.test.ts | 69 ++++++++ 6 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 src/services/session-configuration.ts diff --git a/README.md b/README.md index b6ff38d2..45e8a8fe 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,7 @@ Page copy, appearance, legal links and callbacks belong in saved widget settings Free-tier real accounts must pair inside the authenticated Console. Session configuration uses Platform authority and requires a server API key. +Updates reject unknown fields, invalid nested values, and fields listed in both `set` and `reset` before sending a request. Include at least one of `set` or `reset`; the API enforces permissions, consent, and storage eligibility. Inspect configuration with `polymorfa session configuration get `. Update it with `polymorfa session configuration update --input update.json`: diff --git a/package-lock.json b/package-lock.json index 6046cb33..1d550192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e" + "@polymorfa/sdk": "github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32" }, "bin": { "polymorfa": "bin/run.js" @@ -972,7 +972,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#08452992a32a392de5fc3d0fa3954ba6addd7f32", "integrity": "sha512-e2+6BRTFp5OMi7aJXnL1/LBhTfL82bOp+9+Jn8FgFKn7rtoVg15AYMBdSbM9od8mm9t1vyrcRh85D9H+MrYx9g==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index b34a35bb..13d74106 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#8f6b7bc1079edbda08029b4bbb5c9dba98cee45e" + "@polymorfa/sdk": "github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/src/commands/session/configuration/update.ts b/src/commands/session/configuration/update.ts index 34ddf6f8..d12a7bec 100644 --- a/src/commands/session/configuration/update.ts +++ b/src/commands/session/configuration/update.ts @@ -1,10 +1,7 @@ import {Args, Flags} from '@oclif/core' -import { - PolymorfaConfigurationError, - type UpdateSessionRequest, -} from '@polymorfa/sdk' import {readJsonInput} from '../../../api/request.js' import {BaseCommand} from '../../../base-command.js' +import {parseSessionConfigurationUpdate} from '../../../services/session-configuration.js' export default class SessionConfigurationUpdate extends BaseCommand { static override description = @@ -21,29 +18,12 @@ export default class SessionConfigurationUpdate extends BaseCommand { } async run(): Promise { const {args, flags} = await this.parse(SessionConfigurationUpdate) - const input = await readJsonInput(flags.input) - if (!input || typeof input !== 'object' || Array.isArray(input)) - throw new PolymorfaConfigurationError( - 'Configuration update must be an object.', - 'input', - ) - const body = input as Record - if ( - Object.keys(body).some( - (key) => !['revision', 'configuration'].includes(key), - ) || - !Number.isSafeInteger(body.revision) || - Number(body.revision) < 0 || - !body.configuration || - typeof body.configuration !== 'object' + const input = parseSessionConfigurationUpdate( + await readJsonInput(flags.input), ) - throw new PolymorfaConfigurationError( - 'Expected a nonnegative revision and configuration.set/reset.', - 'input', - ) const response = await ( await this.platformClient(flags) - ).sessions.update(args.session, input as UpdateSessionRequest) + ).sessions.update(args.session, input) this.writeValue( {data: response.data.data, metadata: response.metadata}, 'Configuration saved. Check application status for runtime acknowledgement.', diff --git a/src/services/session-configuration.ts b/src/services/session-configuration.ts new file mode 100644 index 00000000..393ac775 --- /dev/null +++ b/src/services/session-configuration.ts @@ -0,0 +1,163 @@ +import { + PolymorfaConfigurationError, + type UpdateSessionRequest, +} from '@polymorfa/sdk' + +function invalid(path: string): never { + throw new PolymorfaConfigurationError( + `Invalid session configuration at ${path}.`, + 'input', + ) +} + +function object( + value: unknown, + keys: readonly string[], + path: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(path) + const result = value as Record + if (Object.keys(result).some((key) => !keys.includes(key))) invalid(path) + return result +} + +const observationKeys = [ + 'presenceMode', + 'typingMode', + 'labelMode', + 'quickReplyMode', +] as const +const resetPaths = [ + 'historySync', + 'historySync.mode', + 'historySync.requestFull', + 'hms', + 'observation', + ...observationKeys.map((key) => `observation.${key}`), +] + +/** Validate the complete JSON boundary before constructing a client or sending a request. */ +export function parseSessionConfigurationUpdate( + input: unknown, +): UpdateSessionRequest { + const body = object(input, ['revision', 'configuration'], 'input') + if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) + invalid('revision') + const patch = object(body.configuration, ['set', 'reset'], 'configuration') + if (patch.set === undefined && patch.reset === undefined) + invalid('configuration.set/reset') + const set = + patch.set === undefined + ? {} + : object( + patch.set, + ['historySync', 'hms', 'observation'], + 'configuration.set', + ) + if (set.historySync !== undefined) { + const history = object( + set.historySync, + ['mode', 'requestFull'], + 'configuration.set.historySync', + ) + if ( + history.mode !== undefined && + (typeof history.mode !== 'string' || + !['metadata_only', 'deliver'].includes(history.mode)) + ) + invalid('historySync.mode') + if ( + history.requestFull !== undefined && + typeof history.requestFull !== 'boolean' + ) + invalid('historySync.requestFull') + } + if (set.observation !== undefined) { + const observation = object( + set.observation, + observationKeys, + 'configuration.set.observation', + ) + for (const [key, value] of Object.entries(observation)) { + const modes = + key === 'labelMode' + ? ['off', 'events', 'cache', 'project'] + : ['off', 'events', 'cache'] + if (typeof value !== 'string' || !modes.includes(value)) + invalid(`observation.${key}`) + } + } + if (set.hms !== undefined) { + const hms = object( + set.hms, + [ + 'enabled', + 'region', + 'policy', + 'retentionDays', + 'policyVersion', + 'legalHold', + ], + 'configuration.set.hms', + ) + if (hms.enabled === false) { + if (Object.keys(hms).length !== 1) invalid('hms') + } else if (hms.enabled === true) { + if ( + typeof hms.region !== 'string' || + !/^[a-z0-9][a-z0-9-]{0,31}$/.test(hms.region) + ) + invalid('hms.region') + const policies: Record = { + short: 7, + standard: 30, + extended: 90, + compliance: 365, + } + if ( + typeof hms.policy !== 'string' || + ![...Object.keys(policies), 'enterprise_archive'].includes(hms.policy) + ) + invalid('hms.policy') + if ( + hms.retentionDays !== undefined && + (!Number.isSafeInteger(hms.retentionDays) || + Number(hms.retentionDays) < 1 || + Number(hms.retentionDays) > 2555) + ) + invalid('hms.retentionDays') + if ( + hms.policy === 'enterprise_archive' + ? hms.retentionDays === undefined + : hms.retentionDays !== undefined && + hms.retentionDays !== policies[hms.policy] + ) + invalid('hms.retentionDays') + if ( + hms.policyVersion !== undefined && + (typeof hms.policyVersion !== 'string' || + hms.policyVersion.length < 1 || + hms.policyVersion.length > 128) + ) + invalid('hms.policyVersion') + if (hms.legalHold !== undefined && typeof hms.legalHold !== 'boolean') + invalid('hms.legalHold') + } else invalid('hms.enabled') + } + if (patch.reset !== undefined) { + if (!Array.isArray(patch.reset)) invalid('configuration.reset') + for (const path of patch.reset) { + if (typeof path !== 'string' || !resetPaths.includes(path)) + invalid('configuration.reset') + const [group, field] = path.split('.') + const value = set[group!] + if ( + value !== undefined && + (field === undefined || + (value as Record)[field] !== undefined) + ) + invalid(`configuration.reset.${path}: also present in set`) + } + } + return input as UpdateSessionRequest +} diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index ab69c2f6..c752a452 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -51,6 +51,75 @@ describe('Platform management commands', () => { }) }) + test.each([ + [], + {}, + {unknown: true}, + {set: []}, + {set: {unknown: true}}, + {set: {historySync: []}}, + {set: {historySync: {mode: ['deliver']}}}, + {set: {historySync: {requestFull: 'yes'}}}, + {set: {observation: {presenceMode: 'project'}}}, + {set: {observation: {unknown: 'off'}}}, + {set: {hms: {enabled: false, region: 'br'}}}, + {set: {hms: {enabled: true, region: 'BR', policy: 'short'}}}, + { + set: { + hms: {enabled: true, region: 'br', policy: 'short', retentionDays: 90}, + }, + }, + {set: {hms: {enabled: true, region: 'br', policy: 'enterprise_archive'}}}, + {reset: 'hms'}, + {reset: ['unknown']}, + {reset: [{}]}, + {set: {historySync: {mode: 'deliver'}}, reset: ['historySync']}, + { + set: {observation: {labelMode: 'project'}}, + reset: ['observation.labelMode'], + }, + ])( + 'rejects invalid nested configuration before HTTP: %j', + async (configuration) => { + server = await startHttpServer({body: {success: true, data: {}}}) + const input = await jsonInput('invalid-configuration.json', { + revision: 0, + configuration, + }) + const result = await platformEnvironment(() => + runCommand( + `session configuration update support --input ${input} --json`, + ), + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }, + ) + + test('sends validated nested overrides and non-overlapping resets unchanged', async () => { + server = await startHttpServer({body: {success: true, data: {}}}) + const payload = { + revision: 3, + configuration: { + set: { + historySync: {mode: 'deliver'}, + hms: {enabled: false}, + observation: {presenceMode: 'cache', labelMode: 'project'}, + }, + reset: ['historySync.requestFull', 'observation.typingMode'], + }, + } + const input = await jsonInput('configuration.json', payload) + const result = await platformEnvironment(() => + runCommand( + `session configuration update support --input ${input} --json`, + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual(payload) + }) + test('lists API-key metadata without implying that the scope mask is authoritative', async () => { server = await startHttpServer({ body: {