Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cce2a9dac3 |
No files matched your search
+24
-9
@@ -1,16 +1,31 @@
|
||||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/reference/configuration-reference/
|
||||
version: 2.1
|
||||
# Build and test on CircleCI (as the other QRun-IO repos); GitHub Actions also
|
||||
# runs .github/workflows/ci.yml and publishes on version tags.
|
||||
|
||||
# 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
|
||||
jobs:
|
||||
build:
|
||||
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
|
||||
docker:
|
||||
- image: cimg/node:22.12
|
||||
# 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
|
||||
steps:
|
||||
# Checkout the code as the first step.
|
||||
- checkout
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
- 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
|
||||
workflows:
|
||||
build:
|
||||
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.
|
||||
jobs:
|
||||
- build
|
||||
- say-hello
|
||||
@@ -1 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.env
|
||||
.spec-src/
|
||||
@@ -1,137 +0,0 @@
|
||||
# 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
|
||||
Generated
-2971
File diff suppressed because it is too large.
Load diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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')
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/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
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* 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')
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/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
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* 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/)
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/*
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* 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'
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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'
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* 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 } })
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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}`)
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/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}`)
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user