diff --git a/.env.live.example b/.env.live.example index e08a09f..5953b7d 100644 --- a/.env.live.example +++ b/.env.live.example @@ -1,3 +1,8 @@ +# Runnable examples default to PAT. Set sa explicitly to use SA Key or SAT. +QODER_AUTH_MODE=pat +# For SA examples, selects the business and exchange hosts together. +QODER_SA_REGION=cn + # Dedicated test account. Never commit credentials. QODER_FORWARD_BASE_URL=https://api.qoder.com/api/v1/forward QODER_FORWARD_PAT= @@ -18,3 +23,17 @@ QODER_MANAGED_LIVE_TIMEOUT_SECONDS=15 # Execution wait and each independent cleanup operation deadline. QODER_E2E_TIMEOUT_SECONDS=180 + +# Separate SA tests run CN and Global; they load this file or process variables. +# QODER_AUTH_MODE selects examples only; ordinary PAT suites stay PAT. +# Never commit real keys. +# Ordinary clients do not read these implicitly. +QODER_SA_CN_KEY= +QODER_SA_GLOBAL_KEY= +# Separate SA execution entry; set both flags only for dedicated test accounts. +QODER_SA_LIVE_ALLOW_WRITE=false +QODER_SA_LIVE_ALLOW_EXECUTION=false +QODER_SA_MODEL=auto +# Optional single-region factory input (QODER_SA_KEY), used only explicitly. +QODER_SA_KEY= +QODER_ACCESS_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c606d..99ce30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Read-only SA Key and SAT authentication examples, plus explicit PAT/SA selection for existing business examples without duplicating scenarios. +- Shared PAT and Service Account authentication for Forward and Managed, including explicit SA mode, direct SAT access, and lazy SA Key exchange with an instance-owned cache, early renewal, concurrent request sharing, and failure cooldown. +- Separate CN/Global SA authentication smoke checks for both API surfaces, covering automatic exchange and direct SAT use, plus opt-in write/read, Session SSE and cleanup verification. + +### Changed + +- Support `QODER_ACCESS_TOKEN` for the selected mode. SA never falls back to `QODER_PAT`; ordinary clients never implicitly read `QODER_SA_KEY`. +- Add `accessToken` and `authMode`; client copies retain authentication mode and source. Legacy `pat` plus a custom credential retains provider precedence. + +### Migration + +- For an existing SAT, select SA mode and supply the neutral token option. For automatic exchange, construct a Service Account credential with explicit region and scopes; do not combine it with a static token. Existing PAT calls require no new mode argument. + +### Fixed + +- Example failure logs retain error names, HTTP status, error codes and request IDs without dumping authenticated request objects. + ## [0.3.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a45288..7421347 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ npm run docs:check - **Unit and contract tests** live under `tests/` and run with `npm test`; focused entries include `npm run test:contract` and `npm run test:scenarios`. - **Integration tests** live under `tests/live/`. Copy `.env.live.example` to `.env.live`, use a dedicated test account, enable only the required write/execution gates, then run `npm run test:live`. - **E2E tests** execute real models and tools and run with `npm run test:e2e` after all documented execution gates are enabled. -- **Examples** are runnable documentation. Tests must not import from `examples/`; build first and run examples directly as documented in the README. +- **Examples** are runnable documentation. Business integration tests must not import from `examples/`; build first and run examples directly as documented in the README. Never commit `.env.live`, tokens, credentials, generated logs, or test output. Live scenarios must register cleanup immediately after creating a resource. @@ -144,3 +144,37 @@ If a workflow-only fix is needed after the release tag was created, merge that f ## Pull requests Complete the pull request template, include exact verification commands and results, and identify public API, documentation, integration-test, and cross-SDK effects. Do not combine unrelated refactors with behavior changes. + +## Service Account authentication checks + +The separate read-only SA suite requires `QODER_SA_CN_KEY` and +`QODER_SA_GLOBAL_KEY` in `.env.live` or the process environment and provisioned SA permissions +(and Managed workspace bindings). Run `npm run test:live:sa`. It tests both API +surfaces in CN and Global using automatic exchange and static SAT. Required keys +are checked before the first API call; normal PAT suites remain independent. +The read-only command does not prove write, model execution, SSE, or +user/identity isolation coverage. Do not print +SA Keys or SATs in test reports. + +For write/read, Session SSE and cleanup verification, use dedicated SA accounts +and run `npm run test:live:sa:execution` with +`QODER_SA_LIVE_ALLOW_WRITE=true` and `QODER_SA_LIVE_ALLOW_EXECUTION=true`. +`QODER_SA_MODEL` selects the model (default `auto`). This separate entry creates +unique test Environments, Templates/Agents, Forward Identities and Sessions in +both regions, sends one message per Session, checks the assistant reply and idle +event through SSE, and cleans up resources even after execution fails. Cleanup +failures fail the test. +Missing keys or execution flags fail this explicit command before resource creation. +The read-only SA command does not run these execution cases. + +Process environment variables override `.env.live`; explicit empty values do not +fall back to the file. Use `LIVE_ENV_FILE` to select another file. +`QODER_AUTH_MODE` selects example authentication only. PAT and SA test entries +are separate; setting it to `sa` does not switch the ordinary PAT suites. + +`npm test` includes SA live-file syntax checks, configuration regression tests, +and CJS/ESM public export checks without credentials or network calls. + +Business integration scenarios keep independent test helpers. Dedicated offline +regression tests for example configuration and lifecycle may import examples; +those tests do not replace SDK integration coverage. diff --git a/README.md b/README.md index ede19b0..08dd8c8 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ remains available. ### Configuration -`pat` accepts a string or a function returning a string or a promise, which is re-resolved before every request attempt — useful for short-lived tokens. A `Credential` object can be supplied instead; `PATCredential.fromEnv()` reads `QODER_PAT`. +`accessToken` accepts a string or a function returning a string or a promise, re-resolved before every request attempt. `pat` remains available for existing PAT integrations. `QODER_ACCESS_TOKEN` supplies the selected mode's token; PAT mode falls back to `QODER_PAT`. A `Credential` object can be supplied instead; see the authentication section for automatic SA exchange. The base URL comes from `QODER_FORWARD_BASE_URL` for Forward and `QODER_BASE_URL` for Managed, defaulting to `https://api.qoder.com/api/v1/forward` and `https://api.qoder.com/api/v1/cloud`. Explicit constructor options always win. @@ -126,6 +126,91 @@ const client = new ManagedClient({ }); ``` +## PAT and Service Account authentication + +PAT and SA are parallel modes. Existing `pat` calls keep working. For an existing +SAT use `{ authMode: 'sa', accessToken }`. `accessToken` also accepts a callback. +`QODER_ACCESS_TOKEN` supplies the selected mode's token; only PAT mode falls back +to `QODER_PAT`. Ordinary clients never automatically read `QODER_SA_KEY`. + +For automatic SAT exchange, explicitly construct a shared credential: + +```ts +import { ForwardClient, ManagedClient, ServiceAccountCredential } from 'qca-sdk'; + +const credential = ServiceAccountCredential.fromEnv({ + region: 'global', + scopes: ['qca.access', 'forward.access'], +}); +const forward = new ForwardClient({ credential }); +const managed = new ManagedClient({ credential }); +``` + +For CN, use `region: 'cn'` and matching `https://api.qoder.com.cn/api/v1/forward/` +and `https://api.qoder.com.cn/api/v1/cloud/` BaseURLs. Managed-only integrations +can request `['qca.access']`. Scopes and Service Account permissions/workspace +bindings must be configured server-side; the SDK does not provision them. + +The credential lazily exchanges the SA Key, caches the server's actual lifetime, +and renews on the next request in the refresh window. Concurrent callers share +one exchange. A transient failure can reuse a still-safe SAT; authentication or +invalid-response failures cannot. Failures enter a bounded cooldown. There is +no PAT fallback, background refresh, or automatic replay of a 401 write request. +The independent exchange timeout includes reading its body (default 30 seconds). +Cancelling a business request ends that caller's wait; the shared exchange can +continue and cache its result. Already-open streams are not closed by cache expiry. + +Do not combine `accessToken` with `pat` or `credential`, or an SA credential with +a static token. Legacy `pat` + custom credential retains provider precedence. +Request Authorization headers still override automatic injection. `withOptions` +retains mode and source; explicitly changing `authMode` clears the other mode's +credentials before applying new options. + +Run the read-only CN/Global SA smoke separately with `npm run test:live:sa`, setting +`QODER_SA_CN_KEY` and `QODER_SA_GLOBAL_KEY` in `.env.live` or the process environment. +Process variables take precedence; `LIVE_ENV_FILE` selects another file. +Both keys are checked before the first API call. For write/read, Session SSE and +cleanup verification, run `npm run test:live:sa:execution` with +`QODER_SA_LIVE_ALLOW_WRITE=true` and `QODER_SA_LIVE_ALLOW_EXECUTION=true`. +`QODER_SA_MODEL` defaults to `auto`. Ordinary offline and PAT suites do not require +SA credentials; `QODER_AUTH_MODE` switches examples, not the PAT test suites. See the +[official authentication contract](https://docs.qoder.com/cloud-agents/api/conventions/authentication). + +### Authentication examples + +The two read-only examples under `examples/auth/` show client construction directly: +SA Key exchange with automatic caching/renewal, and direct SAT access with renewal +managed by the caller. Each lists Forward templates and Managed agents; no model +execution or resources are created. The SA needs both API scopes/permissions and +its Managed workspace binding in the selected region. + +```sh +npm run build +# Configure QODER_SA_REGION=cn|global and only the credential needed by the demo. +node --env-file=.env.live examples/auth/sa-key.mjs # QODER_SA_KEY; leave QODER_ACCESS_TOKEN empty +node --env-file=.env.live examples/auth/sa-token.mjs # QODER_ACCESS_TOKEN; leave QODER_SA_KEY empty +``` + +The existing business examples keep PAT as the default. Node's `--env-file` can +load `.env.live`; the example helpers themselves read process environment variables. +Set `QODER_AUTH_MODE=sa` to select SA explicitly, then configure exactly one of +`QODER_SA_KEY` and `QODER_ACCESS_TOKEN`. `QODER_SA_REGION=cn|global` selects both the +business and exchange hosts. Leave the unused credential empty. Ambient PATs are +ignored in SA mode; missing SA credentials and conflicting Key/SAT inputs fail +before a request is sent. This selection is example configuration, not implicit +SDK behavior. + +```sh +# Reuse the same models or Session program with SA authentication. +QODER_AUTH_MODE=sa QODER_SA_REGION=cn node --env-file=.env.live examples/forward/models.mjs +QODER_AUTH_MODE=sa QODER_SA_REGION=global node --env-file=.env.live examples/managed/session.mjs +``` + +For a centralized SAT service, use +`new ForwardClient({ authMode: 'sa', accessToken: () => tokenManager.getValidSAT() })`. +The callback may return a promise and is called before each HTTP attempt; the token +manager is responsible for renewing the SAT. + ## Request and response types Every operation is typed. The two APIs define independent type sets, so request and response types are exported from the subpath entry points rather than the package root: diff --git a/docs/api/forward/README.md b/docs/api/forward/README.md index e7569db..7cc5a83 100644 --- a/docs/api/forward/README.md +++ b/docs/api/forward/README.md @@ -285,6 +285,12 @@ Re-exports [AuthenticationError](../index/classes/AuthenticationError.md) *** +### AuthMode + +Re-exports [AuthMode](../index/type-aliases/AuthMode.md) + +*** + ### BadRequestError Re-exports [BadRequestError](../index/classes/BadRequestError.md) @@ -381,6 +387,18 @@ Re-exports [ResumableSessionEventStream](../index/classes/ResumableSessionEventS *** +### ServiceAccountCredential + +Re-exports [ServiceAccountCredential](../index/classes/ServiceAccountCredential.md) + +*** + +### ServiceAccountCredentialOptions + +Re-exports [ServiceAccountCredentialOptions](../index/interfaces/ServiceAccountCredentialOptions.md) + +*** + ### Stream Re-exports [Stream](../index/classes/Stream.md) diff --git a/docs/api/index/README.md b/docs/api/index/README.md index cd3fe47..bec4a1c 100644 --- a/docs/api/index/README.md +++ b/docs/api/index/README.md @@ -28,6 +28,7 @@ - [QoderError](classes/QoderError.md) - [RateLimitError](classes/RateLimitError.md) - [ResumableSessionEventStream](classes/ResumableSessionEventStream.md) +- [ServiceAccountCredential](classes/ServiceAccountCredential.md) - [Stream](classes/Stream.md) - [UnprocessableEntityError](classes/UnprocessableEntityError.md) @@ -39,9 +40,11 @@ - [PageResponse](interfaces/PageResponse.md) - [RequestOptions](interfaces/RequestOptions.md) - [ServerSentEvent](interfaces/ServerSentEvent.md) +- [ServiceAccountCredentialOptions](interfaces/ServiceAccountCredentialOptions.md) ## Type Aliases +- [AuthMode](type-aliases/AuthMode.md) - [HeadersLike](type-aliases/HeadersLike.md) - [Middleware](type-aliases/Middleware.md) - [PaginationMode](type-aliases/PaginationMode.md) diff --git a/docs/api/index/classes/APIClient.md b/docs/api/index/classes/APIClient.md index d37dea0..e726cdf 100644 --- a/docs/api/index/classes/APIClient.md +++ b/docs/api/index/classes/APIClient.md @@ -33,6 +33,12 @@ ## Properties +### authMode + +> `readonly` **authMode**: [`AuthMode`](../type-aliases/AuthMode.md) + +*** + ### baseURL > `readonly` **baseURL**: `string` diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index b3fed00..3118e50 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -32,6 +32,16 @@ ## Properties +### authMode + +> `readonly` **authMode**: [`AuthMode`](../type-aliases/AuthMode.md) + +#### Inherited from + +[`APIClient`](APIClient.md).[`authMode`](APIClient.md#authmode) + +*** + ### baseURL > `readonly` **baseURL**: `string` diff --git a/docs/api/index/classes/ManagedClient.md b/docs/api/index/classes/ManagedClient.md index 43e6e93..560beab 100644 --- a/docs/api/index/classes/ManagedClient.md +++ b/docs/api/index/classes/ManagedClient.md @@ -40,6 +40,16 @@ Qoder Managed Mode API client. *** +### authMode + +> `readonly` **authMode**: [`AuthMode`](../type-aliases/AuthMode.md) + +#### Inherited from + +[`APIClient`](APIClient.md).[`authMode`](APIClient.md#authmode) + +*** + ### baseURL > `readonly` **baseURL**: `string` diff --git a/docs/api/index/classes/ServiceAccountCredential.md b/docs/api/index/classes/ServiceAccountCredential.md new file mode 100644 index 0000000..552a1df --- /dev/null +++ b/docs/api/index/classes/ServiceAccountCredential.md @@ -0,0 +1,81 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / ServiceAccountCredential + +# Class: ServiceAccountCredential + +Lazy, instance-owned SAT cache. Share an instance between clients in the same region. + +## Implements + +- [`Credential`](../interfaces/Credential.md) + +## Constructors + +### Constructor + +> **new ServiceAccountCredential**(`options`): `ServiceAccountCredential` + +#### Parameters + +##### options + +[`ServiceAccountCredentialOptions`](../interfaces/ServiceAccountCredentialOptions.md) + +#### Returns + +`ServiceAccountCredential` + +## Properties + +### region + +> `readonly` **region**: `"cn"` \| `"global"` + +## Methods + +### getToken() + +> **getToken**(): `Promise`\<`string`\> + +#### Returns + +`Promise`\<`string`\> + +#### Implementation of + +[`Credential`](../interfaces/Credential.md).[`getToken`](../interfaces/Credential.md#gettoken) + +*** + +### validateRegion() + +> **validateRegion**(`host`): `void` + +#### Parameters + +##### host + +`string` + +#### Returns + +`void` + +*** + +### fromEnv() + +> `static` **fromEnv**(`options`): `ServiceAccountCredential` + +#### Parameters + +##### options + +`Omit`\<[`ServiceAccountCredentialOptions`](../interfaces/ServiceAccountCredentialOptions.md), `"saKey"`\> + +#### Returns + +`ServiceAccountCredential` diff --git a/docs/api/index/interfaces/ClientOptions.md b/docs/api/index/interfaces/ClientOptions.md index 6d442f8..3f4daf2 100644 --- a/docs/api/index/interfaces/ClientOptions.md +++ b/docs/api/index/interfaces/ClientOptions.md @@ -8,6 +8,18 @@ ## Properties +### accessToken? + +> `optional` **accessToken?**: `string` \| (() => `string` \| `Promise`\<`string`\>) + +*** + +### authMode? + +> `optional` **authMode?**: [`AuthMode`](../type-aliases/AuthMode.md) + +*** + ### baseURL? > `optional` **baseURL?**: `string` @@ -42,7 +54,7 @@ ##### input -`URL` \| `RequestInfo` +`RequestInfo` \| `URL` ##### init? diff --git a/docs/api/index/interfaces/ServiceAccountCredentialOptions.md b/docs/api/index/interfaces/ServiceAccountCredentialOptions.md new file mode 100644 index 0000000..4d03f88 --- /dev/null +++ b/docs/api/index/interfaces/ServiceAccountCredentialOptions.md @@ -0,0 +1,61 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / ServiceAccountCredentialOptions + +# Interface: ServiceAccountCredentialOptions + +## Properties + +### fetch? + +> `optional` **fetch?**: (`input`, `init?`) => `Promise`\<`Response`\> + +[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) + +#### Parameters + +##### input + +`RequestInfo` \| `URL` + +##### init? + +`RequestInit` + +#### Returns + +`Promise`\<`Response`\> + +*** + +### region + +> **region**: `"cn"` \| `"global"` + +*** + +### saKey + +> **saKey**: `string` + +*** + +### scopes + +> **scopes**: readonly `string`[] + +*** + +### timeout? + +> `optional` **timeout?**: `number` + +Exchange timeout in milliseconds, including response body reading. + +*** + +### ttlSeconds? + +> `optional` **ttlSeconds?**: `number` diff --git a/docs/api/index/type-aliases/AuthMode.md b/docs/api/index/type-aliases/AuthMode.md new file mode 100644 index 0000000..d503912 --- /dev/null +++ b/docs/api/index/type-aliases/AuthMode.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / AuthMode + +# Type Alias: AuthMode + +> **AuthMode** = `"pat"` \| `"sa"` diff --git a/docs/api/managed/README.md b/docs/api/managed/README.md index 6f151e5..6c3c6ee 100644 --- a/docs/api/managed/README.md +++ b/docs/api/managed/README.md @@ -1052,6 +1052,12 @@ Re-exports [AuthenticationError](../index/classes/AuthenticationError.md) *** +### AuthMode + +Re-exports [AuthMode](../index/type-aliases/AuthMode.md) + +*** + ### BadRequestError Re-exports [BadRequestError](../index/classes/BadRequestError.md) @@ -1160,6 +1166,18 @@ Re-exports [ResumableSessionEventStream](../index/classes/ResumableSessionEventS *** +### ServiceAccountCredential + +Re-exports [ServiceAccountCredential](../index/classes/ServiceAccountCredential.md) + +*** + +### ServiceAccountCredentialOptions + +Re-exports [ServiceAccountCredentialOptions](../index/interfaces/ServiceAccountCredentialOptions.md) + +*** + ### Stream Re-exports [Stream](../index/classes/Stream.md) diff --git a/examples/auth/sa-key.mjs b/examples/auth/sa-key.mjs new file mode 100644 index 0000000..3acf145 --- /dev/null +++ b/examples/auth/sa-key.mjs @@ -0,0 +1,26 @@ +// Read-only authentication example: node examples/auth/sa-key.mjs. +// Set QODER_SA_KEY and QODER_SA_REGION=cn|global in the process environment. +import { ForwardClient, ManagedClient, ServiceAccountCredential } from '../../dist/index.js'; +import { isMain } from '../lib/forward.mjs'; + +export async function main() { + if (process.env.QODER_ACCESS_TOKEN) throw new Error('Unset QODER_ACCESS_TOKEN before running the SA Key example'); + const region = process.env.QODER_SA_REGION || 'cn'; + if (!['cn', 'global'].includes(region)) throw new Error('QODER_SA_REGION must be cn or global'); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const credential = new ServiceAccountCredential({ + saKey: process.env.QODER_SA_KEY, + region, + scopes: ['qca.access', 'forward.access'], + }); + // Both clients share the automatically renewed SAT cache. + const forward = new ForwardClient({ credential, baseURL: `${host}/api/v1/forward`, maxRetries: 0 }); + const managed = new ManagedClient({ credential, baseURL: `${host}/api/v1/cloud`, maxRetries: 0 }); + console.log('Forward templates:', (await forward.templates.list({ limit: 1 })).data.length); + console.log('Managed agents:', (await managed.agents.list({ limit: 1 })).data.length); +} + +if (isMain(import.meta.url)) main().catch(error => { + console.error(`SA Key example failed: ${error.name}`); + process.exitCode = 1; +}); diff --git a/examples/auth/sa-token.mjs b/examples/auth/sa-token.mjs new file mode 100644 index 0000000..b9e43e7 --- /dev/null +++ b/examples/auth/sa-token.mjs @@ -0,0 +1,22 @@ +// Read-only authentication example: node examples/auth/sa-token.mjs. +// Set QODER_ACCESS_TOKEN and QODER_SA_REGION=cn|global. The caller renews the SAT. +import { ForwardClient, ManagedClient } from '../../dist/index.js'; +import { isMain } from '../lib/forward.mjs'; + +export async function main() { + if (process.env.QODER_SA_KEY) throw new Error('Unset QODER_SA_KEY before running the SAT example'); + const region = process.env.QODER_SA_REGION || 'cn'; + if (!['cn', 'global'].includes(region)) throw new Error('QODER_SA_REGION must be cn or global'); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const accessToken = process.env.QODER_ACCESS_TOKEN; + if (!accessToken?.trim()) throw new Error('Configure QODER_ACCESS_TOKEN'); + const forward = new ForwardClient({ authMode: 'sa', accessToken, baseURL: `${host}/api/v1/forward`, maxRetries: 0 }); + const managed = new ManagedClient({ authMode: 'sa', accessToken, baseURL: `${host}/api/v1/cloud`, maxRetries: 0 }); + console.log('Forward templates:', (await forward.templates.list({ limit: 1 })).data.length); + console.log('Managed agents:', (await managed.agents.list({ limit: 1 })).data.length); +} + +if (isMain(import.meta.url)) main().catch(error => { + console.error(`SAT example failed: ${error.name}`); + process.exitCode = 1; +}); diff --git a/examples/forward/models.mjs b/examples/forward/models.mjs index 49ea784..a363c0c 100644 --- a/examples/forward/models.mjs +++ b/examples/forward/models.mjs @@ -1,6 +1,6 @@ // Forward · models:查询当前账号可用的模型。 // 运行:node examples/forward/models.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 -import { isMain, makeForwardClient } from '../lib/forward.mjs'; +import { formatExampleError, isMain, makeForwardClient } from '../lib/forward.mjs'; async function main() { const client = makeForwardClient(); @@ -12,7 +12,7 @@ async function main() { if (isMain(import.meta.url)) { main().catch((error) => { - console.error(error); + console.error(formatExampleError(error)); process.exitCode = 1; }); } diff --git a/examples/lib/auth.mjs b/examples/lib/auth.mjs new file mode 100644 index 0000000..af7bcf6 --- /dev/null +++ b/examples/lib/auth.mjs @@ -0,0 +1,46 @@ +// Authentication selection belongs to examples; clients do not implicitly read SA Keys. +import { ServiceAccountCredential } from '../../dist/index.js'; + +// Never print an SDK error object: it may include a Request with authentication headers. +export function formatExampleError(error) { + const name = /^[A-Za-z]+Error$/.test(error?.name ?? '') ? error.name : 'Error'; + const status = Number.isInteger(error?.status) ? ` HTTP ${error.status}` : ''; + const code = /^[a-zA-Z0-9_-]{1,80}$/.test(error?.code ?? '') ? ` code=${error.code}` : ''; + const requestID = /^[a-zA-Z0-9_-]{1,128}$/.test(error?.request_id ?? '') ? ` request_id=${error.request_id}` : ''; + return `${name}${status}${code}${requestID}`; +} + +export function clientOptions(mode) { + const authMode = process.env.QODER_AUTH_MODE || 'pat'; + const prefix = `QODER_${mode.toUpperCase()}_`; + const baseURL = process.env[`${prefix}BASE_URL`] || undefined; + if (authMode === 'pat') { + const pat = mode === 'managed' + ? process.env.QODER_PAT || process.env.QODER_MANAGED_PAT + : process.env.QODER_FORWARD_PAT || process.env.QODER_PAT; + if (!pat?.trim()) throw new Error(`设置 ${prefix}PAT 或 QODER_PAT 后运行本示例`); + return { pat, baseURL, maxRetries: 0 }; + } + if (authMode !== 'sa') throw new Error('QODER_AUTH_MODE must be pat or sa'); + const saKey = process.env.QODER_SA_KEY; + const accessToken = process.env.QODER_ACCESS_TOKEN; + if (Boolean(saKey?.trim()) === Boolean(accessToken?.trim())) { + throw new Error('SA examples require exactly one of QODER_SA_KEY and QODER_ACCESS_TOKEN'); + } + const suffix = mode === 'forward' ? 'forward' : 'cloud'; + const url = new URL(baseURL || `https://api.qoder.com/api/v1/${suffix}`); + if (url.protocol !== 'https:' || !['api.qoder.com', 'api.qoder.com.cn'].includes(url.host) + || url.username || url.password || url.search || url.hash || url.pathname.replace(/\/$/, '') !== `/api/v1/${suffix}`) { + throw new Error('SA examples require the public Forward or Managed HTTPS API root'); + } + const configuredRegion = process.env.QODER_SA_REGION; + if (configuredRegion) { + if (!['cn', 'global'].includes(configuredRegion)) throw new Error('QODER_SA_REGION must be cn or global'); + url.hostname = configuredRegion === 'cn' ? 'api.qoder.com.cn' : 'api.qoder.com'; + } + const region = url.hostname === 'api.qoder.com.cn' ? 'cn' : 'global'; + const options = { authMode: 'sa', baseURL: url.href, maxRetries: 0 }; + if (accessToken?.trim()) return { ...options, accessToken }; + const scopes = mode === 'forward' ? ['qca.access', 'forward.access'] : ['qca.access']; + return { ...options, credential: new ServiceAccountCredential({ saKey, region, scopes }) }; +} diff --git a/examples/lib/forward.mjs b/examples/lib/forward.mjs index 42bb225..0a6045c 100644 --- a/examples/lib/forward.mjs +++ b/examples/lib/forward.mjs @@ -8,8 +8,9 @@ import { pathToFileURL } from 'node:url'; import { randomBytes } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { ForwardClient, toFile } from '../../dist/forward/index.js'; +import { clientOptions, formatExampleError } from './auth.mjs'; -export { toFile }; +export { toFile, formatExampleError }; // 生成唯一名称与随机校验值,避免多次运行互相干扰。 export const name = (kind) => `sdk-example-${kind}-${randomBytes(4).toString('hex')}`; @@ -20,9 +21,7 @@ export const isMain = (moduleURL) => Boolean(process.argv[1]) && moduleURL === p // 从公开 API 构造 Forward 客户端;凭据与地址来自环境变量。 export function makeForwardClient() { - const pat = process.env.QODER_FORWARD_PAT ?? process.env.QODER_PAT; - if (!pat) throw new Error('设置 QODER_FORWARD_PAT 或 QODER_PAT 后运行本示例'); - return new ForwardClient({ pat, baseURL: process.env.QODER_FORWARD_BASE_URL, maxRetries: 0 }); + return new ForwardClient(clientOptions('forward')); } // 打印助手回复(多行缩进)。 @@ -66,7 +65,7 @@ export class ForwardExample { async cleanup() { for (const { label, run } of this.cleanups.reverse()) { try { await run(); console.log(` 已清理:${label}`); } - catch (error) { console.log(` 清理失败(忽略):${label} — ${error?.message ?? error}`); } + catch (error) { console.log(` 清理失败(忽略):${label} — ${formatExampleError(error)}`); } } this.cleanups = []; } @@ -205,7 +204,7 @@ export async function runForwardExample(demo) { try { await demo(example); } catch (error) { - console.error(error); + console.error(formatExampleError(error)); process.exitCode = 1; } finally { await example.cleanup(); diff --git a/examples/lib/managed.mjs b/examples/lib/managed.mjs index e178cb7..70a5d49 100644 --- a/examples/lib/managed.mjs +++ b/examples/lib/managed.mjs @@ -8,8 +8,9 @@ import { pathToFileURL } from 'node:url'; import { randomBytes } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { ManagedClient, toFile } from '../../dist/managed/index.js'; +import { clientOptions, formatExampleError } from './auth.mjs'; -export { toFile }; +export { toFile, formatExampleError }; export const name = (kind) => `sdk-example-${kind}-${randomBytes(4).toString('hex')}`; export const marker = () => randomBytes(12).toString('hex'); @@ -19,9 +20,7 @@ export const isMain = (moduleURL) => Boolean(process.argv[1]) && moduleURL === p // 从公开 API 构造 Managed 客户端;凭据与地址来自环境变量。 export function makeManagedClient() { - const pat = process.env.QODER_PAT ?? process.env.QODER_MANAGED_PAT; - if (!pat) throw new Error('设置 QODER_PAT 或 QODER_MANAGED_PAT 后运行本示例'); - return new ManagedClient({ pat, baseURL: process.env.QODER_MANAGED_BASE_URL, maxRetries: 0 }); + return new ManagedClient(clientOptions('managed')); } export function printReply(text) { @@ -61,7 +60,7 @@ export class ManagedExample { async cleanup() { for (const { label, run } of this.cleanups.reverse()) { try { await run(); console.log(` 已清理:${label}`); } - catch (error) { console.log(` 清理失败(忽略):${label} — ${error?.message ?? error}`); } + catch (error) { console.log(` 清理失败(忽略):${label} — ${formatExampleError(error)}`); } } this.cleanups = []; } @@ -187,7 +186,7 @@ export async function runManagedExample(demo) { try { await demo(example); } catch (error) { - console.error(error); + console.error(formatExampleError(error)); process.exitCode = 1; } finally { await example.cleanup(); diff --git a/examples/managed/models.mjs b/examples/managed/models.mjs index 75d4c0a..f59885d 100644 --- a/examples/managed/models.mjs +++ b/examples/managed/models.mjs @@ -1,6 +1,6 @@ // Managed · models:查询当前账号可用的模型。 // 运行:node examples/managed/models.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 -import { isMain, makeManagedClient } from '../lib/managed.mjs'; +import { formatExampleError, isMain, makeManagedClient } from '../lib/managed.mjs'; async function main() { const client = makeManagedClient(); @@ -12,7 +12,7 @@ async function main() { if (isMain(import.meta.url)) { main().catch((error) => { - console.error(error); + console.error(formatExampleError(error)); process.exitCode = 1; }); } diff --git a/package.json b/package.json index 4640544..9ff0b3d 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "scripts": { "build": "node scripts/build.mjs", "typecheck": "tsc --noEmit && tsc -p tsconfig.types.json", - "test": "npm run build && npm run test:types && node --test tests/*.test.mjs tests/scenarios/*.test.mjs", + "test": "npm run build && npm run test:types && npm run test:live:sa:check && node --test tests/*.test.mjs tests/scenarios/*.test.mjs", "test:unit": "npm test", "test:contract": "npm run build && node --test tests/api-contracts.test.mjs", "test:scenarios": "npm run build && node --test tests/scenarios/*.test.mjs", @@ -78,7 +78,10 @@ "test:e2e": "npm test && node scripts/run-live.mjs e2e", "example": "npm run build && node examples/forward/session.mjs", "docs": "node scripts/generate-docs.mjs", - "docs:check": "node scripts/docs-check.mjs" + "docs:check": "node scripts/docs-check.mjs", + "test:live:sa": "npm run build && node scripts/run-sa-live.mjs readonly", + "test:live:sa:execution": "npm run build && node scripts/run-sa-live.mjs execution", + "test:live:sa:check": "node --check tests/sa/auth-live.mjs && node --check tests/sa/execution-live.mjs" }, "devDependencies": { "semver": "7.7.3", diff --git a/scripts/run-sa-live.mjs b/scripts/run-sa-live.mjs new file mode 100644 index 0000000..7a80798 --- /dev/null +++ b/scripts/run-sa-live.mjs @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function validateSAEnvironment(environment, mode) { + if (!['readonly', 'execution'].includes(mode)) throw new Error('SA suite must be readonly or execution'); + for (const name of ['QODER_SA_CN_KEY', 'QODER_SA_GLOBAL_KEY']) { + if (!environment[name]?.trim()) throw new Error(`${name} is required`); + } + if (mode === 'execution') { + for (const name of ['QODER_SA_LIVE_ALLOW_WRITE', 'QODER_SA_LIVE_ALLOW_EXECUTION']) { + if (environment[name] !== 'true') throw new Error(`${name}=true is required`); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const mode = process.argv[2] ?? 'readonly'; + const envFile = resolve(process.env.LIVE_ENV_FILE ?? '.env.live'); + if (existsSync(envFile)) process.loadEnvFile(envFile); + validateSAEnvironment(process.env, mode); + const file = mode === 'execution' ? 'execution-live.mjs' : 'auth-live.mjs'; + const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1', `tests/sa/${file}`], { stdio: 'inherit' }); + process.exitCode = result.status ?? 1; + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/src/core/auth.ts b/src/core/auth.ts new file mode 100644 index 0000000..bd23560 --- /dev/null +++ b/src/core/auth.ts @@ -0,0 +1,37 @@ +import { type Credential, readEnv } from './credentials.js'; +import { ServiceAccountCredential } from './service-account-credential.js'; +import { QoderError } from './error.js'; + +export type AuthMode = 'pat' | 'sa'; +export type TokenSource = string | (() => string | Promise); +export interface AuthOptions { + authMode?: AuthMode; + pat?: TokenSource; + accessToken?: TokenSource; + credential?: Credential; +} + +export function resolveAuthMode(options: AuthOptions): AuthMode { + const nativeSA = options.credential instanceof ServiceAccountCredential; + const mode = options.authMode ?? (nativeSA ? 'sa' : 'pat'); + if (mode !== 'pat' && mode !== 'sa') throw new QoderError('authMode must be pat or sa'); + if (mode === 'sa' && options.pat !== undefined) throw new QoderError('pat cannot be used in SA mode'); + if (nativeSA && (mode !== 'sa' || options.pat !== undefined || options.accessToken !== undefined)) { + throw new QoderError('ServiceAccountCredential requires SA mode without a static token'); + } + if (options.accessToken !== undefined && (options.pat !== undefined || options.credential !== undefined)) { + throw new QoderError('accessToken cannot be combined with pat or credential'); + } + return mode; +} + +export async function resolveToken(options: AuthOptions, mode: AuthMode): Promise { + const source = options.credential ?? options.accessToken ?? options.pat; + if (source !== undefined) { + const token = typeof source === 'object' ? await source.getToken() + : typeof source === 'function' ? await source() : source; + if (typeof token !== 'string' || !token.trim()) throw new QoderError('Configured credential returned an empty access token'); + return token; + } + return readEnv('QODER_ACCESS_TOKEN') || (mode === 'pat' ? readEnv('QODER_PAT') : undefined); +} diff --git a/src/core/client.ts b/src/core/client.ts index c9e8999..778ebea 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,5 +1,7 @@ import { APIPromise } from './api-promise.js'; import { type Credential, readEnv } from './credentials.js'; +import { type AuthMode, resolveAuthMode, resolveToken } from './auth.js'; +import { ServiceAccountCredential } from './service-account-credential.js'; import { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, QoderError } from './error.js'; import { Page, PagePromise, type PageResponse, type PaginationMode } from './pagination.js'; import { Stream } from './streaming.js'; @@ -9,6 +11,8 @@ import { platformHeaders } from './detect-platform.js'; export type HeadersLike = HeadersInit | Record; export type Middleware = (request: Request, next: (request: Request) => Promise) => Promise; export interface ClientOptions { + authMode?: AuthMode; + accessToken?: string | (() => string | Promise); pat?: string | (() => string | Promise); credential?: Credential; baseURL?: string; @@ -88,6 +92,7 @@ async function wait(ms: number, signal: AbortSignal, timedOut: () => boolean): P } export class APIClient { + readonly authMode: AuthMode; readonly baseURL: string; readonly maxRetries: number; readonly timeout: number; @@ -97,12 +102,14 @@ export class APIClient { private readonly controllers = new WeakMap(); constructor(options: ClientOptions = {}, readonly mode: 'forward' | 'managed' = 'managed') { - this.options = { ...options }; + this.authMode = resolveAuthMode(options); + this.options = { ...options, authMode: this.authMode }; this.defaultHeaders = options.defaultHeaders; this.baseURL = (options.baseURL ?? readEnv(mode === 'forward' ? 'QODER_FORWARD_BASE_URL' : 'QODER_BASE_URL') ?? `https://api.qoder.com/api/v1/${mode === 'forward' ? 'forward' : 'cloud'}`).replace(/\/+$/, ''); const url = new URL(this.baseURL); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new QoderError('Invalid baseURL'); + if (options.credential instanceof ServiceAccountCredential) options.credential.validateRegion(url.hostname); this.maxRetries = options.maxRetries ?? 2; this.timeout = options.timeout ?? 600_000; this.validateOptions(this.maxRetries, this.timeout); @@ -113,8 +120,14 @@ export class APIClient { /** Create a client of the same type, replacing supplied options and retaining the rest. */ withOptions(options: Partial): this { const Client = this.constructor as new (options: ClientOptions, mode: 'forward' | 'managed') => this; + const inherited = { ...this.options }; + if (options.authMode !== undefined && options.authMode !== this.authMode) { + delete inherited.pat; + delete inherited.accessToken; + delete inherited.credential; + } return new Client({ - ...this.options, + ...inherited, baseURL: this.baseURL, maxRetries: this.maxRetries, timeout: this.timeout, @@ -134,7 +147,7 @@ export class APIClient { } throw new Error( - 'Could not resolve authentication method. Expected one of pat or credential to be set, or the QODER_PAT environment variable to be configured.', + 'Could not resolve authentication method. Configure accessToken or credential, or QODER_ACCESS_TOKEN (QODER_PAT is supported in PAT mode).', ); } @@ -250,9 +263,7 @@ export class APIClient { if (!storage) { attemptHeaders.set('X-Qoder-Retry-Count', String(attempt)); if (!attemptHeaders.has('authorization')) { - const credential = this.options.credential; - const configured = this.options.pat; - const token = await withSignal(Promise.resolve(credential ? credential.getToken() : typeof configured === 'function' ? configured() : configured ?? readEnv('QODER_PAT')), signal, () => timedOut); + const token = await withSignal(resolveToken(this.options, this.authMode), signal, () => timedOut); if (token) attemptHeaders.set('Authorization', `Bearer ${token}`); } this.validateHeaders(attemptHeaders); diff --git a/src/core/credentials.ts b/src/core/credentials.ts index 0582e57..e5309df 100644 --- a/src/core/credentials.ts +++ b/src/core/credentials.ts @@ -2,6 +2,10 @@ export interface Credential { getToken(): string | Promise; } +export { ServiceAccountCredential } from './service-account-credential.js'; +export type { ServiceAccountCredentialOptions } from './service-account-credential.js'; +export type { AuthMode } from './auth.js'; + export function readEnv(name: string): string | undefined { return (globalThis as unknown as { process?: { env?: Record } }).process?.env?.[name]; } diff --git a/src/core/service-account-credential.ts b/src/core/service-account-credential.ts new file mode 100644 index 0000000..871dac0 --- /dev/null +++ b/src/core/service-account-credential.ts @@ -0,0 +1,137 @@ +import type { Credential } from './credentials.js'; +import { readEnv } from './credentials.js'; +import { APIConnectionError, APIConnectionTimeoutError, APIError, QoderError } from './error.js'; + +export interface ServiceAccountCredentialOptions { + saKey: string; + region: 'cn' | 'global'; + scopes: readonly string[]; + ttlSeconds?: number; + /** Exchange timeout in milliseconds, including response body reading. */ + timeout?: number; + fetch?: typeof globalThis.fetch; +} + +/** Lazy, instance-owned SAT cache. Share an instance between clients in the same region. */ +export class ServiceAccountCredential implements Credential { + readonly region: 'cn' | 'global'; + #key: string; + #scope: string; + #ttl: number; + #timeout: number; + #fetch: typeof globalThis.fetch; + #token?: string; + #refreshAt = 0; + #safeUntil = 0; + #inflight?: Promise; + #failure?: unknown; + #retryAt = 0; + #transient = false; + + constructor(options: ServiceAccountCredentialOptions) { + if (!options.saKey?.trim()) throw new QoderError('saKey must not be empty'); + if (options.region !== 'cn' && options.region !== 'global') throw new QoderError('region must be cn or global'); + if (!Array.isArray(options.scopes) || !options.scopes.length + || options.scopes.some(scope => typeof scope !== 'string' || !scope || /\s/.test(scope))) { + throw new QoderError('scopes must be a nonempty array of scope names'); + } + this.region = options.region; + this.#key = options.saKey; + this.#scope = [...new Set(options.scopes)].join(' '); + this.#ttl = options.ttlSeconds ?? 3600; + if (!Number.isInteger(this.#ttl) || this.#ttl < 1 || this.#ttl > 43200) throw new QoderError('ttlSeconds must be an integer from 1 to 43200'); + this.#timeout = options.timeout ?? 30_000; + if (!Number.isFinite(this.#timeout) || this.#timeout <= 0 || this.#timeout > 2 ** 31 - 1) throw new QoderError('Invalid exchange timeout'); + this.#fetch = options.fetch ?? globalThis.fetch; + if (!this.#fetch) throw new QoderError('A fetch implementation is required'); + } + + static fromEnv(options: Omit): ServiceAccountCredential { + return new ServiceAccountCredential({ ...options, saKey: readEnv('QODER_SA_KEY') ?? '' }); + } + + validateRegion(host: string): void { + const expected = host === 'api.qoder.com.cn' ? 'cn' : host === 'api.qoder.com' ? 'global' : undefined; + if (expected && expected !== this.region) throw new QoderError('SA credential region does not match baseURL'); + } + + getToken(): Promise { + const now = performance.now(); + if (now < this.#retryAt) return this.#fallback(); + if (this.#token && now < this.#refreshAt) return Promise.resolve(this.#token); + if (!this.#inflight) { + const task = this.#exchange(); + this.#inflight = task; + // Clear only this generation; rejection is shared and cached during cooldown. + void task.then(() => { this.#inflight = undefined; }, () => { this.#inflight = undefined; }); + } + return this.#inflight; + } + + #fallback(): Promise { + if (this.#transient && this.#token && performance.now() < this.#safeUntil) return Promise.resolve(this.#token); + return Promise.reject(this.#failure); + } + + async #exchange(): Promise { + const started = performance.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.#timeout); + let response: Response | undefined; + let bodyRead = false; + try { + const host = this.region === 'cn' ? 'https://openapi.qoder.com.cn' : 'https://openapi.qoder.sh'; + response = await this.#fetch(`${host}/api/v1/serviceToken/exchange`, { + method: 'POST', redirect: 'error', credentials: 'omit', signal: controller.signal, + headers: { Authorization: `Bearer ${this.#key}`, 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ grant_type: 'client_credentials', audience: 'qoder', scope: this.#scope, ttl_seconds: this.#ttl }), + }); + const text = await response.text(); + bodyRead = true; + let body: Record = {}; + try { body = JSON.parse(text); } catch { /* Handled as an invalid response below. */ } + if (!response.ok) { + const detail = body?.error && typeof body.error === 'object' ? body.error as Record : body; + const safe = (value: unknown) => typeof value === 'string' ? value.split(this.#key).join('[redacted]').split(this.#token ?? '\0').join('[redacted]').slice(0,256) : undefined; + throw APIError.generate(response.status, { code: safe(detail?.code), request_id: safe(body?.request_id) }, + 'Service account token exchange failed', new Headers({ 'x-request-id': safe(response.headers.get('x-request-id') ?? response.headers.get('request-id')) ?? '' })); + } + const ttl = typeof body?.expires_in === 'string' && /^\d+$/.test(body.expires_in) ? Number(body.expires_in) : body?.expires_in; + if (typeof body?.access_token !== 'string' || !body.access_token.trim() + || typeof body.token_type !== 'string' || body.token_type.toLowerCase() !== 'bearer' + || typeof ttl !== 'number' || !Number.isSafeInteger(ttl) || ttl <= 0 || ttl > 43200 + || (body.expires_at !== undefined && (typeof body.expires_at !== 'string' + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(body.expires_at) + || !Number.isFinite(Date.parse(body.expires_at))))) { + throw new QoderError('Invalid service account token exchange response'); + } + const safeUntil = started + (ttl - Math.min(5, ttl * 0.05)) * 1000; + if (performance.now() >= safeUntil) throw new QoderError('Exchanged token has insufficient remaining lifetime'); + this.#token = body.access_token; + this.#safeUntil = safeUntil; + this.#refreshAt = started + (ttl - Math.min(60, ttl * 0.1)) * 1000; + this.#retryAt = 0; + this.#failure = undefined; + return this.#token; + } catch (error) { + const failure = controller.signal.aborted ? new APIConnectionTimeoutError('Service account token exchange timed out') + : bodyRead && error instanceof QoderError ? error : new APIConnectionError('Service account token exchange connection failed'); + this.#failure = failure; + this.#transient = failure instanceof APIConnectionError + || (failure instanceof APIError && (failure.status === 429 || (failure.status !== undefined && failure.status >= 500))); + let cooldown = 5000; + if (response?.status === 429) { + const value = response.headers.get('retry-after'); + if (value) { + const seconds = Number(value); + const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now(); + if (Number.isFinite(delay) && delay > 0 && delay <= 2 ** 31 - 1) cooldown = Math.max(cooldown, delay); + } + } + this.#retryAt = performance.now() + cooldown; + return this.#fallback(); + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/forward/index.ts b/src/forward/index.ts index a27ed05..b414caa 100644 --- a/src/forward/index.ts +++ b/src/forward/index.ts @@ -34,7 +34,8 @@ export { Stream } from '../core/streaming.js'; export { ResumableSessionEventStream } from '../core/resumable-session-event-stream.js'; export { toFile } from '../core/uploads.js'; export type { Uploadable } from '../core/uploads.js'; -export { PATCredential } from '../core/credentials.js'; +export { PATCredential, ServiceAccountCredential } from '../core/credentials.js'; +export type { AuthMode, ServiceAccountCredentialOptions } from '../core/credentials.js'; export type { Credential } from '../core/credentials.js'; export * from '../core/error.js'; export { Usage } from './usage.js'; diff --git a/tests/example-auth.test.mjs b/tests/example-auth.test.mjs new file mode 100644 index 0000000..b370bd4 --- /dev/null +++ b/tests/example-auth.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { clientOptions, formatExampleError } from '../examples/lib/auth.mjs'; +import { makeForwardClient } from '../examples/lib/forward.mjs'; +import { makeManagedClient } from '../examples/lib/managed.mjs'; + +const names = ['QODER_AUTH_MODE', 'QODER_SA_KEY', 'QODER_ACCESS_TOKEN', 'QODER_SA_REGION', 'QODER_PAT', + 'QODER_FORWARD_PAT', 'QODER_MANAGED_PAT', 'QODER_FORWARD_BASE_URL', 'QODER_MANAGED_BASE_URL']; + +function withEnv(values, run) { + const previous = names.map(name => process.env[name]); + for (const name of names) delete process.env[name]; + Object.assign(process.env, { QODER_PAT: 'ambient-pat', ...values }); + return Promise.resolve().then(run).finally(() => names.forEach((name, index) => { + if (previous[index] === undefined) delete process.env[name]; else process.env[name] = previous[index]; + })); +} + +for (const [mode, factory] of [['forward', makeForwardClient], ['managed', makeManagedClient]]) { + test(`${mode} business examples select PAT or SAT without ambient PAT masking SA`, async () => { + for (const authMode of ['pat', 'sa']) { + await withEnv({ QODER_AUTH_MODE: authMode, QODER_ACCESS_TOKEN: 'static-sat' }, async () => { + const requests = []; + const client = factory().withOptions({ fetch: async (url, init) => { + requests.push(new Request(url, init)); + return new Response(JSON.stringify({ data: [] }), { headers: { 'content-type': 'application/json' } }); + } }); + await client.models.list({}); + assert.equal(requests[0].headers.get('authorization'), `Bearer ${authMode === 'sa' ? 'static-sat' : 'ambient-pat'}`); + }); + } + }); +} + +test('SA example factories reject missing credentials, conflicting credentials and invalid regions', async () => { + for (const extra of [{}, { QODER_SA_KEY: 'key', QODER_ACCESS_TOKEN: 'sat' }]) { + await withEnv({ QODER_AUTH_MODE: 'sa', ...extra }, () => { + assert.throws(() => clientOptions('forward'), /exactly one/); + }); + } + await withEnv({ QODER_AUTH_MODE: 'sa', QODER_SA_KEY: 'key', QODER_SA_REGION: 'invalid' }, () => { + assert.throws(() => clientOptions('managed'), /REGION must be cn or global/); + }); +}); + +test('SA Key examples keep exchange and business hosts in the selected region', async () => { + await withEnv({ QODER_AUTH_MODE: 'sa', QODER_SA_KEY: 'key', QODER_SA_REGION: 'cn' }, async () => { + const requests = []; + const previousFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + const request = new Request(url, init); + requests.push(request); + const body = request.url.endsWith('/exchange') + ? { access_token: 'exchanged-sat', token_type: 'Bearer', expires_in: 3600 } : { data: [] }; + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); + }; + try { + await makeForwardClient().models.list({}); + assert.deepEqual(requests.map(r => new URL(r.url).hostname), ['openapi.qoder.com.cn', 'api.qoder.com.cn']); + assert.deepEqual(requests.map(r => r.headers.get('authorization')), ['Bearer key', 'Bearer exchanged-sat']); + } finally { globalThis.fetch = previousFetch; } + }); +}); + +test('example error logging retains diagnostics without request headers or response bodies', () => { + const error = Object.assign(new Error('SAT-secret in a response message'), { + name: 'ConflictError', status: 409, code: 'batch_already_processing', request_id: 'test-request-id', + request: new Request('https://api.qoder.com', { headers: { authorization: 'Bearer SAT-secret' } }), + error: { message: 'SAT-secret in response body' }, + }); + assert.equal(formatExampleError(error), 'ConflictError HTTP 409 code=batch_already_processing request_id=test-request-id'); + assert.ok(!formatExampleError(error).includes('SAT-secret')); +}); diff --git a/tests/package.test.mjs b/tests/package.test.mjs index 2b522b7..1157a9c 100644 --- a/tests/package.test.mjs +++ b/tests/package.test.mjs @@ -10,6 +10,7 @@ for (const mode of ['forward', 'managed']) { const Client = module.default; assert.equal(typeof Client, 'function'); assert.equal(typeof module.PATCredential, 'function'); + assert.equal(typeof module.ServiceAccountCredential, 'function'); assert.equal(typeof module.toFile, 'function'); const client = new Client({ pat: 'package-test', maxRetries: 0, fetch: async (url, init) => { assert.match(String(url), /\/models$/); @@ -23,7 +24,7 @@ for (const mode of ['forward', 'managed']) { } test('package root: named exports are available to CommonJS and ESM', async () => { for (const module of [require('qca-sdk'), await import('qca-sdk')]) { - for (const name of ['ForwardClient', 'ManagedClient', 'APIPromise', 'Stream', 'Page', 'PATCredential', 'toFile', 'APIError']) { + for (const name of ['ForwardClient', 'ManagedClient', 'APIPromise', 'Stream', 'Page', 'PATCredential', 'ServiceAccountCredential', 'toFile', 'APIError']) { assert.equal(typeof module[name], 'function', name); } } diff --git a/tests/sa-live-configuration.test.mjs b/tests/sa-live-configuration.test.mjs new file mode 100644 index 0000000..59fc263 --- /dev/null +++ b/tests/sa-live-configuration.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { validateSAEnvironment } from '../scripts/run-sa-live.mjs'; +import { safeFailure } from './sa/diagnostics.mjs'; + +test('SA preflight validates both regions before starting read or execution tests', () => { + for (const mode of ['readonly', 'execution']) { + assert.throws(() => validateSAEnvironment({ QODER_SA_CN_KEY: 'cn' }, mode), /QODER_SA_GLOBAL_KEY is required/); + } + validateSAEnvironment({ QODER_SA_CN_KEY: 'cn', QODER_SA_GLOBAL_KEY: 'global' }, 'readonly'); +}); + +test('SA failure diagnostics omit authenticated requests and response bodies', () => { + const failure = { status: 403, code: 'forbidden', request_id: 'req-test', + request: { headers: { Authorization: 'Bearer secret' } }, body: { token: 'secret' }, message: 'secret' }; + assert.equal(safeFailure(failure), 'HTTP 403 code=forbidden request_id=req-test'); + assert.doesNotMatch(safeFailure(new AggregateError([failure], 'secret')), /secret|Authorization/); +}); + +test('SA execution requires both write and execution flags', () => { + const env = { QODER_SA_CN_KEY: 'cn', QODER_SA_GLOBAL_KEY: 'global', + QODER_SA_LIVE_ALLOW_WRITE: 'true', QODER_SA_LIVE_ALLOW_EXECUTION: 'true' }; + for (const name of ['QODER_SA_LIVE_ALLOW_WRITE', 'QODER_SA_LIVE_ALLOW_EXECUTION']) { + assert.throws(() => validateSAEnvironment({ ...env, [name]: 'false' }, 'execution'), new RegExp(`${name}=true is required`)); + } + validateSAEnvironment(env, 'execution'); +}); + +test('SA entry loads the env file, respects explicit empty environment and fails before child tests', () => { + const dir = mkdtempSync(join(tmpdir(), 'qca-sa-entry-')); + try { + const path = join(dir, '.env.live'); + writeFileSync(path, 'QODER_SA_CN_KEY=file-cn\nQODER_SA_GLOBAL_KEY=file-global\n'); + for (const name of ['QODER_SA_CN_KEY', 'QODER_SA_GLOBAL_KEY']) { + const env = { ...process.env, LIVE_ENV_FILE: path }; + delete env.QODER_SA_CN_KEY; + delete env.QODER_SA_GLOBAL_KEY; + env[name] = ''; + const result = spawnSync(process.execPath, ['scripts/run-sa-live.mjs', 'readonly'], { env, encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.match(result.stderr, new RegExp(`${name} is required`)); + assert.equal(result.stdout, '', 'no account-backed child test should start'); + assert.doesNotMatch(result.stderr, /file-cn|file-global/); + } + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/tests/sa/auth-live.mjs b/tests/sa/auth-live.mjs new file mode 100644 index 0000000..8ad5fe6 --- /dev/null +++ b/tests/sa/auth-live.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ForwardClient, ManagedClient, ServiceAccountCredential } from '../../dist/index.js'; +import { safeFailure } from './diagnostics.mjs'; + +for (const region of ['cn', 'global']) { + test(`SA authentication: ${region} Forward and Managed, automatic and static SAT`, { timeout: 180_000 }, async () => { + const name = region === 'cn' ? 'QODER_SA_CN_KEY' : 'QODER_SA_GLOBAL_KEY'; + assert.ok(process.env[name], `${name} is required`); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const credential = new ServiceAccountCredential({ saKey: process.env[name], region, scopes: ['qca.access', 'forward.access'] }); + try { + for (const [Client, resource, path] of [[ForwardClient, 'templates', 'forward'], [ManagedClient, 'agents', 'cloud']]) { + const options = { baseURL: `${host}/api/v1/${path}/`, maxRetries: 0, timeout: 30_000 }; + const automatic = await new Client({ ...options, credential })[resource].list({ limit: 1 }); + assert.ok(Array.isArray(automatic.data)); + const accessToken = await credential.getToken(); + const direct = await new Client({ ...options, authMode: 'sa', accessToken })[resource].list({ limit: 1 }); + assert.ok(Array.isArray(direct.data)); + } + } catch (error) { throw new Error(safeFailure(error)); } + }); +} diff --git a/tests/sa/diagnostics.mjs b/tests/sa/diagnostics.mjs new file mode 100644 index 0000000..53ede58 --- /dev/null +++ b/tests/sa/diagnostics.mjs @@ -0,0 +1,5 @@ +export function safeFailure(error) { + if (error instanceof AggregateError) return error.errors.map(safeFailure).join('; '); + if (error.status) return `HTTP ${error.status} code=${error.code ?? ''} request_id=${error.request_id ?? ''}`; + return error.message; +} diff --git a/tests/sa/execution-live.mjs b/tests/sa/execution-live.mjs new file mode 100644 index 0000000..65cd2ee --- /dev/null +++ b/tests/sa/execution-live.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ForwardClient, ManagedClient, ServiceAccountCredential } from '../../dist/index.js'; +import { ForwardLiveSuite, marker } from '../live/forward-support.mjs'; +import { ManagedScenarioSuite } from '../live/managed-support.mjs'; +import { safeFailure } from './diagnostics.mjs'; + +for (const region of ['cn', 'global']) { + for (const mode of ['forward', 'managed']) { + test(`SA execution: ${region} ${mode} write, read, SSE and cleanup`, { timeout: 600_000 }, async (t) => { + for (const flag of ['QODER_SA_LIVE_ALLOW_WRITE', 'QODER_SA_LIVE_ALLOW_EXECUTION']) { + assert.equal(process.env[flag], 'true', `${flag}=true is required`); + } + const name = region === 'cn' ? 'QODER_SA_CN_KEY' : 'QODER_SA_GLOBAL_KEY'; + assert.ok(process.env[name], `${name} is required`); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const path = mode === 'forward' ? 'forward' : 'cloud'; + const credential = new ServiceAccountCredential({ saKey: process.env[name], region, scopes: ['qca.access', 'forward.access'] }); + const Client = mode === 'forward' ? ForwardClient : ManagedClient; + const client = new Client({ credential, baseURL: `${host}/api/v1/${path}/`, maxRetries: 0, timeout: 30_000, + fetch: async (input, init) => { + const response = await fetch(input, init); + const url = new URL(input instanceof Request ? input.url : String(input)); + t.diagnostic(`http method=${init?.method ?? 'GET'} path=${url.pathname} status=${response.status} request_id=${response.headers.get('x-request-id') ?? ''}`); + return response; + }, + }); + const model = process.env.QODER_SA_MODEL || 'auto'; + try { + if (mode === 'forward') { + const suite = new ForwardLiveSuite(client, { model }); + let failure; + try { + const environment = await suite.environment(); + assert.equal((await client.environments.retrieve(environment.id, suite.options)).id, environment.id); + const identity = await suite.identity(); + const template = await suite.template(environment.id); + assert.equal((await client.templates.retrieve(template.id, suite.options)).id, template.id); + const session = await suite.session(identity.id, template.id); + t.diagnostic(`created session=${session.id} model=${model}`); + assert.equal((await client.sessions.retrieve(session.id, suite.options)).id, session.id); + const expected = marker(); + const after = await suite.sendTurn(session.id, `Reply with exactly ${expected}`); + const reply = await suite.waitTurn(session.id, after, [expected], false, true); + t.diagnostic(`verified session=${session.id} last_event_id=${reply.lastID} via SSE`); + } catch (error) { failure = error; } + const resources = suite.cleanups.map(item => item.label); + try { await suite.close(); } + catch (error) { throw new AggregateError([...(failure ? [failure] : []), error], 'Execution or cleanup failed'); } + t.diagnostic(`cleanup completed: ${resources.join(', ')}`); + if (failure) throw failure; + } else { + const suite = new ManagedScenarioSuite(client, { model }); + await suite.run(async (s) => { + const environment = await s.createEnvironment(); + assert.equal((await client.environments.retrieve(environment.id, {}, s.options())).id, environment.id); + const agent = await s.createAgent(); + assert.equal((await client.agents.retrieve(agent.id, {}, s.options())).id, agent.id); + const session = await client.sessions.create({ agent: agent.id, environment_id: environment.id }, s.options()); + s.cleanupSession(session.id); + t.diagnostic(`created session=${session.id} model=${model}`); + assert.equal((await client.sessions.retrieve(session.id, {}, s.options())).id, session.id); + const expected = marker(); + const after = await s.sendTurn(session.id, `Reply with exactly ${expected}`); + const reply = await s.waitTurn(session.id, after, [expected], false, true); + t.diagnostic(`verified session=${session.id} last_event_id=${reply.lastID} via SSE`); + }); + t.diagnostic('cleanup completed: session, agent, environment'); + } + } catch (error) { throw new Error(safeFailure(error)); } + }); + } +} diff --git a/tests/service-account.test.mjs b/tests/service-account.test.mjs new file mode 100644 index 0000000..987ffb3 --- /dev/null +++ b/tests/service-account.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { test, mock } from 'node:test'; +import { sdk, response } from './helpers.mjs'; + +const config = (fetch, options = {}) => ({ saKey: 'secret-key', region: 'global', scopes: ['qca.access', 'forward.access'], fetch, ...options }); +const tokenResponse = (token = 'sat-1', expires = 100) => response({ access_token: token, token_type: 'Bearer', expires_in: expires, expires_at: '2099-01-01T00:00:00Z' }); + +for (const [Client, resource] of [[sdk.ForwardClient,'templates'], [sdk.ManagedClient,'agents']]) { + test(`${resource}: PAT and SA modes isolate ambient credentials and survive copying`, async () => { + const previous = [process.env.QODER_PAT, process.env.QODER_ACCESS_TOKEN]; + const requests = []; + const fetch = async (input, init) => { requests.push(new Request(input, init)); return response({ data: [] }); }; + try { + process.env.QODER_PAT = 'ambient-pat'; + delete process.env.QODER_ACCESS_TOKEN; + const pat = new Client({ fetch }); + await pat[resource].list({}); + const sa = new Client({ authMode: 'sa', accessToken: 'static-sat', fetch }); + await sa.withOptions({})[resource].list({}); + await sa.withOptions({ authMode: 'pat' })[resource].list({}); + await assert.rejects(new Client({ authMode: 'sa', fetch })[resource].list({}), /Could not resolve/); + process.env.QODER_ACCESS_TOKEN = 'generic-token'; + await new Client({ authMode: 'sa', fetch })[resource].list({}); + await new Client({ fetch })[resource].list({}); + assert.deepEqual(requests.map(r => r.headers.get('authorization')), ['Bearer ambient-pat','Bearer static-sat','Bearer ambient-pat','Bearer generic-token','Bearer generic-token']); + assert.throws(() => new Client({ authMode: 'sa', pat: 'pat' }), /pat cannot/); + assert.throws(() => new Client({ accessToken: 'sat', credential: { getToken: () => 'other' } }), /cannot be combined/); + await assert.rejects(new Client({ accessToken: '', fetch })[resource].list({}), /empty/); + } finally { + for (const [name,value] of [['QODER_PAT',previous[0]],['QODER_ACCESS_TOKEN',previous[1]]]) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + } + }); +} + +test('shared SA credential exchanges once, keeps key off API requests and validates region', async () => { + const exchanges = [], api = []; + const credential = new sdk.ServiceAccountCredential(config(async (input, init) => { exchanges.push(new Request(input,init)); return tokenResponse(); })); + const fetch = async (input, init) => { api.push(new Request(input,init)); return response({ data: [] }); }; + await new sdk.ForwardClient({ credential, fetch }).templates.list({}); + await new sdk.ManagedClient({ credential, fetch }).agents.list({}); + assert.equal(exchanges.length,1); + assert.equal(exchanges[0].url,'https://openapi.qoder.sh/api/v1/serviceToken/exchange'); + assert.deepEqual(await exchanges[0].json(), { grant_type:'client_credentials', audience:'qoder', scope:'qca.access forward.access', ttl_seconds:3600 }); + assert.equal(exchanges[0].headers.get('authorization'),'Bearer secret-key'); + for (const request of api) assert.equal(request.headers.get('authorization'),'Bearer sat-1'); + assert.throws(() => new sdk.ManagedClient({ credential, pat: 'static' }), sdk.QoderError); + assert.throws(() => new sdk.ManagedClient({ credential, baseURL: 'https://api.qoder.com.cn/api/v1/cloud/' }), /region/); + assert.equal(JSON.stringify(credential).includes('secret-key'),false); +}); + +test('refresh uses monotonic TTL, bounded fallback and failure cooldown', async () => { + let now = 1000, calls = 0, status = 200; + const clock = mock.method(performance, 'now', () => now); + try { + const credential = new sdk.ServiceAccountCredential(config(async () => { + calls++; + return status === 200 ? tokenResponse(`sat-${calls}`, '100') : response({ error: { code: 'temporary', message: 'secret-key' } }, status); + })); + assert.equal(await credential.getToken(),'sat-1'); + now += 89_000; + assert.equal(await credential.getToken(),'sat-1'); + assert.equal(calls,1); + now += 1000; + status = 503; + assert.equal(await credential.getToken(),'sat-1'); + assert.equal(await credential.getToken(),'sat-1'); + assert.equal(calls,2); + now += 5000; + await assert.rejects(credential.getToken(), sdk.InternalServerError); + assert.equal(calls,3); + now += 5000; + status = 200; + assert.equal(await credential.getToken(),'sat-4'); + now += 90_000; + status = 403; + await assert.rejects(credential.getToken(), e => e instanceof sdk.PermissionDeniedError && !String(e).includes('secret-key')); + await assert.rejects(credential.getToken(), sdk.PermissionDeniedError); + assert.equal(calls,5); + } finally { clock.mock.restore(); } +}); + +test('concurrent callers share success and failure; cancelled SDK waiter does not cancel exchange', async () => { + let release, calls = 0; + const gate = new Promise(resolve => { release = resolve; }); + const credential = new sdk.ServiceAccountCredential(config(async () => { calls++; await gate; return tokenResponse(); })); + const controller = new AbortController(); + const client = new sdk.ManagedClient({ credential, fetch: async () => response({ data: [] }) }); + const abandoned = client.agents.list({}, { signal: controller.signal }); + const pending = Array.from({length:12},() => credential.getToken()); + controller.abort(); + await assert.rejects(abandoned, sdk.APIUserAbortError); + release(); + assert.deepEqual(await Promise.all(pending),Array(12).fill('sat-1')); + assert.equal(calls,1); + calls = 0; + const failed = new sdk.ServiceAccountCredential(config(async () => { calls++; return response({},429, {'retry-after':'120'}); })); + const results = await Promise.allSettled(Array.from({length:12},() => failed.getToken())); + assert.equal(calls,1); + assert.ok(results.every(result => result.status === 'rejected' && result.reason instanceof sdk.RateLimitError)); + await assert.rejects(failed.getToken(),sdk.RateLimitError); + assert.equal(calls,1); +}); + +test('invalid lifetimes and configuration fail without leaking response tokens', async () => { + for (const ttl of [0,-1,1.5,'1.5',true,NaN,43201]) { + const credential = new sdk.ServiceAccountCredential(config(async () => tokenResponse('sensitive-response-token',ttl))); + await assert.rejects(credential.getToken(), e => e instanceof sdk.QoderError && !String(e).includes('sensitive-response-token')); + } + for (const options of [{region:'invalid'}, {scopes:[]}, {scopes:['has space']}, {ttlSeconds:0}, {timeout:0}, {saKey:''}]) { + assert.throws(() => new sdk.ServiceAccountCredential(config(async () => tokenResponse(),options)),sdk.QoderError); + } +}); + +test('CN exchange is explicit and 401 business writes are not replayed', async () => { + let exchanges = 0, writes = 0; + const credential = new sdk.ServiceAccountCredential(config(async (input) => { + assert.equal(String(input),'https://openapi.qoder.com.cn/api/v1/serviceToken/exchange'); + exchanges++; return tokenResponse(); + },{region:'cn'})); + const client = new sdk.ManagedClient({ credential, baseURL:'https://api.qoder.com.cn/api/v1/cloud/', fetch:async () => { writes++; return response({},401); } }); + await assert.rejects(client.request({method:'POST',path:'sessions',body:{}}),sdk.AuthenticationError); + assert.equal(writes,1); + assert.equal(exchanges,1); +}); + +test('short-lived responses must still be safe when exchange finishes', async () => { + let now = 0; + const clock = mock.method(performance, 'now', () => now); + try { + const credential = new sdk.ServiceAccountCredential(config(async () => { + now = 1000; + return tokenResponse('sensitive-token', 1); + })); + await assert.rejects(credential.getToken(), /insufficient remaining lifetime/); + } finally { clock.mock.restore(); } +}); + +test('exchange rejects malformed fields and sanitizes transport errors', async () => { + for (const body of [null, [], {}, {access_token:'token',token_type:'Basic',expires_in:100}, + ...[null, '', '2026-10-12'].map(expires_at => ({access_token:'token',token_type:'Bearer',expires_in:100,expires_at}))]) { + const credential = new sdk.ServiceAccountCredential(config(async () => response(body))); + await assert.rejects(credential.getToken(), /Invalid service account token exchange response/); + } + const credential = new sdk.ServiceAccountCredential(config(async () => { throw new sdk.QoderError('secret-key'); })); + await assert.rejects(credential.getToken(), error => error instanceof sdk.APIConnectionError && !String(error).includes('secret-key') && error.cause === undefined); +}); + +test('exchange timeout covers reading the response body', async () => { + const credential = new sdk.ServiceAccountCredential(config(async (_, init) => ({ + ok:true, status:200, + text:() => new Promise((_,reject) => init.signal.addEventListener('abort', () => reject(new Error('secret-key')), {once:true})), + }), {timeout:10})); + await assert.rejects(credential.getToken(), error => error instanceof sdk.APIConnectionTimeoutError && !String(error).includes('secret-key')); +}); diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index 272d696..ee01bbc 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -145,3 +145,12 @@ managed.vaults.search({}); // @ts-expect-error Scoped run queries require a deployment ID. managed.deployments.runs.list({}); + +import { ServiceAccountCredential } from 'qca-sdk'; +import { ServiceAccountCredential as ForwardSACredential } from 'qca-sdk/forward'; +import { ServiceAccountCredential as ManagedSACredential } from 'qca-sdk/managed'; +const saCredential = new ServiceAccountCredential({ saKey: 'test', region: 'global', scopes: ['qca.access'] }); +new ManagedClient({ credential: saCredential, authMode: 'sa' }); +new ForwardClient({ authMode: 'sa', accessToken: async () => 'sat' }).withOptions({ authMode: 'pat', pat: 'pat' }); +new Forward({ credential: new ForwardSACredential({ saKey: 'test', region: 'global', scopes: ['forward.access'] }) }); +new Managed({ credential: new ManagedSACredential({ saKey: 'test', region: 'global', scopes: ['qca.access'] }) });