Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 77bc4badce chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:10 -05:00
james.maesandClaude Opus 5.5 6df221e110 fix(conformance): jobs not enabled on a server is a skip, not a failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:25:45 -05:00
james.maesandClaude Opus 5.5 628c86d605 ci: CircleCI build and test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:20:50 -05:00
james.maesandClaude Opus 5.5 754eed4fe7 feat(client): generate from the merged UI API 1.6.0 document (core, esb, health and eight qbit extensions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:20:26 -05:00
james.maesandClaude Opus 5.5 7669b55b2a chore: LF line endings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:47:21 -05:00
james.maesandClaude Opus 5.5 ea79ddeb78 feat: reference CLI, generated UI API client, conformance suite and dev IdP
- @qrun-io/qqq-ui-api-client: typed client generated (openapi-typescript +
  openapi-fetch) from the merged UI API document; device-flow sign-in, refresh,
  UiApiError (BasicErrorResponseV1), SSE reader
- @qrun-io/qqq-cli: qqq login/whoami/apps/tables/query/get/run/jobs/tail,
  only through /qqq/v1 with Authorization: Bearer
- @qrun-io/qqq-ui-api-conformance: black-box suite + qqq-dev-idp
- scripts/merge-spec.mjs, Prism mock, CI and GitHub Packages publish workflows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:47:12 -05:00
33 changed files with 73019 additions and 24 deletions

No files matched your search

+9 -24
View File
@@ -1,31 +1,16 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference/
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
# Build and test on CircleCI (as the other QRun-IO repos); GitHub Actions also
# runs .github/workflows/ci.yml and publishes on version tags.
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
build:
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
- image: cimg/node:22.12
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
- run: npm ci
- run: npm run build
- run: npm test
workflows:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
build:
jobs:
- say-hello
- build
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+28
View File
@@ -0,0 +1,28 @@
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Generated client is up to date with the vendored document
run: |
npm run generate
git diff --exit-code packages/ui-api-client/src/schema.ts
- run: npm run build
- run: npm test
- name: Mock server smoke test (Prism on the merged document)
run: |
npx prism mock --port 4010 packages/ui-api-client/openapi/qqq-v1.json > prism.log 2>&1 &
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:4010/qqq/v1/version && break; sleep 1; done
curl -fsS http://127.0.0.1:4010/qqq/v1/metaData/authentication
+33
View File
@@ -0,0 +1,33 @@
name: publish
# Publishes the three packages to GitHub Packages (npm.pkg.github.com, scope @qrun-io)
# when a version tag (v*) is pushed. The tag must match the client package version.
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: '@qrun-io'
- run: npm ci
- run: npm run build
- run: npm test
- name: Tag matches the client version
run: test "v$(node -p "require('./packages/ui-api-client/package.json').version")" = "${GITHUB_REF_NAME}"
- run: npm publish -w @qrun-io/qqq-ui-api-client --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm publish -w @qrun-io/qqq-ui-api-conformance --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm publish -w @qrun-io/qqq-cli --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tgz
.env
.spec-src/
+137
View File
@@ -0,0 +1,137 @@
# qqq-cli
The reference command-line client for [QQQ](https://github.com/QRun-IO/qqq) applications, and the kit for writing your own CLI, TUI or native client. Everything here talks to a QQQ server only through the **UI API** (`/qqq/v1`), the API the web UI uses too.
| Package | What it is |
|---|---|
| [`@qrun-io/qqq-cli`](packages/cli) | `qqq`, the reference CLI: device-code sign-in, apps, tables, filtered queries, records, processes, jobs, and a live event stream |
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | A typed TypeScript client, generated from the merged UI API document (core plus extensions). It also does OAuth2 device-flow sign-in, token refresh, and reads the realtime (SSE) stream |
| [`@qrun-io/qqq-ui-api-conformance`](packages/conformance) | A black-box conformance suite you can run against any server, plus `qqq-dev-idp`, a local OIDC provider for testing native clients |
The packages are published to GitHub Packages:
```sh
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@rc
```
Requires Node.js 20 or later and a QQQ server with UI API 1.6.0 or later (QQQ 4.1.0-RC.15 or later).
## The CLI
```console
$ qqq login --server https://admin.example.com
To sign in to https://admin.example.com, open:
https://auth.example.com/device?code=ED84-CC03
and confirm the code ED84-CC03 (it expires in 10 minutes).
Waiting for approval...
Signed in to https://admin.example.com as darin@example.com.
$ qqq tables --app peopleApp
name label insert edit delete
------- -------- ------ ---- ------
person Person yes yes yes
pet Pet yes yes yes
$ qqq query person --where "firstName ~ a" --where "id < 100" --order -id --limit 5
Id First Name Last Name Annual Salary ...
5 Morgan Sample $1,500,000.00
3 Casey Sample $300,000.00
(2 records)
$ qqq get person 1
$ qqq run greetInteractive --records 1,2 --value greetingPrefix=Hi
$ qqq jobs --all
$ qqq tail --channel table:person
```
| Command | UI API operations |
|---|---|
| `login` | `getAuthenticationMetaData`, then the provider's device-authorization grant with `values.nativeClientId` |
| `whoami` | `getVersion`, `getMetaData` |
| `apps`, `tables [--app]` | `getMetaData` |
| `query <table> [--where "field OP value"]... [--or] [--order [-]field] [--limit n] [--fields a,b]` | `getTableMetaData`, `queryTable` |
| `get <table> <pk>` | `getTableMetaData`, `getRecord` |
| `run <process> [--value k=v]... [--records ids]` | `getProcessMetaData`, `initProcess`, `stepProcess`, `getProcessStatus` |
| `jobs [--all]` | `listJobs` |
| `tail [--channel ...]` | `openRealtimeStream`, `updateRealtimeSubscriptions` (`user:me`, plus your channels) |
Add `--json` for machine-readable output. Errors print the server's `message`, `code` and `traceId`.
Where things are kept:
- **Tokens** go in `~/.config/qqq-cli/credentials.json` (mode 0600), one entry per server. The CLI refreshes the access token at the provider before it expires, and once more on a 401.
- **`QQQ_TOKEN`** overrides the stored token with any bearer token, such as a QQQ access token exchanged for a JWT, or a session UUID.
- **`QQQ_SERVER`** sets the default server.
Every request carries `X-QQQ-Client: cli/qqq-cli <version>`, so the server's audits attribute it to the CLI.
## Writing a client
```ts
import { createUiApiClient, unwrap, getNativeSignInConfig, deviceSignIn } from '@qrun-io/qqq-ui-api-client'
const config = await getNativeSignInConfig('https://admin.example.com')
const tokens = await deviceSignIn(config, (p) => console.log(`Open ${p.verificationUriComplete}`))
const api = createUiApiClient({ baseUrl: 'https://admin.example.com', token: () => tokens.accessToken, clientChannel: 'tui/my-tui 1.0' })
const page = await unwrap(api.POST('/qqq/v1/table/{tableName}/query', {
params: { path: { tableName: 'person' } },
body: { filter: { criteria: [{ fieldName: 'lastName', operator: 'STARTS_WITH', values: ['K'] }], limit: 20 } },
}))
```
Paths, parameters, bodies and responses are typed from the document. Read the QQQ docs before you write a client:
- [`UI-API-CLIENTS.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CLIENTS.md) covers authentication, errors, widgets, processes, capabilities and validation.
- [`UI-API-CHANGELOG.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CHANGELOG.md) covers versioning.
## Mock server
`npm run mock` serves the merged document with [Prism](https://github.com/stoplightio/prism) on http://127.0.0.1:4010, so you can build a client without a server. Responses come from the document's examples and schemas.
## Conformance suite
```sh
qqq-ui-api-conformance --server https://admin.example.com # public checks only
qqq-ui-api-conformance --server https://admin.example.com --token "$T" # all read-only checks
```
The suite only reads. It never writes. It checks that:
- the version and the served document agree;
- every operation has a unique operationId and every `$ref` resolves;
- every extension the server lists has operations;
- requests without credentials or with forged tokens (`alg: none`, garbage) get a 401 with the error schema;
- metadata, table metadata (with `clientCapabilities`), query, count, record, jobs and realtime responses validate against the document;
- a 404 has the error schema, and `X-Request-Id` comes back as `traceId`.
## Testing a client locally
`qqq-dev-idp` is a small OIDC provider for **local development only**: device grant, refresh and RS256 JWTs, with one issuer per client, as Authentik does it. Run it with the QQQ sample app:
```sh
npx qqq-dev-idp --port 9400 --client sample-app --client sample-cli --user you@example.com --groups admins --auto-approve
# in a qqq checkout (QQQ 4.1.0-RC.15 or later)
export OAUTH2_BASE_URL=http://localhost:9400/application/o/sample-app/ OAUTH2_CLIENT_ID=sample-app OAUTH2_CLIENT_SECRET=dev \
OAUTH2_SCOPES="openid profile email offline_access" \
OAUTH2_NATIVE_CLIENT_ID=sample-cli OAUTH2_NATIVE_CLIENT_BASE_URL=http://localhost:9400/application/o/sample-cli/
./mvnw -f qqq-sample-project/pom.xml compile exec:java -Dexec.mainClass=com.kingsrook.sampleapp.SampleJavalinServer -Dqqq.sample.port=8077
qqq login --server http://localhost:8077
```
## Development
```sh
npm ci
npm run spec:merge -- <core openapi.yaml> <extension yaml>... # or a server's /qqq/v1/openapi.json
npm run generate # regenerate packages/ui-api-client/src/schema.ts
npm run build && npm test
```
Releases: push a tag `v<client version>`. The `publish` workflow publishes the three packages to GitHub Packages.
## License
Apache-2.0
+2971
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "qqq-cli-workspace",
"private": true,
"description": "Reference CLI, generated TypeScript client, mock server and conformance suite for the QQQ UI API (/qqq/v1).",
"license": "Apache-2.0",
"type": "module",
"engines": {
"node": ">=20"
},
"workspaces": [
"packages/ui-api-client",
"packages/conformance",
"packages/cli"
],
"scripts": {
"spec:merge": "node scripts/merge-spec.mjs --out packages/ui-api-client/openapi/qqq-v1.json",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json"
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@qrun-io/qqq-cli",
"version": "0.1.0-rc.1",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"license": "Apache-2.0",
"type": "module",
"bin": {
"qqq": "dist/main.js"
},
"files": [
"dist",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/cli"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.6.0-rc.1",
"commander": "15.0.0"
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { components } from "@qrun-io/qqq-ui-api-client"
type Json = Record<string, unknown>
type Operator = NonNullable<components["schemas"]["FilterCriteria"]["operator"]>
const OPERATORS = new Set([
'EQUALS', 'NOT_EQUALS', 'NOT_EQUALS_OR_IS_NULL', 'IN', 'NOT_IN', 'IS_NULL_OR_IN', 'LIKE', 'NOT_LIKE', 'STARTS_WITH', 'ENDS_WITH',
'CONTAINS', 'CONTAINS_ALL', 'NOT_STARTS_WITH', 'NOT_ENDS_WITH', 'NOT_CONTAINS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN',
'GREATER_THAN_OR_EQUALS', 'IS_BLANK', 'IS_NOT_BLANK', 'BETWEEN', 'NOT_BETWEEN', 'TRUE', 'FALSE',
])
const SHORTHAND: Record<string, string> = {
'=': 'EQUALS', '==': 'EQUALS', '!=': 'NOT_EQUALS', '<': 'LESS_THAN', '<=': 'LESS_THAN_OR_EQUALS', '>': 'GREATER_THAN',
'>=': 'GREATER_THAN_OR_EQUALS', '~': 'CONTAINS',
}
/** A filter criterion of the UI API (FilterCriteria). */
export interface Criterion {
fieldName: string
operator: Operator
values: string[]
}
/**
* Parses `field OPERATOR value[,value]` (operators as in FilterCriteria, or =, !=, <, <=, >, >=, ~).
*
* @param text - The criterion.
* @param fields - The table's fields (to check the name).
*/
export function parseCriteria(text: string, fields: Record<string, unknown>): Criterion {
const match = /^\s*(\S+)\s+(\S+)\s*(.*)$/.exec(text)
if (!match) throw new Error(`Criteria must be "field OPERATOR value": ${text}`)
const [, fieldName, rawOperator, rest] = match
const operator = SHORTHAND[rawOperator] ?? rawOperator.toUpperCase()
if (!OPERATORS.has(operator)) throw new Error(`Unknown operator ${rawOperator}; use one of ${[...OPERATORS].join(', ')}`)
if (Object.keys(fields).length && !(fieldName in fields)) throw new Error(`No field ${fieldName}; fields: ${Object.keys(fields).join(', ')}`)
const values = rest === '' ? [] : ['IN', 'NOT_IN', 'IS_NULL_OR_IN', 'BETWEEN', 'NOT_BETWEEN', 'CONTAINS_ALL'].includes(operator) ? rest.split(',').map((v) => v.trim()) : [rest.trim()]
return { fieldName, operator: operator as Operator, values }
}
/** Parses `name=value` pairs. */
export function parseValues(pairs: string[]): Record<string, string> {
const values: Record<string, string> = {}
for (const pair of pairs) {
const at = pair.indexOf('=')
if (at < 1) throw new Error(`Values are name=value: ${pair}`)
values[pair.slice(0, at)] = pair.slice(at + 1)
}
return values
}
/** A record's value as text: its display value when the server sent one. */
export function valueText(record: Json, field: string): string {
const display = (record.displayValues as Json | undefined)?.[field]
const raw = (record.values as Json | undefined)?.[field]
const value = display ?? raw
if (value === null || value === undefined) return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/**
* Formats rows as an aligned text table.
*
* @param rows - Objects.
* @param columns - Keys to show.
* @param headers - Optional header labels.
*/
export function formatTable(rows: Array<Record<string, unknown>>, columns: string[], headers: string[] = columns): string {
const cell = (v: unknown) => (v === null || v === undefined ? '' : String(v)).replace(/\s+/g, ' ').slice(0, 60)
const widths = columns.map((c, i) => Math.max(headers[i].length, ...rows.map((r) => cell(r[c]).length)))
const line = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd()
return [line(headers), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(columns.map((c) => cell(r[c]))))].join('\n')
}
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* qqq - a reference command-line client for QQQ applications. Every call is an operation of the
* QQQ UI API (/qqq/v1), through the generated @qrun-io/qqq-ui-api-client; nothing is hard-coded
* about an application: apps, tables, fields and processes all come from metadata.
*/
import { Command } from 'commander'
import {
UiApiError, decodeJwtClaims, deviceSignIn, openRealtimeStream, unwrap, UI_API_VERSION,
} from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, CLI_VERSION, clientFor, currentToken, getLogin, nativeConfig, removeLogin, resolveServer, saveLogin } from './session.js'
import { formatTable, parseCriteria, parseValues, valueText } from './format.js'
type Json = Record<string, unknown>
const program = new Command()
program
.name('qqq')
.description('Reference CLI for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`)
.option('-s, --server <url>', 'the QQQ server (default: QQQ_SERVER, or the last one signed in to)')
.option('--json', 'print JSON instead of tables')
function server(): string {
return resolveServer(program.opts().server)
}
function json(): boolean {
return Boolean(program.opts().json)
}
function print(data: unknown, table?: () => string): void {
console.log(json() || !table ? JSON.stringify(data, null, 2) : table())
}
program.command('login')
.description('Sign in with the device-authorization grant (approve the code in any browser).')
.action(async () => {
const origin = server()
const config = await nativeConfig(origin)
const tokens = await deviceSignIn(config, (prompt) => {
console.error(`To sign in to ${origin}, open:\n\n ${prompt.verificationUriComplete ?? prompt.verificationUri}\n`)
console.error(`and confirm the code ${prompt.userCode} (it expires in ${Math.round(prompt.expiresIn / 60)} minutes).`)
console.error('Waiting for approval...')
})
saveLogin(origin, { config, tokens })
const claims = decodeJwtClaims(tokens.accessToken)
console.error(`Signed in to ${origin} as ${claims.email ?? claims.preferred_username ?? claims.sub}.`)
})
program.command('logout')
.description('Forget this server\'s tokens.')
.action(() => {
const origin = server()
console.error(removeLogin(origin) ? `Signed out of ${origin}.` : `Not signed in to ${origin}.`)
})
program.command('whoami')
.description('Show the signed-in user, the server version and its extensions.')
.action(async () => {
const origin = server()
const client = clientFor(origin)
const version = await unwrap(client.GET('/qqq/v1/version')) as Json
const token = await currentToken(origin)
const claims = token && token.includes('.') ? decodeJwtClaims(token) : {}
const metaData = await unwrap(client.GET('/qqq/v1/metaData', {})) as Json
const login = getLogin(origin)
const result = {
server: origin,
user: claims.email ?? claims.preferred_username ?? claims.sub,
name: claims.name,
tokenExpires: login ? new Date(login.tokens.expiresAt).toISOString() : undefined,
apiVersion: version.apiVersion,
extensions: (version.extensions as Json[] | undefined)?.map((e) => `${e.name} ${e.version}`),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
}
print(result, () => Object.entries(result).map(([k, v]) => `${k.padEnd(13)} ${Array.isArray(v) ? v.join(', ') : v ?? ''}`).join('\n'))
})
program.command('apps')
.description('List the apps you may use.')
.action(async () => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
const apps = Object.values((metaData.apps ?? {}) as Record<string, Json>)
.map((a) => ({ name: a.name, label: a.label, items: (a.children as unknown[] | undefined)?.length ?? 0 }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(apps, () => formatTable(apps, ['name', 'label', 'items']))
})
program.command('tables')
.description('List the tables you may read (optionally of one app).')
.option('-a, --app <name>', 'only tables in this app')
.action(async (options: { app?: string }) => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
let tables = Object.values((metaData.tables ?? {}) as Record<string, Json>).filter((t) => !t.isHidden && t.readPermission !== false)
if (options.app) {
const app = (metaData.apps as Record<string, Json> | undefined)?.[options.app]
if (!app) throw new Error(`No app named ${options.app} (see \`qqq apps\`)`)
const names = new Set<unknown>()
const visit = (node: Json | undefined, seen: Set<unknown>) => {
for (const child of (node?.children ?? []) as Json[]) {
if (child.type === 'TABLE') names.add(child.name)
else if (child.type === 'APP' && !seen.has(child.name)) visit((metaData.apps as Record<string, Json>)[String(child.name)], seen.add(child.name))
}
}
visit(app, new Set([options.app]))
tables = tables.filter((t) => names.has(t.name))
}
const rows = tables.map((t) => ({ name: t.name, label: t.label, insert: t.insertPermission ? 'yes' : '', edit: t.editPermission ? 'yes' : '', delete: t.deletePermission ? 'yes' : '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(rows, () => formatTable(rows, ['name', 'label', 'insert', 'edit', 'delete']))
})
program.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]", e.g. "status EQUALS active" or "id GREATER_THAN 10".')
.option('-w, --where <criteria...>', 'criteria (ANDed)')
.option('--or', 'OR the criteria instead')
.option('-o, --order <field>', 'order by a field (prefix with - for descending)')
.option('-l, --limit <n>', 'most records to return', '25')
.option('-f, --fields <names>', 'comma-separated fields to show (default: the first few)')
.action(async (table: string, options: { where?: string[]; or?: boolean; order?: string; limit: string; fields?: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const criteria = (options.where ?? []).map((text) => parseCriteria(text, fieldMeta))
const order = options.order ? [{ fieldName: options.order.replace(/^-/, ''), isAscending: !options.order.startsWith('-') }] : undefined
const body = { filter: { criteria, booleanOperator: options.or ? 'OR' as const : 'AND' as const, orderBys: order, limit: Number(options.limit) } }
const result = await unwrap(client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: table } }, body })) as Json
const records = (result.records ?? []) as Json[]
const fields = options.fields ? options.fields.split(',') : defaultFields(meta)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)])))
print(json() ? records : rows, () => `${formatTable(rows, fields, fields.map((f) => String(fieldMeta[f]?.label ?? f)))}\n(${records.length} record${records.length === 1 ? '' : 's'})`)
})
program.command('get <table> <primaryKey>')
.description('View one record, with field labels and display values.')
.action(async (table: string, primaryKey: string) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const result = await unwrap(client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey } } })) as Json
const record = (result.record ?? result) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const rows = Object.keys(fieldMeta).filter((f) => !fieldMeta[f].isHidden)
.map((f) => ({ field: String(fieldMeta[f].label ?? f), value: valueText(record, f) }))
print(record, () => `${record.recordLabel ?? `${meta.label} ${primaryKey}`}\n${formatTable(rows, ['field', 'value'], ['Field', 'Value'])}`)
})
program.command('run <process>')
.description('Run a process: init, then each step until it completes; prints its results.')
.option('-v, --value <name=value...>', 'process input values')
.option('-r, --records <ids>', 'comma-separated primary keys of the process table\'s records to run on')
.option('--timeout <ms>', 'how long each step may run synchronously', '60000')
.action(async (processName: string, options: { value?: string[]; records?: string; timeout: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as Json
const steps = ((meta.frontendSteps ?? []) as Json[]).map((s) => String(s.name))
const values = parseValues(options.value ?? [])
const form = (extra: Record<string, string>) => {
const data = new FormData()
data.append('values', JSON.stringify(values))
data.append('stepTimeoutMillis', options.timeout)
for (const [k, v] of Object.entries(extra)) data.append(k, v)
return data
}
const records: Record<string, string> = options.records ? { recordsParam: 'recordIds', recordIds: options.records } : {}
let response = await unwrap(client.POST('/qqq/v1/processes/{processName}/init', {
params: { path: { processName } }, body: {} as never, bodySerializer: () => form(records),
})) as Json
console.error(`${meta.label ?? processName}: started (${response.processUUID})`)
for (let guard = 0; guard < 50; guard++) {
response = await waitForJob(client, processName, response)
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error))
const next = response.nextStep as string | undefined
if (!next) break
const isLast = steps.indexOf(next) === steps.length - 1
console.error(` step ${next}${isLast ? ' (results)' : ''}`)
if (isLast) break
response = await unwrap(client.POST('/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}', {
params: { path: { processName, processUUID: String(response.processUUID), stepName: next } }, body: {} as never, bodySerializer: () => form({}),
})) as Json
}
const out = (response.values ?? {}) as Json
const summary = (out.processResults ?? out.recordListSummary ?? []) as Json[]
if (json()) return print(response)
if (Array.isArray(summary) && summary.length) {
for (const line of summary) console.log(`[${line.status ?? 'INFO'}] ${line.count ?? ''} ${line.message ?? ''}`.replace(/\s+/g, ' ').trim())
} else {
const shown = Object.entries(out).filter(([, v]) => v !== null && typeof v !== 'object').slice(0, 20)
console.log(shown.length ? formatTable(shown.map(([k, v]) => ({ value: k, result: String(v) })), ['value', 'result']) : 'Done.')
}
})
program.command('jobs')
.description('List your jobs (durable and tracked processes).')
.option('--all', 'all states (default: active and recent)')
.action(async (options: { all?: boolean }) => {
let result: Json
try {
result = await unwrap(clientFor(server()).GET('/qqq/v1/jobs', { params: { query: { scope: 'mine', ...(options.all ? {} : { state: 'active' }) } as never } })) as Json
} catch (error) {
if (error instanceof UiApiError && error.status === 404) throw new Error('This server has no jobs operations (durable jobs are not enabled on it).')
throw error
}
const jobs = ((result.jobs ?? []) as Json[]).map((j) => ({ id: j.jobId, label: j.label ?? j.processName, status: j.status, percent: j.percent ?? '', message: j.message ?? '' }))
print(result, () => `${formatTable(jobs, ['id', 'label', 'status', 'percent', 'message'])}\n(${result.activeCount ?? 0} active)`)
})
program.command('tail')
.description('Follow your jobs and notifications live (realtime stream, channel user:me).')
.option('-c, --channel <channels...>', 'more channels, e.g. table:person or record:person:42')
.action(async (options: { channel?: string[] }) => {
const origin = server()
const client = clientFor(origin)
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
for await (const message of openRealtimeStream({ baseUrl: origin, token: () => currentToken(origin), clientChannel: CLIENT_CHANNEL, signal: controller.signal })) {
const data = (message.data ?? {}) as Json
if (message.event === 'hello') {
const connectionId = String(data.connectionId ?? (data.data as Json | undefined)?.connectionId ?? '')
const add = ['user:me', ...(options.channel ?? [])]
await unwrap(client.POST('/qqq/v1/realtime/subscriptions', { body: { connectionId, add } }))
console.error(`Following ${add.join(', ')} on ${origin} (Ctrl-C to stop).`)
continue
}
if (json()) {
console.log(JSON.stringify(message))
continue
}
const payload = (data.data ?? data) as Json
const time = new Date().toISOString().slice(11, 19)
const detail = payload.label ?? payload.title ?? payload.message ?? payload.jobId ?? ''
const progress = payload.percent !== undefined && payload.percent !== null ? ` ${payload.percent}%` : ''
console.log(`${time} ${message.event}${data.channel ? ` [${data.channel}]` : ''} ${detail}${progress}${payload.status ? ` ${payload.status}` : ''}`.trim())
}
})
/** The fields a query shows by default: the primary key and the next few visible fields. */
function defaultFields(meta: Json): string[] {
const fields = Object.values((meta.fields ?? {}) as Record<string, Json>).filter((f) => !f.isHidden && !f.isHeavy).map((f) => String(f.name))
const pk = String(meta.primaryKeyField ?? 'id')
return [pk, ...fields.filter((f) => f !== pk)].slice(0, 6)
}
/** Polls an asynchronous step (JOB_STARTED / RUNNING) until it completes. */
async function waitForJob(client: ReturnType<typeof clientFor>, processName: string, response: Json): Promise<Json> {
let current = response
while (current.type === 'JOB_STARTED' || current.type === 'RUNNING') {
const jobUUID = String(current.jobUUID ?? response.jobUUID)
await new Promise((resolve) => setTimeout(resolve, 1000))
current = await unwrap(client.GET('/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}', {
params: { path: { processName, processUUID: String(response.processUUID), jobUUID } },
})) as Json
if (current.type === 'RUNNING') console.error(` running ${current.current ?? ''}/${current.total ?? ''} ${current.message ?? ''}`)
current = { ...current, jobUUID, processUUID: response.processUUID }
}
return current
}
program.parseAsync().catch((error: unknown) => {
if (error instanceof UiApiError) {
console.error(`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`)
for (const fieldError of error.fieldErrors) console.error(` ${fieldError.field}: ${fieldError.message}`)
if (error.code === 'UNAUTHENTICATED') console.error('Sign in again with `qqq login`.')
} else {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
process.exitCode = 1
})
+122
View File
@@ -0,0 +1,122 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Credentials and the authenticated client for one server. Tokens are kept in
* ~/.config/qqq-cli/credentials.json (mode 0600), per server origin, or come from QQQ_TOKEN
* (any bearer token: an access token or a session UUID).
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createUiApiClient, getNativeSignInConfig, isExpiring, refreshTokens,
type NativeSignInConfig, type TokenSet, type UiApiClient,
} from '@qrun-io/qqq-ui-api-client'
export const CLI_VERSION = '0.1.0-rc.1'
export const CLIENT_CHANNEL = `cli/qqq-cli ${CLI_VERSION}`
/** What is stored per server. */
export interface StoredLogin {
config: NativeSignInConfig
tokens: TokenSet
}
interface Store {
defaultServer?: string
servers: Record<string, StoredLogin>
}
const dir = process.env.QQQ_CLI_HOME ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'qqq-cli')
const file = join(dir, 'credentials.json')
function readStore(): Store {
if (!existsSync(file)) return { servers: {} }
return JSON.parse(readFileSync(file, 'utf8')) as Store
}
function writeStore(store: Store): void {
mkdirSync(dir, { recursive: true, mode: 0o700 })
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 })
try {
chmodSync(file, 0o600)
} catch {
// not supported on every file system (e.g. some Windows volumes)
}
}
/** The server to use: --server, else QQQ_SERVER, else the last one signed in to. */
export function resolveServer(option?: string): string {
const server = option ?? process.env.QQQ_SERVER ?? readStore().defaultServer
if (!server) throw new Error('No server: pass --server https://... or set QQQ_SERVER (or run `qqq login --server ...`).')
return new URL(server).origin
}
/** Keeps a new sign-in for a server, and makes it the default server. */
export function saveLogin(server: string, login: StoredLogin): void {
const store = readStore()
store.servers[server] = login
store.defaultServer = server
writeStore(store)
}
/** Forgets a server's tokens. */
export function removeLogin(server: string): boolean {
const store = readStore()
const had = server in store.servers
delete store.servers[server]
if (store.defaultServer === server) delete store.defaultServer
writeStore(store)
return had
}
/** The stored sign-in of a server, if any. */
export function getLogin(server: string): StoredLogin | undefined {
return readStore().servers[server]
}
/** Fetches the native sign-in config of a server. */
export function nativeConfig(server: string): Promise<NativeSignInConfig> {
return getNativeSignInConfig(server)
}
/**
* Returns the current bearer token for a server, refreshing it at the provider when it is about to
* expire (QQQ_TOKEN overrides everything).
*
* @param server - Server origin.
* @param force - Refresh even when not expiring (after a 401).
*/
export async function currentToken(server: string, force = false): Promise<string | undefined> {
if (process.env.QQQ_TOKEN) return process.env.QQQ_TOKEN
const login = getLogin(server)
if (!login) return undefined
if ((force || isExpiring(login.tokens)) && login.tokens.refreshToken) {
const tokens = await refreshTokens(login.config, login.tokens.refreshToken)
saveLogin(server, { config: login.config, tokens })
return tokens.accessToken
}
return login.tokens.accessToken
}
/** An authenticated UI API client for a server (refreshes once on 401). */
export function clientFor(server: string): UiApiClient {
return createUiApiClient({
baseUrl: server,
clientChannel: CLIENT_CHANNEL,
token: () => currentToken(server),
onUnauthorized: async () => {
if (process.env.QQQ_TOKEN || !getLogin(server)?.tokens.refreshToken) return false
try {
await currentToken(server, true)
return true
} catch {
return false
}
},
})
}
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatTable, parseCriteria, parseValues, valueText } from '../format.js'
const fields = { id: {}, status: {}, lastName: {} }
test('criteria parse operators, shorthand and lists', () => {
assert.deepEqual(parseCriteria('status EQUALS active', fields), { fieldName: 'status', operator: 'EQUALS', values: ['active'] })
assert.deepEqual(parseCriteria('id >= 10', fields), { fieldName: 'id', operator: 'GREATER_THAN_OR_EQUALS', values: ['10'] })
assert.deepEqual(parseCriteria('id IN 1, 2,3', fields), { fieldName: 'id', operator: 'IN', values: ['1', '2', '3'] })
assert.deepEqual(parseCriteria('lastName ~ van der', fields), { fieldName: 'lastName', operator: 'CONTAINS', values: ['van der'] })
assert.deepEqual(parseCriteria('lastName IS_BLANK', fields), { fieldName: 'lastName', operator: 'IS_BLANK', values: [] })
assert.throws(() => parseCriteria('nope EQUALS 1', fields), /No field nope/)
assert.throws(() => parseCriteria('id ABOUT 1', fields), /Unknown operator/)
})
test('values parse name=value pairs', () => {
assert.deepEqual(parseValues(['a=1', 'b=x=y']), { a: '1', b: 'x=y' })
assert.throws(() => parseValues(['=1']))
})
test('display values win over raw values', () => {
assert.equal(valueText({ values: { s: 1 }, displayValues: { s: 'Active' } }, 's'), 'Active')
assert.equal(valueText({ values: { s: 1 } }, 's'), '1')
assert.equal(valueText({ values: {} }, 's'), '')
})
test('tables align columns', () => {
assert.equal(formatTable([{ a: 'x', b: 'long value' }], ['a', 'b']), 'a b\n- ----------\nx long value')
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@qrun-io/qqq-ui-api-conformance",
"version": "1.6.0-rc.1",
"description": "Black-box conformance suite for QQQ UI API (/qqq/v1) servers, plus qqq-dev-idp, a local OIDC provider for testing native clients.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/conformance.js",
"types": "dist/conformance.d.ts",
"bin": {
"qqq-ui-api-conformance": "dist/main.js",
"qqq-dev-idp": "dist/dev-idp.js"
},
"files": [
"dist",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/conformance"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
},
"dependencies": {
"ajv": "8.20.0"
}
}
+331
View File
@@ -0,0 +1,331 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* A black-box conformance suite for QQQ UI API servers (and, run against the Prism mock, for the
* document itself). Any client author can run it against a server to see that the server keeps the
* contract their client relies on: versioning, the published document, authentication (401s),
* the one error schema, and that real responses validate against the document's schemas.
*
* It only reads (GET, and POST queries/counts); it never writes.
*/
import { Ajv, type ValidateFunction } from 'ajv'
/** One check's outcome. */
export interface CheckResult {
name: string
ok: boolean
skipped?: boolean
detail?: string
}
/** Options of {@link runConformance}. */
export interface ConformanceOptions {
baseUrl: string
/** A bearer token (access token or session UUID); without one only the public checks run. */
token?: string
/** A table to read (default: the first readable table in the metadata). */
table?: string
fetch?: typeof fetch
log?: (result: CheckResult) => void
}
type Json = Record<string, any>
/**
* Runs the suite. Resolves with every result; `ok` is false for failures only.
*
* @param options - Server and token.
*/
export async function runConformance(options: ConformanceOptions): Promise<CheckResult[]> {
const fetchImpl = options.fetch ?? fetch
const results: CheckResult[] = []
const base = options.baseUrl.replace(/\/$/, '')
const record = (result: CheckResult) => {
results.push(result)
options.log?.(result)
}
const check = async (name: string, body: () => Promise<string | void>) => {
try {
const detail = await body()
record({ name, ok: true, detail: detail || undefined })
} catch (error) {
if (error instanceof Skip) record({ name, ok: true, skipped: true, detail: error.message })
else record({ name, ok: false, detail: error instanceof Error ? error.message : String(error) })
}
}
const call = async (method: string, path: string, init: { token?: string | null; body?: unknown; headers?: Record<string, string> } = {}) => {
const headers: Record<string, string> = { Accept: 'application/json', 'X-QQQ-Client': 'cli/qqq-ui-api-conformance', ...(init.headers ?? {}) }
const token = init.token === undefined ? options.token : init.token
if (token) headers.Authorization = `Bearer ${token}`
if (init.body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetchImpl(`${base}${path}`, { method, headers, body: init.body === undefined ? undefined : JSON.stringify(init.body) })
const text = await response.text()
let json: unknown
try {
json = text ? JSON.parse(text) : undefined
} catch {
json = undefined
}
return { status: response.status, headers: response.headers, json: json as Json, text }
}
/////////////////////////////////////////
// the document, version and validator //
/////////////////////////////////////////
let document: Json | undefined
let version: Json | undefined
let validator: SchemaValidator | undefined
await check('GET /qqq/v1/version answers a 1.x semantic version', async () => {
const r = await call('GET', '/qqq/v1/version', { token: null })
expect(r.status === 200, `status ${r.status}`)
version = r.json
expect(/^1\.\d+\.\d+$/.test(String(version?.apiVersion)), `apiVersion ${version?.apiVersion}`)
return `apiVersion ${version?.apiVersion}, ${(version?.extensions ?? []).length} extensions`
})
await check('GET /qqq/v1/openapi.json is the served, merged document', async () => {
const r = await call('GET', '/qqq/v1/openapi.json', { token: null })
expect(r.status === 200 && r.json?.paths, `status ${r.status}`)
document = r.json
expect(document!.info?.version === version?.apiVersion, `info.version ${document!.info?.version} != apiVersion ${version?.apiVersion}`)
validator = new SchemaValidator(document!)
return `${Object.keys(document!.paths).length} paths`
})
await check('every operation has a unique operationId (UI API >= 1.6.0)', async () => {
requireDocument(document)
if (!atLeast(version?.apiVersion, 1, 6)) throw new Skip(`server is UI API ${version?.apiVersion}`)
const seen = new Set<string>()
for (const [path, ops] of Object.entries<Json>(document!.paths)) {
for (const [method, op] of Object.entries<Json>(ops)) {
expect(op.operationId, `${method.toUpperCase()} ${path} has no operationId`)
expect(!seen.has(op.operationId), `duplicate operationId ${op.operationId}`)
seen.add(op.operationId)
}
}
return `${seen.size} operations`
})
await check('every $ref resolves', async () => {
requireDocument(document)
const dangling = new Set<string>()
JSON.stringify(document, (key, value) => {
if (key === '$ref' && typeof value === 'string' && value.startsWith('#/components/schemas/') && !document!.components?.schemas?.[value.slice(21)]) dangling.add(value)
return value
})
expect(dangling.size === 0, `dangling: ${[...dangling].join(', ')}`)
})
await check('every extension in /version has operations in the document', async () => {
requireDocument(document)
const paths = Object.keys(document!.paths)
const missing = (version?.extensions ?? []).filter((e: Json) => !paths.some((p) => p.startsWith(`/qqq/v1${e.pathPrefix}`)))
expect(missing.length === 0, `no operations for ${missing.map((e: Json) => e.name).join(', ')}`)
})
let authentication: Json | undefined
await check('GET /qqq/v1/metaData/authentication validates', async () => {
const r = await call('GET', '/qqq/v1/metaData/authentication', { token: null })
expect(r.status === 200, `status ${r.status}`)
authentication = r.json
validator?.response('get', '/qqq/v1/metaData/authentication', 200, r.json)
return `type ${authentication?.type}${authentication?.values?.nativeClientId ? `, native client ${authentication.values.nativeClientId}` : ''}`
})
const anonymous = authentication?.type === 'FULLY_ANONYMOUS' || authentication?.type === 'MOCK'
await check('a request without credentials answers 401 with the error schema', async () => {
if (anonymous) throw new Skip(`authentication type ${authentication?.type}`)
const r = await call('GET', '/qqq/v1/metaData', { token: null })
expect(r.status === 401, `status ${r.status}`)
expectError(r, 'UNAUTHENTICATED', validator, 'get', '/qqq/v1/metaData', 401)
})
await check('a forged bearer token answers 401', async () => {
if (anonymous) throw new Skip(`authentication type ${authentication?.type}`)
const forged = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 'admin', exp: 4102444800 })}.`
const r = await call('GET', '/qqq/v1/metaData', { token: forged })
expect(r.status === 401, `alg none: status ${r.status}`)
const garbage = await call('GET', '/qqq/v1/metaData', { token: 'not-a-token' })
expect(garbage.status === 401, `garbage: status ${garbage.status}`)
})
if (!options.token && !anonymous) {
record({ name: 'authenticated checks', ok: true, skipped: true, detail: 'no token given (--token or QQQ_TOKEN)' })
return results
}
let metaData: Json | undefined
await check('GET /qqq/v1/metaData validates', async () => {
const r = await call('GET', '/qqq/v1/metaData')
expect(r.status === 200, `status ${r.status} ${r.text.slice(0, 200)}`)
metaData = r.json
validator?.response('get', '/qqq/v1/metaData', 200, r.json)
return `${Object.keys(metaData?.apps ?? {}).length} apps, ${Object.keys(metaData?.tables ?? {}).length} tables`
})
const tableName = options.table ?? Object.values<Json>(metaData?.tables ?? {}).find((t) => t.readPermission !== false && !t.isHidden)?.name
let tableMeta: Json | undefined
await check('GET /qqq/v1/metaData/table/{tableName} validates (with clientCapabilities and field validation since 1.6.0)', async () => {
if (!tableName) throw new Skip('no readable table')
const r = await call('GET', `/qqq/v1/metaData/table/${encodeURIComponent(tableName)}`)
expect(r.status === 200, `status ${r.status}`)
tableMeta = r.json
validator?.response('get', '/qqq/v1/metaData/table/{tableName}', 200, r.json)
if (atLeast(version?.apiVersion, 1, 6)) expect(tableMeta?.clientCapabilities, 'no clientCapabilities')
return tableName
})
let firstRecord: Json | undefined
await check('POST /qqq/v1/table/{tableName}/query validates', async () => {
if (!tableName) throw new Skip('no readable table')
const r = await call('POST', `/qqq/v1/table/${encodeURIComponent(tableName)}/query`, { body: { filter: { limit: 3 } } })
expect(r.status === 200, `status ${r.status}`)
validator?.response('post', '/qqq/v1/table/{tableName}/query', 200, r.json)
firstRecord = r.json?.records?.[0]
return `${r.json?.records?.length ?? 0} records`
})
await check('POST /qqq/v1/table/{tableName}/count validates', async () => {
if (!tableName) throw new Skip('no readable table')
const r = await call('POST', `/qqq/v1/table/${encodeURIComponent(tableName)}/count`, { body: { filter: {} } })
expect(r.status === 200, `status ${r.status}`)
validator?.response('post', '/qqq/v1/table/{tableName}/count', 200, r.json)
return `count ${r.json?.count}`
})
await check('GET /qqq/v1/table/{tableName}/{primaryKey} validates', async () => {
const pk = firstRecord?.values?.[tableMeta?.primaryKeyField ?? 'id']
if (pk === undefined || pk === null) throw new Skip('no record to read')
const r = await call('GET', `/qqq/v1/table/${encodeURIComponent(tableName!)}/${encodeURIComponent(String(pk))}`)
expect(r.status === 200, `status ${r.status}`)
validator?.response('get', '/qqq/v1/table/{tableName}/{primaryKey}', 200, r.json)
})
await check('an unknown table answers 404 with the error schema', async () => {
const r = await call('GET', '/qqq/v1/metaData/table/no_such_table_conformance')
expect(r.status === 404, `status ${r.status}`)
expectError(r, 'NOT_FOUND', validator, 'get', '/qqq/v1/metaData/table/{tableName}', 404)
})
await check('X-Request-Id is echoed as the error traceId (UI API >= 1.6.0)', async () => {
if (!atLeast(version?.apiVersion, 1, 6)) throw new Skip(`server is UI API ${version?.apiVersion}`)
const r = await call('GET', '/qqq/v1/metaData/table/no_such_table_conformance', { headers: { 'X-Request-Id': 'conformance-trace-1' } })
expect(r.json?.traceId === 'conformance-trace-1', `traceId ${r.json?.traceId}`)
expect(r.headers.get('X-QQQ-Trace-Id') === 'conformance-trace-1', `header ${r.headers.get('X-QQQ-Trace-Id')}`)
})
await check('GET /qqq/v1/jobs validates', async () => {
if (!document?.paths?.['/qqq/v1/jobs']) throw new Skip('no jobs operation')
const r = await call('GET', '/qqq/v1/jobs?scope=mine')
if (r.status === 403) throw new Skip('no jobs permission')
if (r.status === 404) throw new Skip('jobs are not enabled on this server (no durable jobs tables)')
expect(r.status === 200, `status ${r.status}`)
validator?.response('get', '/qqq/v1/jobs', 200, r.json)
return `${r.json?.jobs?.length ?? 0} jobs`
})
await check('GET /qqq/v1/realtime/stream opens with a hello event', async () => {
if (!document?.paths?.['/qqq/v1/realtime/stream']) throw new Skip('no realtime operation')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
if (options.token) headers.Authorization = `Bearer ${options.token}`
const response = await fetchImpl(`${base}/qqq/v1/realtime/stream`, { headers, signal: controller.signal })
if (response.status === 404 || response.status === 503) throw new Skip(`realtime disabled (${response.status})`)
expect(response.ok && response.body, `status ${response.status}`)
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
let text = ''
while (!/event: ?hello/.test(text)) {
const { value, done } = await reader.read()
if (done) break
text += new TextDecoder().decode(value)
}
expect(/event: ?hello/.test(text), 'no hello event')
await reader.cancel()
} finally {
clearTimeout(timer)
controller.abort()
}
})
return results
}
/** A check that does not apply to this server. */
class Skip extends Error {}
function expect(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function requireDocument(document: Json | undefined): asserts document is Json {
if (!document) throw new Skip('no document')
}
function atLeast(version: unknown, major: number, minor: number): boolean {
const match = /^(\d+)\.(\d+)/.exec(String(version ?? ''))
return Boolean(match) && (Number(match![1]) > major || (Number(match![1]) === major && Number(match![2]) >= minor))
}
function b64(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
function expectError(r: { json: Json; headers: Headers }, code: string, validator: SchemaValidator | undefined, method: string, path: string, status: number): void {
expect(r.json && typeof r.json.error === 'string', 'error body has no "error" string')
if (r.json.code !== undefined) {
expect(r.json.code === code, `code ${r.json.code}, expected ${code}`)
expect(typeof r.json.traceId === 'string' && r.json.traceId, 'no traceId')
}
validator?.response(method, path, status, r.json)
}
/**
* Validates response bodies against the document's schemas (OpenAPI 3.0 -> JSON Schema: nullable
* becomes a null type; discriminators and formats are informational).
*/
export class SchemaValidator {
private readonly ajv = new Ajv({ strict: false, allErrors: false, validateFormats: false })
private readonly cache = new Map<string, ValidateFunction>()
constructor(private readonly document: Json) {
this.ajv.addSchema({ $id: 'oas', components: { schemas: convert(document.components?.schemas ?? {}) } })
}
/** Throws when a response body does not match the operation's declared schema. */
response(method: string, path: string, status: number, body: unknown): void {
const operation = this.document.paths?.[path]?.[method]
const schema = operation?.responses?.[String(status)]?.content?.['application/json']?.schema
if (!schema) return
const key = `${method} ${path} ${status}`
const validate = this.cache.get(key) ?? this.ajv.compile(convert(schema) as object)
this.cache.set(key, validate)
if (!validate(body)) {
const error = validate.errors?.[0]
throw new Error(`${key}: ${error?.instancePath || '(body)'} ${error?.message} ${error?.params ? JSON.stringify(error.params) : ''}`.trim())
}
}
}
function convert(node: unknown): unknown {
if (Array.isArray(node)) return node.map(convert)
if (!node || typeof node !== 'object') return node
const out: Json = {}
for (const [key, value] of Object.entries(node as Json)) {
if (key === '$ref' && typeof value === 'string' && value.startsWith('#/components/')) out.$ref = `oas${value}`
else if (key === 'nullable' || key === 'discriminator' || key === 'example' || key === 'examples' || key === 'deprecated' || key === 'xml') continue
else out[key] = convert(value)
}
if ((node as Json).nullable === true) {
if (typeof out.type === 'string') out.type = [out.type, 'null']
else if (out.$ref || out.allOf || out.oneOf || out.anyOf) return { anyOf: [out, { type: 'null' }] }
}
return out
}
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* qqq-dev-idp - a tiny OpenID Connect provider for LOCAL DEVELOPMENT AND TESTS ONLY, so a client
* author can run the whole native-client flow (device-authorization grant, refresh, bearer JWTs)
* against a local QQQ server without an identity provider account. Never expose it: it approves
* device codes for one configured user without a password.
*
* Like Authentik, every client has its own issuer, http://localhost:{port}/application/o/{clientId}/,
* with discovery and JWKS (RS256); the token and device endpoints are shared; GET /device?code=...
* approves a code (or start it with --auto-approve).
*
* qqq-dev-idp --port 9400 --client sample-app --client sample-cli --user dev@example.com --groups admins [--auto-approve]
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { createSign, generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto'
interface DeviceGrant {
clientId: string
scope: string
userCode: string
approved: boolean
expiresAt: number
}
/** Options of {@link startDevIdp}. */
export interface DevIdpOptions {
port: number
clients: string[]
user: { sub: string; email: string; name: string; preferredUsername: string; groups: string[] }
autoApprove?: boolean
accessTokenSeconds?: number
log?: (line: string) => void
}
/** A running dev provider. */
export interface DevIdp {
close: () => Promise<void>
issuer: (clientId: string) => string
approve: (userCode: string) => boolean
}
/**
* Starts the provider; resolves once it listens.
*
* @param options - Port, client ids (each gets an issuer), the user and whether codes auto-approve.
*/
export async function startDevIdp(options: DevIdpOptions): Promise<DevIdp> {
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const kid = randomUUID()
const jwk = { ...(publicKey.export({ format: 'jwk' }) as Record<string, string>), kid, alg: 'RS256', use: 'sig' }
const base = `http://localhost:${options.port}`
const issuer = (clientId: string) => `${base}/application/o/${clientId}/`
const devices = new Map<string, DeviceGrant>()
const refreshTokens = new Map<string, { clientId: string; scope: string }>()
const log = options.log ?? (() => undefined)
const sign = (clientId: string, scope: string): Record<string, unknown> => {
const now = Math.floor(Date.now() / 1000)
const lifetime = options.accessTokenSeconds ?? 600
const claims = {
iss: issuer(clientId), aud: clientId, sub: options.user.sub, iat: now, nbf: now, exp: now + lifetime, jti: randomUUID(),
email: options.user.email, email_verified: true, name: options.user.name, preferred_username: options.user.preferredUsername,
groups: options.user.groups, scope,
}
const encode = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
const input = `${encode({ alg: 'RS256', typ: 'JWT', kid })}.${encode(claims)}`
const signature = createSign('RSA-SHA256').update(input).sign(privateKey).toString('base64url')
const refresh = randomBytes(24).toString('base64url')
refreshTokens.set(refresh, { clientId, scope })
return { access_token: `${input}.${signature}`, token_type: 'Bearer', expires_in: lifetime, refresh_token: refresh, scope }
}
const approve = (userCode: string): boolean => {
for (const grant of devices.values()) {
if (grant.userCode === userCode.toUpperCase()) {
grant.approved = true
log(`approved ${userCode} for ${grant.clientId}`)
return true
}
}
return false
}
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? '/', base)
const send = (status: number, body: unknown, type = 'application/json') => {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' })
res.end(type === 'application/json' ? JSON.stringify(body) : String(body))
}
if (url.pathname === '/device') {
const code = url.searchParams.get('code')
if (code) return send(200, approve(code) ? `Approved ${code}. You can close this page.` : `Unknown code ${code}.`, 'text/plain')
return send(200, 'Open /device?code=XXXX-XXXX to approve a code.', 'text/plain')
}
if (req.method === 'POST' && (url.pathname === '/application/o/token/' || url.pathname === '/application/o/device/')) {
let text = ''
for await (const chunk of req) text += chunk
const body = new URLSearchParams(text)
const clientId = body.get('client_id') ?? ''
if (!options.clients.includes(clientId)) return send(401, { error: 'invalid_client' })
if (url.pathname === '/application/o/device/') {
const deviceCode = randomBytes(24).toString('base64url')
const userCode = `${randomBytes(2).toString('hex')}-${randomBytes(2).toString('hex')}`.toUpperCase()
devices.set(deviceCode, { clientId, scope: body.get('scope') ?? 'openid', userCode, approved: Boolean(options.autoApprove), expiresAt: Date.now() + 600_000 })
log(`device code ${userCode} for ${clientId}${options.autoApprove ? ' (auto-approved)' : ''}`)
return send(200, {
device_code: deviceCode, user_code: userCode, verification_uri: `${base}/device`,
verification_uri_complete: `${base}/device?code=${userCode}`, expires_in: 600, interval: 1,
})
}
const grantType = body.get('grant_type')
if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') {
const grant = devices.get(body.get('device_code') ?? '')
if (!grant || grant.clientId !== clientId || grant.expiresAt < Date.now()) return send(400, { error: 'expired_token' })
if (!grant.approved) return send(400, { error: 'authorization_pending' })
devices.delete(body.get('device_code') ?? '')
return send(200, sign(clientId, grant.scope))
}
if (grantType === 'refresh_token') {
const stored = refreshTokens.get(body.get('refresh_token') ?? '')
if (!stored || stored.clientId !== clientId) return send(400, { error: 'invalid_grant' })
refreshTokens.delete(body.get('refresh_token') ?? '')
return send(200, sign(clientId, stored.scope))
}
return send(400, { error: 'unsupported_grant_type' })
}
const match = /^\/application\/o\/([^/]+)\/(.*)$/.exec(url.pathname)
const clientId = match?.[1]
if (!match || !clientId || !options.clients.includes(clientId)) return send(404, { error: 'not_found' })
if (match[2] === '.well-known/openid-configuration') {
return send(200, {
issuer: issuer(clientId), jwks_uri: `${issuer(clientId)}jwks/`, token_endpoint: `${base}/application/o/token/`,
device_authorization_endpoint: `${base}/application/o/device/`, authorization_endpoint: `${base}/application/o/authorize/`,
response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'],
grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
})
}
if (match[2] === 'jwks/') return send(200, { keys: [jwk] })
return send(404, { error: 'not_found' })
})
await new Promise<void>((resolve) => server.listen(options.port, resolve))
log(`qqq-dev-idp on ${base}; issuers: ${options.clients.map(issuer).join(', ')}`)
return { close: () => new Promise<void>((resolve) => server.close(() => resolve())), issuer, approve }
}
/** CLI entry. */
async function main(): Promise<void> {
const args = process.argv.slice(2)
const value = (name: string, fallback: string) => {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : fallback
}
const clients = args.flatMap((a, i) => (a === '--client' ? [args[i + 1]] : []))
const email = value('--user', 'dev@example.com')
await startDevIdp({
port: Number(value('--port', '9400')),
clients: clients.length ? clients : ['sample-app', 'sample-cli'],
user: { sub: email, email, name: value('--name', 'Dev User'), preferredUsername: email.split('@')[0], groups: value('--groups', 'admins').split(',') },
autoApprove: args.includes('--auto-approve'),
accessTokenSeconds: Number(value('--token-seconds', '600')),
log: (line) => console.log(line),
})
}
if (process.argv[1] && /dev-idp(\.js)?$/.test(process.argv[1])) {
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* qqq-ui-api-conformance --server https://app.example.com [--token <bearer>] [--table person] [--json]
*
* Runs the black-box UI API conformance suite against a server (or the Prism mock). Exit code 1
* when any check fails.
*/
import { runConformance } from './conformance.js'
const args = process.argv.slice(2)
const value = (name: string) => {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : undefined
}
const server = value('--server') ?? process.env.QQQ_SERVER
if (!server) {
console.error('usage: qqq-ui-api-conformance --server <url> [--token <bearer>] [--table <name>] [--json]')
process.exit(2)
}
const asJson = args.includes('--json')
const results = await runConformance({
baseUrl: server,
token: value('--token') ?? process.env.QQQ_TOKEN,
table: value('--table'),
log: asJson ? undefined : (r) => console.log(`${r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? ` (${r.detail})` : ''}`),
})
const failed = results.filter((r) => !r.ok)
if (asJson) console.log(JSON.stringify({ server, passed: results.filter((r) => r.ok && !r.skipped).length, skipped: results.filter((r) => r.skipped).length, failed: failed.length, results }, null, 2))
else console.log(`\n${results.filter((r) => r.ok && !r.skipped).length} passed, ${results.filter((r) => r.skipped).length} skipped, ${failed.length} failed`)
process.exitCode = failed.length ? 1 : 0
@@ -0,0 +1,45 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { startDevIdp } from '../dev-idp.js'
import { SchemaValidator } from '../conformance.js'
test('dev IdP: discovery, device grant (manual approval) and refresh', async () => {
const idp = await startDevIdp({ port: 9488, clients: ['app', 'cli'], user: { sub: 'u@x.test', email: 'u@x.test', name: 'U', preferredUsername: 'u', groups: ['g'] } })
try {
const discovery = await (await fetch(`${idp.issuer('cli')}.well-known/openid-configuration`)).json() as Record<string, string>
assert.equal(discovery.issuer, 'http://localhost:9488/application/o/cli/')
const keys = await (await fetch(discovery.jwks_uri)).json() as { keys: unknown[] }
assert.equal(keys.keys.length, 1)
const form = (body: Record<string, string>) => ({ method: 'POST', body: new URLSearchParams(body) })
const start = await (await fetch(discovery.device_authorization_endpoint, form({ client_id: 'cli', scope: 'openid' }))).json() as Record<string, string>
const pending = await fetch(discovery.token_endpoint, form({ client_id: 'cli', grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: start.device_code }))
assert.equal(((await pending.json()) as Record<string, string>).error, 'authorization_pending')
assert.ok(idp.approve(start.user_code))
const tokens = await (await fetch(discovery.token_endpoint, form({ client_id: 'cli', grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: start.device_code }))).json() as Record<string, string>
const claims = JSON.parse(Buffer.from(tokens.access_token.split('.')[1], 'base64url').toString())
assert.equal(claims.aud, 'cli')
assert.equal(claims.iss, idp.issuer('cli'))
const refreshed = await fetch(discovery.token_endpoint, form({ client_id: 'cli', grant_type: 'refresh_token', refresh_token: tokens.refresh_token }))
assert.equal(refreshed.status, 200)
const replay = await fetch(discovery.token_endpoint, form({ client_id: 'cli', grant_type: 'refresh_token', refresh_token: tokens.refresh_token }))
assert.equal(replay.status, 400)
} finally {
await idp.close()
}
})
test('schema validator maps OpenAPI nullable and refs', () => {
const validator = new SchemaValidator({
paths: { '/x': { get: { responses: { 200: { content: { 'application/json': { schema: { $ref: '#/components/schemas/X' } } } } } } } },
components: { schemas: { X: { type: 'object', properties: { a: { type: 'string', nullable: true }, b: { type: 'integer' } } } } },
})
validator.response('get', '/x', 200, { a: null, b: 1 })
assert.throws(() => validator.response('get', '/x', 200, { a: 1 }), /must be string/)
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.6.0-rc.1",
"description": "Typed TypeScript client for the QQQ UI API (/qqq/v1), generated from the merged OpenAPI document (core + extensions), with OAuth2 device-flow sign-in, token refresh and a realtime (SSE) reader.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./openapi/qqq-v1.json": "./openapi/qqq-v1.json"
},
"files": [
"dist",
"openapi",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/ui-api-client"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"generate": "openapi-typescript openapi/qqq-v1.json --output src/schema.ts && node ../../scripts/fix-recursive-types.mjs src/schema.ts",
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
},
"dependencies": {
"openapi-fetch": "0.17.0"
},
"devDependencies": {
"openapi-typescript": "7.13.0"
}
}
+198
View File
@@ -0,0 +1,198 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Sign-in for native clients (CLI, TUI, desktop), as documented in the QQQ docs
* UI-API-CLIENTS.md section 7: read GET /qqq/v1/metaData/authentication, use the app's public
* native client (values.nativeClientId) at its issuer with the OAuth2 device-authorization grant
* (RFC 8628), then send the access token as `Authorization: Bearer`; refresh it at the provider.
*/
/** Tokens from the provider, with an absolute expiry. */
export interface TokenSet {
accessToken: string
refreshToken?: string
idToken?: string
/** Epoch milliseconds when the access token expires. */
expiresAt: number
scope?: string
}
/** What a native client needs to sign in to one QQQ server. */
export interface NativeSignInConfig {
/** The issuer (OIDC discovery base URL) of the native client. */
issuer: string
clientId: string
scope: string
}
/** The parts of an OIDC discovery document a native client uses. */
export interface ProviderEndpoints {
issuer: string
tokenEndpoint: string
deviceAuthorizationEndpoint?: string
authorizationEndpoint?: string
}
/** What to show the user while a device sign-in waits for approval. */
export interface DevicePrompt {
verificationUri: string
verificationUriComplete?: string
userCode: string
expiresIn: number
}
/**
* Reads the server's authentication metadata and returns the native client sign-in settings.
*
* @param baseUrl - The server origin, e.g. https://admin.example.com.
* @param fetchImpl - Optional fetch.
* @returns The native client config, or throws when the server has none.
*/
export async function getNativeSignInConfig(baseUrl: string, fetchImpl: typeof fetch = fetch): Promise<NativeSignInConfig> {
const response = await fetchImpl(new URL('/qqq/v1/metaData/authentication', baseUrl))
if (!response.ok) throw new Error(`GET /qqq/v1/metaData/authentication answered ${response.status}`)
const body = await response.json() as { type?: string; values?: Record<string, unknown> }
const values = body.values ?? {}
if (body.type !== 'OAUTH2') throw new Error(`The server uses ${body.type ?? 'unknown'} authentication; this client supports OAUTH2.`)
const clientId = typeof values.nativeClientId === 'string' ? values.nativeClientId : undefined
if (!clientId) throw new Error('The server publishes no native client (values.nativeClientId); it cannot be used from a CLI yet.')
const issuer = typeof values.nativeClientBaseUrl === 'string' ? values.nativeClientBaseUrl : String(values.baseUrl)
const scopes = new Set(String(values.scopes ?? 'openid profile email').split(/\s+/).filter(Boolean))
scopes.add('offline_access')
return { issuer, clientId, scope: [...scopes].join(' ') }
}
/**
* Reads the provider's OIDC discovery document.
*
* @param issuer - The issuer URL.
* @param fetchImpl - Optional fetch.
*/
export async function discoverProvider(issuer: string, fetchImpl: typeof fetch = fetch): Promise<ProviderEndpoints> {
const base = issuer.endsWith('/') ? issuer : `${issuer}/`
const response = await fetchImpl(new URL('.well-known/openid-configuration', base))
if (!response.ok) throw new Error(`OIDC discovery at ${base} answered ${response.status}`)
const doc = await response.json() as Record<string, string | undefined>
if (!doc.token_endpoint) throw new Error('The provider discovery document has no token_endpoint')
return {
issuer: doc.issuer ?? issuer,
tokenEndpoint: doc.token_endpoint,
deviceAuthorizationEndpoint: doc.device_authorization_endpoint,
authorizationEndpoint: doc.authorization_endpoint,
}
}
/**
* Signs in with the device-authorization grant (RFC 8628): asks the provider for a code, shows it
* through `onPrompt`, then polls the token endpoint until the user approves (or denies, or the code
* expires).
*
* @param config - Native client settings (see {@link getNativeSignInConfig}).
* @param onPrompt - Shows the verification URI and code to the user.
* @param options - Optional fetch, abort signal and sleep (for tests).
*/
export async function deviceSignIn(
config: NativeSignInConfig,
onPrompt: (prompt: DevicePrompt) => void | Promise<void>,
options: { fetch?: typeof fetch; signal?: AbortSignal; sleep?: (ms: number) => Promise<void> } = {},
): Promise<TokenSet> {
const fetchImpl = options.fetch ?? fetch
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
const endpoints = await discoverProvider(config.issuer, fetchImpl)
if (!endpoints.deviceAuthorizationEndpoint) throw new Error('The provider does not offer the device-authorization grant')
const start = await postForm(fetchImpl, endpoints.deviceAuthorizationEndpoint, { client_id: config.clientId, scope: config.scope }, options.signal)
if (!start.ok) throw new Error(`Device authorization failed: ${start.body.error_description ?? start.body.error ?? start.status}`)
const deviceCode = String(start.body.device_code)
let interval = Number(start.body.interval ?? 5)
const expiresIn = Number(start.body.expires_in ?? 600)
const deadline = Date.now() + expiresIn * 1000
await onPrompt({
verificationUri: String(start.body.verification_uri),
verificationUriComplete: start.body.verification_uri_complete ? String(start.body.verification_uri_complete) : undefined,
userCode: String(start.body.user_code),
expiresIn,
})
while (Date.now() < deadline) {
await sleep(interval * 1000)
if (options.signal?.aborted) throw new Error('Sign-in cancelled')
const poll = await postForm(fetchImpl, endpoints.tokenEndpoint, {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: config.clientId,
device_code: deviceCode,
}, options.signal)
if (poll.ok) return toTokenSet(poll.body)
const error = String(poll.body.error ?? '')
if (error === 'authorization_pending') continue
if (error === 'slow_down') {
interval += 5
continue
}
throw new Error(error === 'access_denied' ? 'Sign-in was denied' : error === 'expired_token' ? 'The code expired; sign in again' : `Sign-in failed: ${poll.body.error_description ?? error ?? poll.status}`)
}
throw new Error('The code expired; sign in again')
}
/**
* Refreshes the access token at the provider (refresh_token grant, public client).
*
* @param config - Native client settings.
* @param refreshToken - The current refresh token.
* @param fetchImpl - Optional fetch.
* @returns The new tokens (keep the new refresh token: providers may rotate it).
*/
export async function refreshTokens(config: NativeSignInConfig, refreshToken: string, fetchImpl: typeof fetch = fetch): Promise<TokenSet> {
const endpoints = await discoverProvider(config.issuer, fetchImpl)
const result = await postForm(fetchImpl, endpoints.tokenEndpoint, {
grant_type: 'refresh_token',
client_id: config.clientId,
refresh_token: refreshToken,
})
if (!result.ok) throw new Error(`Refreshing the token failed: ${result.body.error_description ?? result.body.error ?? result.status}`)
const tokens = toTokenSet(result.body)
return { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken }
}
/** True when the access token expires within `skewSeconds`. */
export function isExpiring(tokens: TokenSet, skewSeconds = 60): boolean {
return Date.now() + skewSeconds * 1000 >= tokens.expiresAt
}
/** The (unverified) claims of a JWT, for display only - the server is what verifies tokens. */
export function decodeJwtClaims(jwt: string): Record<string, unknown> {
const payload = jwt.split('.')[1]
if (!payload) return {}
return JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) as Record<string, unknown>
}
async function postForm(fetchImpl: typeof fetch, url: string, form: Record<string, string>, signal?: AbortSignal): Promise<{ ok: boolean; status: number; body: Record<string, unknown> }> {
const response = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams(form).toString(),
signal,
})
let body: Record<string, unknown> = {}
try {
body = await response.json() as Record<string, unknown>
} catch {
body = {}
}
return { ok: response.ok, status: response.status, body }
}
function toTokenSet(body: Record<string, unknown>): TokenSet {
if (typeof body.access_token !== 'string') throw new Error('The token response has no access_token')
return {
accessToken: body.access_token,
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,
idToken: typeof body.id_token === 'string' ? body.id_token : undefined,
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
scope: typeof body.scope === 'string' ? body.scope : undefined,
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import createClient, { type Client, type Middleware } from 'openapi-fetch'
import type { paths } from './schema.js'
import { UiApiError } from './errors.js'
/** The typed client: `client.GET('/qqq/v1/metaData')`, `client.POST('/qqq/v1/table/{tableName}/query', ...)`. */
export type UiApiClient = Client<paths>
/** Options for {@link createUiApiClient}. */
export interface UiApiClientOptions {
/** The server origin, e.g. https://admin.example.com (paths already include /qqq/v1). */
baseUrl: string
/** Returns the bearer token (an OAuth2 access token or a session UUID) for each request. */
token?: () => string | undefined | Promise<string | undefined>
/** Called once on a 401; return true when it refreshed the token, to retry the request. */
onUnauthorized?: () => boolean | Promise<boolean>
/** The client channel (X-QQQ-Client), e.g. `cli/qqq-cli 0.1.0`. */
clientChannel?: string
/** Optional fetch (tests, proxies). */
fetch?: typeof fetch
/** Extra headers on every request (e.g. a load-balancer affinity cookie). */
headers?: Record<string, string>
}
/**
* Creates a typed UI API client. Every operation of the merged document (core and extensions) is a
* typed call; errors come back as `error` (BasicErrorResponseV1) - use {@link unwrap} to throw
* {@link UiApiError} instead.
*
* @param options - Server, token and channel.
*/
export function createUiApiClient(options: UiApiClientOptions): UiApiClient {
const baseFetch = options.fetch ?? fetch
const authedFetch: typeof fetch = async (input, init) => {
const request = new Request(input as RequestInfo, init)
const send = async (): Promise<Response> => {
const attempt = request.clone()
const token = await options.token?.()
if (token) attempt.headers.set('Authorization', `Bearer ${token}`)
return baseFetch(attempt)
}
let response = await send()
if (response.status === 401 && options.onUnauthorized && await options.onUnauthorized()) {
response = await send()
}
return response
}
const client = createClient<paths>({ baseUrl: options.baseUrl.replace(/\/$/, ''), fetch: authedFetch })
const headers: Middleware = {
onRequest({ request }) {
if (options.clientChannel) request.headers.set('X-QQQ-Client', options.clientChannel)
for (const [name, value] of Object.entries(options.headers ?? {})) request.headers.set(name, value)
if (!request.headers.has('Accept')) request.headers.set('Accept', 'application/json')
return request
},
}
client.use(headers)
return client
}
/**
* Returns the data of a call, or throws {@link UiApiError} for an error answer.
*
* @param result - What a client call returned.
*/
export async function unwrap<T>(result: Promise<{ data?: T; error?: unknown; response: Response }> | { data?: T; error?: unknown; response: Response }): Promise<T> {
const { data, error, response } = await result
if (!response.ok || error !== undefined) {
throw new UiApiError(response.status, error, response.headers.get('X-QQQ-Trace-Id'))
}
return data as T
}
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** One error about a request field (BasicErrorResponseV1.fieldErrors). */
export interface UiApiFieldError {
field: string
message: string
code?: string
}
/**
* An error answer of the UI API. Every operation answers errors with BasicErrorResponseV1:
* `{ error, code, message, fieldErrors[], traceId }` (code, message, fieldErrors and traceId since
* UI API 1.6.0; older servers send only `error`).
*/
export class UiApiError extends Error {
readonly status: number
readonly code: string
readonly traceId?: string
readonly fieldErrors: UiApiFieldError[]
readonly body: unknown
constructor(status: number, body: unknown, traceIdHeader?: string | null) {
const b = (body && typeof body === 'object' ? body : {}) as Record<string, unknown>
const message = typeof b.message === 'string' ? b.message : typeof b.error === 'string' ? b.error : `HTTP ${status}`
super(message)
this.name = 'UiApiError'
this.status = status
this.code = typeof b.code === 'string' ? b.code : codeForStatus(status)
this.traceId = typeof b.traceId === 'string' ? b.traceId : traceIdHeader ?? undefined
this.fieldErrors = Array.isArray(b.fieldErrors) ? (b.fieldErrors as UiApiFieldError[]) : []
this.body = body
}
}
/** The well-known code for an HTTP status (what a pre-1.6.0 server would have meant). */
export function codeForStatus(status: number): string {
switch (status) {
case 400: return 'BAD_REQUEST'
case 401: return 'UNAUTHENTICATED'
case 403: return 'PERMISSION_DENIED'
case 404: return 'NOT_FOUND'
case 409: return 'CONFLICT'
case 429: return 'RATE_LIMITED'
case 500: return 'INTERNAL_ERROR'
default: return 'ERROR'
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
export { createUiApiClient, unwrap } from './client.js'
export type { UiApiClient, UiApiClientOptions } from './client.js'
export { UiApiError, codeForStatus } from './errors.js'
export type { UiApiFieldError } from './errors.js'
export { getNativeSignInConfig, discoverProvider, deviceSignIn, refreshTokens, isExpiring, decodeJwtClaims } from './auth.js'
export type { TokenSet, NativeSignInConfig, ProviderEndpoints, DevicePrompt } from './auth.js'
export { openRealtimeStream, parseEventBlock } from './realtime.js'
export type { RealtimeMessage, RealtimeStreamOptions } from './realtime.js'
export type { paths, components, operations } from './schema.js'
/** The UI API version this client was generated from (info.version of the merged document). */
export const UI_API_VERSION = '1.6.0'
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** One server-sent event of GET /qqq/v1/realtime/stream (RealtimeEventV1 in `data`). */
export interface RealtimeMessage {
id?: string
event: string
data: unknown
}
/** Options for {@link openRealtimeStream}. */
export interface RealtimeStreamOptions {
baseUrl: string
token?: () => string | undefined | Promise<string | undefined>
clientChannel?: string
lastEventId?: string
signal?: AbortSignal
fetch?: typeof fetch
}
/**
* Opens the realtime event stream and yields its events. The first event is `hello`, whose data
* carries the connectionId to send to POST /qqq/v1/realtime/subscriptions (the subscriptions belong
* to this connection and its session). Comment pings are skipped. The generator ends when the
* server closes the stream (e.g. `session.ended`) or the signal aborts.
*
* Works with bearer tokens, unlike the browser EventSource (which cannot send headers).
*
* @param options - Server, token and optional Last-Event-ID.
*/
export async function* openRealtimeStream(options: RealtimeStreamOptions): AsyncGenerator<RealtimeMessage> {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
const token = await options.token?.()
if (token) headers.Authorization = `Bearer ${token}`
if (options.clientChannel) headers['X-QQQ-Client'] = options.clientChannel
if (options.lastEventId) headers['Last-Event-ID'] = options.lastEventId
const response = await (options.fetch ?? fetch)(new URL('/qqq/v1/realtime/stream', options.baseUrl), { headers, signal: options.signal })
if (!response.ok || !response.body) throw new Error(`GET /qqq/v1/realtime/stream answered ${response.status}`)
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
buffer += decoder.decode(chunk, { stream: true })
let boundary: number
while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary).replace(/^\r?\n\r?\n/, '')
const message = parseEventBlock(block)
if (message) yield message
}
}
}
/**
* Parses one SSE block (lines up to a blank line). Returns undefined for comments-only blocks.
*
* @param block - The raw lines.
*/
export function parseEventBlock(block: string): RealtimeMessage | undefined {
let id: string | undefined
let event = 'message'
const data: string[] = []
for (const line of block.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
const colon = line.indexOf(':')
const field = colon < 0 ? line : line.slice(0, colon)
const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '')
if (field === 'id') id = value
else if (field === 'event') event = value
else if (field === 'data') data.push(value)
}
if (data.length === 0 && event === 'message') return undefined
const text = data.join('\n')
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : undefined
} catch {
parsed = text
}
return { id, event, data: parsed }
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createUiApiClient, unwrap } from '../client.js'
import { UiApiError } from '../errors.js'
import { deviceSignIn, refreshTokens, isExpiring } from '../auth.js'
import { parseEventBlock } from '../realtime.js'
function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } })
}
test('sends the bearer token and client channel, and unwraps data', async () => {
const seen: Request[] = []
const client = createUiApiClient({
baseUrl: 'https://app.example.com/',
token: () => 'tok-1',
clientChannel: 'cli/test',
fetch: async (input) => {
seen.push(input as Request)
return jsonResponse(200, { apiVersion: '1.6.0' })
},
})
const version = await unwrap(client.GET('/qqq/v1/version'))
assert.equal((version as { apiVersion: string }).apiVersion, '1.6.0')
assert.equal(seen[0].url, 'https://app.example.com/qqq/v1/version')
assert.equal(seen[0].headers.get('Authorization'), 'Bearer tok-1')
assert.equal(seen[0].headers.get('X-QQQ-Client'), 'cli/test')
})
test('an error answer becomes UiApiError with code, traceId and field errors', async () => {
const client = createUiApiClient({
baseUrl: 'https://app.example.com',
fetch: async () => jsonResponse(400, { error: 'Bad', code: 'BAD_REQUEST', message: 'Bad input', traceId: 't-1', fieldErrors: [{ field: 'name', message: 'required' }] }),
})
await assert.rejects(unwrap(client.GET('/qqq/v1/metaData', {})), (error: unknown) => {
assert.ok(error instanceof UiApiError)
assert.equal(error.status, 400)
assert.equal(error.code, 'BAD_REQUEST')
assert.equal(error.message, 'Bad input')
assert.equal(error.traceId, 't-1')
assert.equal(error.fieldErrors[0].field, 'name')
return true
})
})
test('a pre-1.6.0 error body (only "error") still maps, using the status code and trace header', async () => {
const client = createUiApiClient({ baseUrl: 'https://app.example.com', fetch: async () => jsonResponse(403, { error: 'Permission denied.' }, { 'X-QQQ-Trace-Id': 'h-1' }) })
await assert.rejects(unwrap(client.GET('/qqq/v1/metaData', {})), (error: unknown) => {
assert.ok(error instanceof UiApiError)
assert.equal(error.code, 'PERMISSION_DENIED')
assert.equal(error.message, 'Permission denied.')
assert.equal(error.traceId, 'h-1')
return true
})
})
test('a 401 refreshes once and retries with the new token', async () => {
let token = 'old'
let calls = 0
const client = createUiApiClient({
baseUrl: 'https://app.example.com',
token: () => token,
onUnauthorized: () => {
token = 'new'
return true
},
fetch: async (input) => {
calls++
return (input as Request).headers.get('Authorization') === 'Bearer new' ? jsonResponse(200, { apiVersion: '1.6.0' }) : jsonResponse(401, { error: 'expired', code: 'UNAUTHENTICATED' })
},
})
await unwrap(client.GET('/qqq/v1/version'))
assert.equal(calls, 2)
})
test('device sign-in polls through authorization_pending and slow_down', async () => {
const answers = [
jsonResponse(400, { error: 'authorization_pending' }),
jsonResponse(400, { error: 'slow_down' }),
jsonResponse(200, { access_token: 'a.b.c', refresh_token: 'r1', expires_in: 600 }),
]
const sleeps: number[] = []
let prompted = ''
const fetchImpl: typeof fetch = async (input) => {
const url = String(input)
if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(200, { issuer: 'https://idp/o/cli/', token_endpoint: 'https://idp/token', device_authorization_endpoint: 'https://idp/device' })
if (url === 'https://idp/device') return jsonResponse(200, { device_code: 'dc', user_code: 'ABCD-EFGH', verification_uri: 'https://idp/activate', verification_uri_complete: 'https://idp/activate?c=1', interval: 2, expires_in: 600 })
return answers.shift() as Response
}
const tokens = await deviceSignIn({ issuer: 'https://idp/o/cli/', clientId: 'cli', scope: 'openid' }, (p) => { prompted = p.userCode }, { fetch: fetchImpl, sleep: async (ms) => { sleeps.push(ms) } })
assert.equal(prompted, 'ABCD-EFGH')
assert.equal(tokens.accessToken, 'a.b.c')
assert.equal(tokens.refreshToken, 'r1')
assert.deepEqual(sleeps, [2000, 2000, 7000])
assert.equal(isExpiring(tokens), false)
})
test('device sign-in stops on access_denied', async () => {
const fetchImpl: typeof fetch = async (input) => {
const url = String(input)
if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(200, { token_endpoint: 'https://idp/token', device_authorization_endpoint: 'https://idp/device' })
if (url === 'https://idp/device') return jsonResponse(200, { device_code: 'dc', user_code: 'X', verification_uri: 'https://idp/a', interval: 1, expires_in: 60 })
return jsonResponse(400, { error: 'access_denied' })
}
await assert.rejects(deviceSignIn({ issuer: 'https://idp/', clientId: 'cli', scope: 'openid' }, () => undefined, { fetch: fetchImpl, sleep: async () => undefined }), /denied/)
})
test('refresh keeps the old refresh token when the provider does not rotate it', async () => {
const fetchImpl: typeof fetch = async (input) => String(input).endsWith('openid-configuration')
? jsonResponse(200, { token_endpoint: 'https://idp/token' })
: jsonResponse(200, { access_token: 'x.y.z', expires_in: 60 })
const tokens = await refreshTokens({ issuer: 'https://idp/', clientId: 'cli', scope: 'openid' }, 'r-old', fetchImpl)
assert.equal(tokens.refreshToken, 'r-old')
assert.equal(isExpiring(tokens, 120), true)
})
test('SSE blocks parse events and skip comments', () => {
assert.deepEqual(parseEventBlock('id: c:1\nevent: hello\ndata: {"connectionId":"c"}'), { id: 'c:1', event: 'hello', data: { connectionId: 'c' } })
assert.equal(parseEventBlock(': ping'), undefined)
assert.deepEqual(parseEventBlock('event: job.progress\ndata: {"jobId":1,\ndata: "percent":50}'), { id: undefined, event: 'job.progress', data: { jobId: 1, percent: 50 } })
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
// openapi-typescript emits a directly self-recursive union (e.g. SqlWorkbenchValueV1, whose arrays
// hold more values) as a one-line member that TypeScript rejects (TS2502). Replace the self
// reference on such a line with `unknown`, keeping the rest of the type.
import { readFileSync, writeFileSync } from 'node:fs'
const file = process.argv[2]
const lines = readFileSync(file, 'utf8').split('\n')
let fixed = 0
for (let i = 0; i < lines.length; i++) {
const match = /^(\s+)([A-Za-z0-9_]+): (.*);$/.exec(lines[i])
if (!match) continue
const self = `components["schemas"]["${match[2]}"]`
if (match[3].includes(self)) {
lines[i] = `${match[1]}${match[2]}: ${match[3].split(self).join('unknown')};`
fixed++
}
}
writeFileSync(file, lines.join('\n'))
console.log(`fixed ${fixed} self-recursive type(s) in ${file}`)
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Merge the QQQ UI API core document with extension documents into one OpenAPI document.
*
* Usage: node scripts/merge-spec.mjs --out <file.json> <core.yaml|url> [extension.yaml|url ...]
*
* Inputs are the committed documents (qqq: qqq-middleware-javalin/src/main/resources/openapi/v1/
* openapi.yaml; each qbit: src/main/resources/openapi/qqq-v1/{name}.yaml), local paths or URLs.
* A server's own GET /qqq/v1/openapi.json is already merged and can be used directly instead.
*
* Rules: paths and operations are unioned (a duplicate method on one path is an error); component
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1); the
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
* name and version is recorded in info.x-qqq-extensions.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { parse } from 'yaml'
const args = process.argv.slice(2)
const outIndex = args.indexOf('--out')
if (outIndex < 0 || args.length < outIndex + 2) {
console.error('usage: merge-spec.mjs --out <file.json> <core> [extensions...]')
process.exit(2)
}
const out = args[outIndex + 1]
const deriveMissing = args.includes('--derive-missing-ids')
const inputs = args.filter((a, i) => i !== outIndex && i !== outIndex + 1 && a !== '--derive-missing-ids')
/**
* The operationId QQQ derives for an operation without one (UiApiOperationIds.derive): the method,
* then each path segment in camel case, `{param}` as `By{Param}`, relative to /qqq/v1. Only for
* documents from servers before UI API 1.6.0 (--derive-missing-ids).
*/
function deriveOperationId(method, path) {
let id = method.toLowerCase()
for (let segment of path.replace(/^\/qqq\/v1/, '').split('/')) {
if (!segment) continue
if (segment.startsWith('{') && segment.endsWith('}')) {
id += 'By'
segment = segment.slice(1, -1)
}
for (const word of segment.split(/[^A-Za-z0-9]+/)) if (word) id += word[0].toUpperCase() + word.slice(1)
}
return id
}
async function load(source) {
const text = /^https?:\/\//.test(source) ? await (await fetch(source)).text() : await readFile(source, 'utf8')
return source.endsWith('.json') ? JSON.parse(text) : parse(text)
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable)
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]))
return value
}
const [coreSource, ...extensionSources] = inputs
const merged = await load(coreSource)
merged.paths ??= {}
merged.components ??= {}
merged.components.schemas ??= {}
merged.tags ??= []
const extensions = []
for (const source of extensionSources) {
const doc = await load(source)
const name = (doc.info?.title ?? '').replace(/^QQQ Middleware API extension: /, '') || source
extensions.push({ name, version: doc.info?.version, description: doc.info?.description })
for (const [path, operations] of Object.entries(doc.paths ?? {})) {
const target = (merged.paths[path] ??= {})
for (const [method, operation] of Object.entries(operations)) {
if (target[method]) throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
target[method] = operation
}
}
for (const [schemaName, schema] of Object.entries(doc.components?.schemas ?? {})) {
const existing = merged.components.schemas[schemaName]
if (existing && JSON.stringify(stable(existing)) !== JSON.stringify(stable(schema))) {
throw new Error(`schema ${schemaName} differs between the core document and ${name}`)
}
merged.components.schemas[schemaName] = schema
}
for (const tag of doc.tags ?? []) {
if (!merged.tags.some((t) => t.name === tag.name)) merged.tags.push(tag)
}
}
const operationIds = new Map()
for (const [path, operations] of Object.entries(merged.paths)) {
for (const [method, operation] of Object.entries(operations)) {
if (!operation.operationId && deriveMissing) operation.operationId = deriveOperationId(method, path)
const id = operation.operationId
if (!id) throw new Error(`no operationId: ${method.toUpperCase()} ${path}`)
if (operationIds.has(id)) throw new Error(`duplicate operationId ${id}: ${operationIds.get(id)} and ${method.toUpperCase()} ${path}`)
operationIds.set(id, `${method.toUpperCase()} ${path}`)
}
}
merged.info['x-qqq-extensions'] = extensions
await writeFile(out, JSON.stringify(merged, null, 2) + '\n')
console.log(`merged ${1 + extensionSources.length} documents: ${Object.keys(merged.paths).length} paths, ${operationIds.size} operations -> ${out}`)
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}